Files
web-terminal/relay-run/tests/browser-server.test.ts
Yaojia Wang bfe1be1dfe feat(relay): B7 — close browser DPoP-subprotocol loop + harden staging mint
- 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.
2026-07-06 16:26:05 +02:00

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)
})
})