fix(connection): fail the load on a trustedHosts entry that is not a bare authority
WHATWG parsing would quietly read a hostname out of harness.internal/path or user@harness.internal, authorizing the typo's hostname; other typos would sit silently ignored until requests 403. Refuse every URL part beyond host[:port] at plugin load.
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md
|
||||||
2026-07-28-api-browser-trust-boundary.md: 45a332fcfe59fb930a85cfc595dd02c5fe12a5d7
|
2026-07-28-api-browser-trust-boundary.md: 4dd913bb73da3b24073c020ff80fdfa83b44a812
|
||||||
2026-07-28-api-browser-trust-boundary.zh.md: 731d6c81f71a2f50b716e52e278f2c53ad62a04b
|
2026-07-28-api-browser-trust-boundary.zh.md: 0be817aca9dde68588959d2cd622639d90d9f993
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--hos
|
|||||||
Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs:
|
Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs:
|
||||||
|
|
||||||
- **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers.
|
- **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers.
|
||||||
- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. `host.pickDirectory` loses its bespoke guard and rides the same fence.
|
- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo. `host.pickDirectory` loses its bespoke guard and rides the same fence.
|
||||||
|
|
||||||
Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover.
|
Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover.
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho
|
|||||||
在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR:
|
在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR:
|
||||||
|
|
||||||
- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。
|
- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。
|
||||||
- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
|
- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
|
||||||
|
|
||||||
两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。
|
两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,8 @@ export interface ConnectionConfig {
|
|||||||
* port-less `host` matching any port. The /api trust fence refuses any
|
* port-less `host` matching any port. The /api trust fence refuses any
|
||||||
* browser request whose Host is neither loopback nor listed here, so a
|
* browser request whose Host is neither loopback nor listed here, so a
|
||||||
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
|
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
|
||||||
* by (the dsh CLI derives the machine's LAN IP literals itself).
|
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
|
||||||
|
* that is not a bare authority fails the plugin load.
|
||||||
*/
|
*/
|
||||||
trustedHosts?: string[]
|
trustedHosts?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||||
README.md: 94b9b3c8d4bde30cedf56e31d83efe9f5f1dd87c
|
README.md: 591e8361c1d28fab909bfe4a4f176fa1887edd93
|
||||||
README.zh.md: 844a2ef030378c32994f7459792db98c779f24b7
|
README.zh.md: bd772b2ab0f36abc8dbce35d30f55b40e53d0756
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a
|
|||||||
|
|
||||||
## /api browser-trust fence
|
## /api browser-trust fence
|
||||||
|
|
||||||
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare `host[:port]` authority fails the plugin load loudly — WHATWG parsing would otherwise quietly authorize the hostname inside a typo like `harness.internal/path`. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||||
|
|
||||||
## Keyless fixture
|
## Keyless fixture
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## /api 浏览器信任栅栏
|
## /api 浏览器信任栅栏
|
||||||
|
|
||||||
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯 `host[:port]` 权威的 `trustedHosts` 条目会让插件加载大声失败——否则 WHATWG 解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||||
|
|
||||||
## 无密钥 fixture
|
## 无密钥 fixture
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ function parseAuthority(authority: string): URL | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
|
||||||
|
* `host:port`) and nothing else. WHATWG parsing would quietly read a hostname
|
||||||
|
* out of `harness.internal/path` or `user@harness.internal` — a typo must fail
|
||||||
|
* the load loudly instead of authorizing its hostname or being ignored until
|
||||||
|
* requests 403. The delimiter test refuses every URL part beyond the authority
|
||||||
|
* (path, backslash path, query, fragment, userinfo); IPv6 brackets use none of
|
||||||
|
* them.
|
||||||
|
* @param entry - the configured value, verbatim.
|
||||||
|
*/
|
||||||
|
export function assertTrustedAuthority(entry: string): void {
|
||||||
|
if (parseAuthority(entry) !== undefined && !/[/\\?#@]/.test(entry)) return
|
||||||
|
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the request authority matches a `trustedHosts` entry. An entry with
|
* Whether the request authority matches a `trustedHosts` entry. An entry with
|
||||||
* an explicit port matches that exact authority; a port-less entry matches the
|
* an explicit port matches that exact authority; a port-less entry matches the
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|||||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||||
import { API_PATH } from './api-path.ts'
|
import { API_PATH } from './api-path.ts'
|
||||||
import { bridge } from './http-bridge.ts'
|
import { bridge } from './http-bridge.ts'
|
||||||
import { isTrustedApiRequest } from './api-request-trust.ts'
|
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||||
|
|
||||||
export { API_PATH } from './api-path.ts'
|
export { API_PATH } from './api-path.ts'
|
||||||
|
|
||||||
@@ -23,7 +23,8 @@ export interface ConnectionConfig {
|
|||||||
* port-less `host` matching any port. The /api trust fence refuses any
|
* port-less `host` matching any port. The /api trust fence refuses any
|
||||||
* browser request whose Host is neither loopback nor listed here, so a
|
* browser request whose Host is neither loopback nor listed here, so a
|
||||||
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
|
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
|
||||||
* by (the dsh CLI derives the machine's LAN IP literals itself).
|
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
|
||||||
|
* that is not a bare authority fails the plugin load.
|
||||||
*/
|
*/
|
||||||
trustedHosts?: string[]
|
trustedHosts?: string[]
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,9 @@ export const Config: z<ConnectionConfig> = z.object({
|
|||||||
export function apply(ctx: Context, config?: ConnectionConfig): void {
|
export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||||
// The Loader resolves schema defaults; hand-built test contexts may pass none.
|
// The Loader resolves schema defaults; hand-built test contexts may pass none.
|
||||||
const trustedHosts = config?.trustedHosts ?? []
|
const trustedHosts = config?.trustedHosts ?? []
|
||||||
|
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||||
|
// silently authorizing its hostname prefix at request time.
|
||||||
|
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||||
const route: WebRoute = {
|
const route: WebRoute = {
|
||||||
kind: 'prefix',
|
kind: 'prefix',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
|
/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { isTrustedApiRequest } from '../src/api-request-trust.ts'
|
import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
|
||||||
|
|
||||||
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
|
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
|
||||||
return { headers }
|
return { headers }
|
||||||
@@ -66,6 +66,17 @@ describe('isTrustedApiRequest', () => {
|
|||||||
expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
|
expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
|
||||||
|
for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
|
||||||
|
expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
|
||||||
|
}
|
||||||
|
// WHATWG parsing would quietly read a hostname out of each of these; the
|
||||||
|
// config boundary must refuse them instead of authorizing the prefix.
|
||||||
|
for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
|
||||||
|
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('refuses malformed or untrusted authorities on browser requests', () => {
|
it('refuses malformed or untrusted authorities on browser requests', () => {
|
||||||
const markers = { 'sec-fetch-site': 'same-origin' }
|
const markers = { 'sec-fetch-site': 'same-origin' }
|
||||||
expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
|
expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
|
||||||
|
|||||||
@@ -54,6 +54,30 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes:
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('connection node half', () => {
|
describe('connection node half', () => {
|
||||||
|
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
|
||||||
|
const routes: WebRoute[] = []
|
||||||
|
const ctx = new Context()
|
||||||
|
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||||
|
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||||
|
// The apply throw also escapes cordis as a late rejection — the shape the
|
||||||
|
// boot's installFailLoud is contracted to catch. Capture it so the run
|
||||||
|
// stays clean, same pattern as the webserver bind-failure test.
|
||||||
|
const rejections: unknown[] = []
|
||||||
|
const onUnhandled = (err: unknown): void => { rejections.push(err) }
|
||||||
|
process.on('unhandledRejection', onUnhandled)
|
||||||
|
try {
|
||||||
|
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
|
||||||
|
await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
|
||||||
|
expect(routes).toHaveLength(0)
|
||||||
|
for (let i = 0; i < 100 && rejections.length === 0; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
}
|
||||||
|
expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
|
||||||
|
} finally {
|
||||||
|
process.off('unhandledRejection', onUnhandled)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||||
const { routes, dispose } = await mounted()
|
const { routes, dispose } = await mounted()
|
||||||
expect(routes).toHaveLength(1)
|
expect(routes).toHaveLength(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user