- Read DPoP proof from term.dpop.<b64u> WS subprotocol (browsers can't set WS headers); header wins, else subprotocol. Fail-closed decoder. Unblocks real browser connect. - F1: rate-limit /auth/mint per-IP via Redis token bucket (salted-hash key, 429 on burst, before password compare). - F2: wire real per-tenant active WS count into activeSessionCount (was hardcoded 0). - F5: scrub error logs to e.message/.code (no DSN leak, INV9). relay-run: tsc clean, 92 tests pass (+18). F3/F4 -> Phase 2 backlog.
69 lines
3.1 KiB
TypeScript
69 lines
3.1 KiB
TypeScript
/**
|
|
* B7 · unit tests for `buildUpgradeRequest` — the browser upgrade → P1 `UpgradeRequest` builder.
|
|
* Focus: the DPoP proof transport (FUNCTIONAL BLOCKER). A browser cannot set the `dpop` request
|
|
* header, so the proof rides the `term.dpop.<b64u>` subprotocol entry; the builder must read it there
|
|
* while keeping the header path working (header WINS if both). Also pins `activeSessionCount` plumbing.
|
|
*/
|
|
import { describe, it, expect } from 'vitest'
|
|
import type { IncomingMessage } from 'node:http'
|
|
import { encodeBase64UrlString, APP_SUBPROTOCOL } from 'relay-contracts'
|
|
import { buildUpgradeRequest } from '../src/servers/browser-server.js'
|
|
import { DPOP_SUBPROTOCOL_PREFIX } from '../src/servers/dpop-subprotocol.js'
|
|
|
|
const PROOF = 'eyJhbGciOiJFZERTQSJ9.eyJodHUiOiJodHRwczovL2FsaWNlL3dzIn0.c2ln'
|
|
|
|
function dpopEntry(proofJws: string): string {
|
|
return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws)
|
|
}
|
|
|
|
function mkReq(headers: Record<string, string | undefined>): IncomingMessage {
|
|
return {
|
|
headers: { host: 'alice.example.com', ...headers },
|
|
url: '/ws',
|
|
socket: { remoteAddress: '1.2.3.4' },
|
|
} as unknown as IncomingMessage
|
|
}
|
|
|
|
describe('buildUpgradeRequest — DPoP transport', () => {
|
|
it('reads the proof from the term.dpop.<b64u> subprotocol when no header is set (the browser path)', () => {
|
|
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` })
|
|
const upgrade = buildUpgradeRequest(req, 0)
|
|
expect(upgrade.dpop.proof).toBe(PROOF)
|
|
})
|
|
|
|
it('prefers the dpop HEADER over the subprotocol entry when both are present (header wins)', () => {
|
|
const req = mkReq({
|
|
dpop: 'header-proof-jws',
|
|
'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}`,
|
|
})
|
|
expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-proof-jws')
|
|
})
|
|
|
|
it('still honours the dpop header alone (header path unchanged)', () => {
|
|
const req = mkReq({ dpop: 'header-only', 'sec-websocket-protocol': APP_SUBPROTOCOL })
|
|
expect(buildUpgradeRequest(req, 0).dpop.proof).toBe('header-only')
|
|
})
|
|
|
|
it('yields proof=null when neither header nor subprotocol carries a proof', () => {
|
|
const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL })
|
|
expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull()
|
|
})
|
|
|
|
it('yields proof=null (fail-closed) for a malformed term.dpop. subprotocol entry', () => {
|
|
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${DPOP_SUBPROTOCOL_PREFIX}not*b64u!` })
|
|
expect(buildUpgradeRequest(req, 0).dpop.proof).toBeNull()
|
|
})
|
|
|
|
it('parses the comma-separated subprotocol list and always carries the app subprotocol through', () => {
|
|
const req = mkReq({ 'sec-websocket-protocol': `${APP_SUBPROTOCOL}, ${dpopEntry(PROOF)}` })
|
|
expect(buildUpgradeRequest(req, 0).subprotocols).toContain(APP_SUBPROTOCOL)
|
|
})
|
|
})
|
|
|
|
describe('buildUpgradeRequest — activeSessionCount plumbing (F2)', () => {
|
|
it('carries the caller-supplied active-session count verbatim', () => {
|
|
const req = mkReq({ 'sec-websocket-protocol': APP_SUBPROTOCOL })
|
|
expect(buildUpgradeRequest(req, 3).activeSessionCount).toBe(3)
|
|
})
|
|
})
|