RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass): - A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script. - B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3). - B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14). - B3: store-backed RouteResolver (subdomain->hostId). - B4: Redis relay:revocations subscriber -> tunnel teardown (INV12). - B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint. - B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol. - C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps. - D1: same-origin static serve of relay-web/public from the browser WSS. - E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md. Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2); scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
127 lines
4.8 KiB
TypeScript
127 lines
4.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { readConfig } from '../src/config'
|
|
import { mountStagingLogin } from '../src/login-staging'
|
|
import type { DpopKey } from '../src/dpop'
|
|
|
|
const cfg = readConfig({
|
|
protocol: 'https:',
|
|
host: 'alice.term.example.com',
|
|
hostname: 'alice.term.example.com',
|
|
})
|
|
|
|
/** Deterministic DPoP key so the login test never touches WebCrypto. */
|
|
const fakeKey = (jkt: string): DpopKey => ({ jkt, proof: async () => 'h.p.s' })
|
|
const FAKE_JKT = 'A'.repeat(43)
|
|
|
|
function mintFetch(body: unknown, ok = true, status = 200): typeof fetch {
|
|
return vi.fn(async () => ({ ok, status, json: async () => body }) as Response) as unknown as typeof fetch
|
|
}
|
|
|
|
describe('mountStagingLogin (B6) — operator password → POST /auth/mint', () => {
|
|
let root: HTMLElement
|
|
beforeEach(() => {
|
|
root = document.createElement('div')
|
|
document.body.append(root)
|
|
})
|
|
|
|
it('correct password → "ok" and POSTs {password, jkt, subdomain} (INV3: a NAME, not an accountId)', async () => {
|
|
const fetchImpl = mintFetch({ token: 'CAPTOKEN' })
|
|
const onSuccess = vi.fn()
|
|
const login = mountStagingLogin(root, cfg, {
|
|
fetchImpl,
|
|
onSuccess,
|
|
createKey: async () => fakeKey(FAKE_JKT),
|
|
})
|
|
|
|
await expect(login.submit('s3cret')).resolves.toBe('ok')
|
|
|
|
const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]!
|
|
expect(call[0]).toBe('/auth/mint')
|
|
const init = call[1] as RequestInit
|
|
expect(init.method).toBe('POST')
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
password: 's3cret',
|
|
jkt: FAKE_JKT,
|
|
subdomain: 'alice',
|
|
})
|
|
// onSuccess receives the token + aud + the SAME dpop key whose jkt was minted.
|
|
const session = onSuccess.mock.calls[0]![0]
|
|
expect(session.token).toBe('CAPTOKEN')
|
|
expect(session.aud).toBe('alice')
|
|
expect(session.dpop.jkt).toBe(FAKE_JKT)
|
|
})
|
|
|
|
it('wrong password (401) → "rejected", no onSuccess, error via textContent (no innerHTML)', async () => {
|
|
const onSuccess = vi.fn()
|
|
const login = mountStagingLogin(root, cfg, {
|
|
fetchImpl: mintFetch({}, false, 401),
|
|
onSuccess,
|
|
createKey: async () => fakeKey(FAKE_JKT),
|
|
})
|
|
await expect(login.submit('nope')).resolves.toBe('rejected')
|
|
expect(onSuccess).not.toHaveBeenCalled()
|
|
const err = root.querySelector('.login-error') as HTMLElement
|
|
expect(err.textContent).toBe('Invalid operator password.')
|
|
expect(err.innerHTML).toBe('Invalid operator password.') // textContent-set, no markup
|
|
})
|
|
|
|
it('empty input keeps submit disabled and submit("") rejects without a fetch or a key', async () => {
|
|
const fetchImpl = mintFetch({ token: 'X' })
|
|
const createKey = vi.fn(async () => fakeKey(FAKE_JKT))
|
|
const login = mountStagingLogin(root, cfg, { fetchImpl, createKey })
|
|
expect((root.querySelector('button') as HTMLButtonElement).disabled).toBe(true)
|
|
await expect(login.submit('')).resolves.toBe('rejected')
|
|
expect(fetchImpl).not.toHaveBeenCalled()
|
|
expect(createKey).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('network error → "rejected" with a retry message', async () => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
throw new Error('offline')
|
|
}) as unknown as typeof fetch
|
|
const login = mountStagingLogin(root, cfg, { fetchImpl, createKey: async () => fakeKey(FAKE_JKT) })
|
|
await expect(login.submit('s3cret')).resolves.toBe('rejected')
|
|
expect((root.querySelector('.login-error') as HTMLElement).textContent).toBe(
|
|
'Network error — please retry.',
|
|
)
|
|
})
|
|
|
|
it('malformed mint response (no token) → "rejected", no onSuccess', async () => {
|
|
const onSuccess = vi.fn()
|
|
const login = mountStagingLogin(root, cfg, {
|
|
fetchImpl: mintFetch({ nope: true }),
|
|
onSuccess,
|
|
createKey: async () => fakeKey(FAKE_JKT),
|
|
})
|
|
await expect(login.submit('s3cret')).resolves.toBe('rejected')
|
|
expect(onSuccess).not.toHaveBeenCalled()
|
|
expect((root.querySelector('.login-error') as HTMLElement).textContent).toBe(
|
|
'Malformed response from the relay.',
|
|
)
|
|
})
|
|
|
|
it('crypto failure (key gen throws) → "rejected", no fetch issued', async () => {
|
|
const fetchImpl = mintFetch({ token: 'X' })
|
|
const login = mountStagingLogin(root, cfg, {
|
|
fetchImpl,
|
|
createKey: async () => {
|
|
throw new Error('no webcrypto')
|
|
},
|
|
})
|
|
await expect(login.submit('s3cret')).resolves.toBe('rejected')
|
|
expect(fetchImpl).not.toHaveBeenCalled()
|
|
expect((root.querySelector('.login-error') as HTMLElement).textContent).toBe(
|
|
'Secure crypto is unavailable in this browser.',
|
|
)
|
|
})
|
|
|
|
it('does not persist the password to localStorage', async () => {
|
|
const login = mountStagingLogin(root, cfg, {
|
|
fetchImpl: mintFetch({ token: 'X' }),
|
|
createKey: async () => fakeKey(FAKE_JKT),
|
|
})
|
|
await login.submit('s3cret')
|
|
expect(JSON.stringify(localStorage)).not.toContain('s3cret')
|
|
})
|
|
})
|