Files
dsh-market/tests/trusted-hosts.spec.ts
fkysly 2b8dbc8f10 fix(index): read the host's authorities per request, not once at mount (#729)
The read itself was right; it landed too early. `HostConnectionService` is
constructed behind `await BrowserAuth.create(...)`, so the `connection` service
does not exist yet when the market mounts on `inject(['webServer', 'loader'])`
-- both of those are ready first. The one read the market took therefore saw
`undefined`, and `Array.isArray(connection?.trustedHosts) ? … : []` turned "not
asked yet" into "none declared": that empty list was handed to
setTrustedHostsSource for the life of the process.

So 2b67f66's own subject is still true on a web host -- a deployment reached by
a name answers 403 `untrusted origin` to every mutation while reads and loopback
keep working, which is #729.

Measured on a real host (market 1.65.3): the mount-time read sees `undefined`,
and the same ctx read while handling a request sees `['<name>']`.

The fix is the idiom `agentsLookupOf` already uses two functions up -- resolve
at request time -- plus a one-time warn when the service is absent, because the
silent fallback is what made "we could not ask" and "nothing is declared" the
same fence and hid this for a release.

`useTrustedHosts` is exported for the spec that covers the wiring; the fence it
feeds stays covered through `sameOrigin` in tests/http.spec.ts, and the flows
case sets the source directly, so the two of them together could not see when
the market reads it.

Verified: 1821 unit tests over 75 files green, including the new
tests/trusted-hosts.spec.ts -- red against a mount-time read, green here;
typecheck (src, client, tests) clean; and end to end on a real DSH web host,
where a declared name reaches the handler (400 from the route's own body
validation), an undeclared name is still refused (403), loopback still works,
and a real mutation from the declared name answers {"ok":true,...}.
2026-09-25 23:51:30 +08:00

67 lines
3.2 KiB
TypeScript

/**
* The wiring that feeds the origin fence (#729).
*
* `tests/http.spec.ts` covers the fence itself and `tests/flows.spec.ts` drives
* real routes through it -- but both hand it its authorities with
* `setTrustedHostsSource(...)` directly, so neither can see WHEN the market
* reads them from the host. That is the whole bug: the read was correct and
* landed too early.
*
* The host provides `connection` behind its own async init, so the market's
* mount (`inject(['webServer', 'loader'])`) wins the race and the one read it
* took saw `undefined`. The [] fallback then made "not asked yet" and "none
* declared" the same fence, and every mutating route answered 403 on a
* deployment reached by a name for the life of the process.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { sameOrigin } from '../src/http.ts'
import { useTrustedHosts } from '../src/index.ts'
const request = (host: string) => ({ headers: { host, origin: `https://${host}` } }) as never
const installed: Array<() => void> = []
afterEach(() => { while (installed.length > 0) installed.pop()!() })
/** A host context whose services arrive when the host's init finishes. */
function contextWith(services: Record<string, unknown>) {
return { get: (name: string) => services[name] } as never
}
describe('the host authorities are read per request', () => {
it('picks the service up when it arrives after the market mounted', () => {
const services: Record<string, unknown> = {}
installed.push(useTrustedHosts(contextWith(services)))
// Mount time: the host has not published the service yet, so a name is not
// yet ours and the rebinding defence stays where it was.
expect(sameOrigin(request('dsh.example.org'))).toBe(false)
// The host's init lands. Nothing tells the market; the next request decides.
services.connection = { trustedHosts: ['dsh.example.org'] }
expect(sameOrigin(request('dsh.example.org'))).toBe(true)
expect(sameOrigin(request('still-undeclared.example.org'))).toBe(false)
// And it keeps listening: a declaration that changes later is honoured too.
services.connection = { trustedHosts: ['dsh.example.org:8443'] }
expect(sameOrigin(request('dsh.example.org:8443'))).toBe(true)
expect(sameOrigin(request('dsh.example.org'))).toBe(false)
})
it('falls back to loopback-only while the host has no such service', () => {
const services: Record<string, unknown> = {}
installed.push(useTrustedHosts(contextWith(services)))
expect(sameOrigin(request('127.0.0.1:3081'))).toBe(true)
expect(sameOrigin(request('dsh.example.org'))).toBe(false)
// A host service that exists but carries nothing valid is the same answer.
services.connection = { trustedHosts: 'dsh.example.org' }
expect(sameOrigin(request('dsh.example.org'))).toBe(false)
})
it('goes back to the previous source when its effect is disposed', () => {
const services: Record<string, unknown> = { connection: { trustedHosts: ['dsh.example.org'] } }
const restore = useTrustedHosts(contextWith(services))
expect(sameOrigin(request('dsh.example.org'))).toBe(true)
restore()
expect(sameOrigin(request('dsh.example.org'))).toBe(false)
})
})