feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
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).
This commit is contained in:
260
relay-run/tests/auth-mint.test.ts
Normal file
260
relay-run/tests/auth-mint.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* B5 · unit tests for the STAGING operator token-mint (`POST /auth/mint`) and the P5 capability
|
||||
* signing-key loader. Covers: the DPoP-bound short-lived token happy path (claims + PoP binding),
|
||||
* the deny-by-default gates (bad password / unknown+revoked subdomain / malformed body / wrong
|
||||
* method / oversized body), route claiming semantics, and PEM+base64 key loading round-trips.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { Readable } from 'node:stream'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { HostRecord } from 'control-plane/src/model/records.js'
|
||||
import { verifyPaseto } from 'relay-auth/src/crypto/paseto.js'
|
||||
import {
|
||||
createAuthMintRoute,
|
||||
loadSigningKeyFromEnv,
|
||||
type SubdomainHostLookup,
|
||||
} from '../src/servers/auth-mint.js'
|
||||
|
||||
const subtle = globalThis.crypto.subtle
|
||||
const NOW = 1_800_000_000
|
||||
const PASSWORD = 'staging-operator-secret'
|
||||
// A syntactically valid base64url SHA-256 JWK thumbprint (exactly 43 chars).
|
||||
const JKT = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO12'.padEnd(43, 'X').slice(0, 43)
|
||||
|
||||
interface Loaded {
|
||||
readonly signingKey: CryptoKey
|
||||
readonly publicKey: CryptoKey
|
||||
}
|
||||
|
||||
let keys: Loaded
|
||||
|
||||
beforeAll(async () => {
|
||||
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
|
||||
publicKey: CryptoKey
|
||||
privateKey: CryptoKey
|
||||
}
|
||||
keys = { signingKey: kp.privateKey, publicKey: kp.publicKey }
|
||||
})
|
||||
|
||||
function mkHost(overrides: Partial<HostRecord> = {}): HostRecord {
|
||||
return {
|
||||
hostId: 'host-1',
|
||||
accountId: 'acct-1',
|
||||
subdomain: 'alice',
|
||||
agentPubkey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr-host-1',
|
||||
status: 'online',
|
||||
lastSeen: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeHosts(rec: HostRecord | null): SubdomainHostLookup {
|
||||
return { getBySubdomain: async () => rec }
|
||||
}
|
||||
|
||||
interface CapturedRes {
|
||||
statusCode: number
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
headersSent: boolean
|
||||
writeHead(status: number, headers: Record<string, string>): CapturedRes
|
||||
end(chunk?: string): void
|
||||
}
|
||||
|
||||
function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise<void> } {
|
||||
let resolveDone!: () => void
|
||||
const done = new Promise<void>((r) => (resolveDone = r))
|
||||
const captured: CapturedRes = {
|
||||
statusCode: 0,
|
||||
headers: {},
|
||||
body: '',
|
||||
headersSent: false,
|
||||
writeHead(status, headers) {
|
||||
this.statusCode = status
|
||||
this.headers = headers
|
||||
this.headersSent = true
|
||||
return this
|
||||
},
|
||||
end(chunk?: string) {
|
||||
if (chunk !== undefined) this.body += chunk
|
||||
resolveDone()
|
||||
},
|
||||
}
|
||||
return { res: captured as unknown as ServerResponse, captured, done }
|
||||
}
|
||||
|
||||
function fakeReq(method: string, url: string, body?: string): IncomingMessage {
|
||||
const chunks = body === undefined ? [] : [Buffer.from(body, 'utf8')]
|
||||
const req = Readable.from(chunks) as unknown as IncomingMessage
|
||||
;(req as { method?: string }).method = method
|
||||
;(req as { url?: string }).url = url
|
||||
return req
|
||||
}
|
||||
|
||||
function mkRoute(hosts: SubdomainHostLookup): (req: IncomingMessage, res: ServerResponse) => boolean {
|
||||
return createAuthMintRoute({
|
||||
signingKey: keys.signingKey,
|
||||
hosts,
|
||||
operatorPassword: PASSWORD,
|
||||
now: () => NOW,
|
||||
onError: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
async function post(
|
||||
hosts: SubdomainHostLookup,
|
||||
bodyObj: unknown,
|
||||
): Promise<CapturedRes> {
|
||||
const route = mkRoute(hosts)
|
||||
const { res, captured, done } = fakeRes()
|
||||
const claimed = route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj)), res)
|
||||
expect(claimed).toBe(true)
|
||||
await done
|
||||
return captured
|
||||
}
|
||||
|
||||
describe('createAuthMintRoute — happy path', () => {
|
||||
it('mints a short-lived capability token bound to the client jkt (INV3 identity from store)', async () => {
|
||||
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice' })
|
||||
|
||||
expect(captured.statusCode).toBe(200)
|
||||
expect(captured.headers['cache-control']).toBe('no-store')
|
||||
const parsedBody = JSON.parse(captured.body) as { token: string }
|
||||
expect(typeof parsedBody.token).toBe('string')
|
||||
|
||||
const claims = (await verifyPaseto(parsedBody.token, keys.publicKey)) as {
|
||||
sub: string
|
||||
aud: string
|
||||
host: string
|
||||
rights: string[]
|
||||
iat: number
|
||||
exp: number
|
||||
cnf: { jkt: string }
|
||||
}
|
||||
// Identity is the STORE row's, never the request body's (INV3).
|
||||
expect(claims.sub).toBe('acct-1')
|
||||
expect(claims.host).toBe('host-1')
|
||||
expect(claims.aud).toBe('alice')
|
||||
expect(claims.rights).toContain('attach')
|
||||
// DPoP proof-of-possession binding to the client-provided thumbprint.
|
||||
expect(claims.cnf.jkt).toBe(JKT)
|
||||
// Short-lived (<= 60 s).
|
||||
expect(claims.iat).toBe(NOW)
|
||||
expect(claims.exp - claims.iat).toBeLessThanOrEqual(60)
|
||||
expect(claims.exp - claims.iat).toBeGreaterThan(0)
|
||||
// The token itself must never leak into logs — asserted by construction (no console here).
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAuthMintRoute — deny by default', () => {
|
||||
it('rejects a wrong password with 401 (no token)', async () => {
|
||||
const captured = await post(fakeHosts(mkHost()), { password: 'wrong', jkt: JKT, subdomain: 'alice' })
|
||||
expect(captured.statusCode).toBe(401)
|
||||
expect(captured.body).not.toContain('token')
|
||||
})
|
||||
|
||||
it('rejects an unknown subdomain with 404', async () => {
|
||||
const captured = await post(fakeHosts(null), { password: PASSWORD, jkt: JKT, subdomain: 'ghost' })
|
||||
expect(captured.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('rejects a revoked host with 403 (INV12: revoked never mints)', async () => {
|
||||
const captured = await post(
|
||||
fakeHosts(mkHost({ status: 'revoked', revokedAt: '2026-02-01T00:00:00.000Z' })),
|
||||
{ password: PASSWORD, jkt: JKT, subdomain: 'alice' },
|
||||
)
|
||||
expect(captured.statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects a malformed jkt with 400', async () => {
|
||||
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: 'too-short', subdomain: 'alice' })
|
||||
expect(captured.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a malformed subdomain with 400', async () => {
|
||||
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'Not_Valid!' })
|
||||
expect(captured.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects invalid JSON with 400', async () => {
|
||||
const route = mkRoute(fakeHosts(mkHost()))
|
||||
const { res, captured, done } = fakeRes()
|
||||
const claimed = route(fakeReq('POST', '/auth/mint', '{not json'), res)
|
||||
expect(claimed).toBe(true)
|
||||
await done
|
||||
expect(captured.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects an oversized body with 413', async () => {
|
||||
const big = 'x'.repeat(5000)
|
||||
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice', pad: big })
|
||||
expect(captured.statusCode).toBe(413)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAuthMintRoute — routing semantics', () => {
|
||||
it('claims /auth/mint but 405s a non-POST method', () => {
|
||||
const route = mkRoute(fakeHosts(mkHost()))
|
||||
const { res, captured } = fakeRes()
|
||||
const claimed = route(fakeReq('GET', '/auth/mint', undefined), res)
|
||||
expect(claimed).toBe(true)
|
||||
expect(captured.statusCode).toBe(405)
|
||||
})
|
||||
|
||||
it('does NOT claim a non-matching path (returns false, response untouched)', () => {
|
||||
const route = mkRoute(fakeHosts(mkHost()))
|
||||
const { res, captured } = fakeRes()
|
||||
const claimed = route(fakeReq('POST', '/index.html', undefined), res)
|
||||
expect(claimed).toBe(false)
|
||||
expect(captured.statusCode).toBe(0)
|
||||
})
|
||||
|
||||
it('claims /auth/mint even with a query string', () => {
|
||||
const route = mkRoute(fakeHosts(null))
|
||||
const { res } = fakeRes()
|
||||
const claimed = route(fakeReq('GET', '/auth/mint?foo=1', undefined), res)
|
||||
expect(claimed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSigningKeyFromEnv', () => {
|
||||
async function exportPkcs8Pem(): Promise<{ pem: string; b64: string; publicKey: CryptoKey }> {
|
||||
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
|
||||
publicKey: CryptoKey
|
||||
privateKey: CryptoKey
|
||||
}
|
||||
const der = new Uint8Array(await subtle.exportKey('pkcs8', kp.privateKey))
|
||||
const b64 = Buffer.from(der).toString('base64')
|
||||
const pem = `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join('\n')}\n-----END PRIVATE KEY-----\n`
|
||||
return { pem, b64, publicKey: kp.publicKey }
|
||||
}
|
||||
|
||||
async function canSign(priv: CryptoKey, pub: CryptoKey): Promise<boolean> {
|
||||
const data = new Uint8Array([1, 2, 3, 4])
|
||||
const sig = new Uint8Array(await subtle.sign({ name: 'Ed25519' }, priv, data))
|
||||
return subtle.verify({ name: 'Ed25519' }, pub, sig, data)
|
||||
}
|
||||
|
||||
it('loads a PKCS#8 PEM into a usable signing key', async () => {
|
||||
const { pem, publicKey } = await exportPkcs8Pem()
|
||||
const key = await loadSigningKeyFromEnv(pem)
|
||||
expect(await canSign(key, publicKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('loads a bare base64 PKCS#8 DER into a usable signing key', async () => {
|
||||
const { b64, publicKey } = await exportPkcs8Pem()
|
||||
const key = await loadSigningKeyFromEnv(b64)
|
||||
expect(await canSign(key, publicKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('throws on an empty value', async () => {
|
||||
await expect(loadSigningKeyFromEnv(' ')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws on a non-key value (never leaking material)', async () => {
|
||||
await expect(loadSigningKeyFromEnv('not-a-real-key')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
109
relay-run/tests/static-web.test.ts
Normal file
109
relay-run/tests/static-web.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for the pure static-file resolver (D1). Covers index resolution, MIME mapping, query
|
||||
* stripping, and the STRICT path-traversal guard (raw `..`, percent-encoded `%2e%2e`, and NUL byte
|
||||
* all reject — even when the escaped target file genuinely exists on disk).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { serveStatic } from '../src/servers/static-web.js'
|
||||
|
||||
const INDEX_HTML = '<!doctype html><title>idx</title>'
|
||||
const PAIR_HTML = '<!doctype html><title>pair</title>'
|
||||
const INDEX_JS = 'export const x = 1\n'
|
||||
const XTERM_CSS = 'body{margin:0}'
|
||||
const SECRET = 'TOP SECRET — outside root'
|
||||
|
||||
let base: string // temp parent dir (holds the secret sibling)
|
||||
let root: string // the static root passed to serveStatic
|
||||
|
||||
beforeAll(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'relay-static-'))
|
||||
root = join(base, 'public')
|
||||
mkdirSync(join(root, 'build'), { recursive: true })
|
||||
writeFileSync(join(root, 'index.html'), INDEX_HTML)
|
||||
writeFileSync(join(root, 'pair.html'), PAIR_HTML)
|
||||
writeFileSync(join(root, 'build', 'index.js'), INDEX_JS)
|
||||
writeFileSync(join(root, 'build', 'xterm.css'), XTERM_CSS)
|
||||
// A real file OUTSIDE root — the traversal target the guard must never reach.
|
||||
writeFileSync(join(base, 'secret.txt'), SECRET)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(base, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('serveStatic — index resolution', () => {
|
||||
it('serves index.html at "/"', () => {
|
||||
const res = serveStatic(root, '/')
|
||||
expect(res).not.toBeNull()
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
|
||||
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
|
||||
expect(res?.headers['content-length']).toBe(String(Buffer.byteLength(INDEX_HTML)))
|
||||
})
|
||||
|
||||
it('serves an explicit .html page', () => {
|
||||
const res = serveStatic(root, '/pair.html')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
|
||||
expect(res?.body.toString('utf8')).toBe(PAIR_HTML)
|
||||
})
|
||||
|
||||
it('strips the query string before resolving', () => {
|
||||
const res = serveStatic(root, '/index.html?join=abc123')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
|
||||
})
|
||||
|
||||
it('strips the hash fragment before resolving', () => {
|
||||
const res = serveStatic(root, '/#section')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serveStatic — MIME types', () => {
|
||||
it('maps /build/*.js to text/javascript', () => {
|
||||
const res = serveStatic(root, '/build/index.js')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.headers['content-type']).toBe('text/javascript; charset=utf-8')
|
||||
expect(res?.body.toString('utf8')).toBe(INDEX_JS)
|
||||
})
|
||||
|
||||
it('maps .css to text/css', () => {
|
||||
const res = serveStatic(root, '/build/xterm.css')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.headers['content-type']).toBe('text/css; charset=utf-8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serveStatic — traversal guard (returns null)', () => {
|
||||
it('blocks a raw ".." escape even though the target exists', () => {
|
||||
// Sanity: the target file really is readable outside root, so a null result proves the guard.
|
||||
expect(serveStatic(root, '/../secret.txt')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks a deep ".." escape', () => {
|
||||
expect(serveStatic(root, '/build/../../secret.txt')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks percent-encoded ".." (%2e%2e)', () => {
|
||||
expect(serveStatic(root, '/%2e%2e/secret.txt')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a NUL byte in the path', () => {
|
||||
expect(serveStatic(root, '/index.html%00.js')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('serveStatic — misses (returns null)', () => {
|
||||
it('returns null for a nonexistent file', () => {
|
||||
expect(serveStatic(root, '/nope.html')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a directory (no listing)', () => {
|
||||
expect(serveStatic(root, '/build')).toBeNull()
|
||||
})
|
||||
})
|
||||
158
relay-run/tests/wiring/mtls-verifier.test.ts
Normal file
158
relay-run/tests/wiring/mtls-verifier.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* B2 tests — registry-backed MtlsVerifier (INV4/INV14, fail-closed).
|
||||
*
|
||||
* Two layers, mirroring relay-auth/test/mtls.test.ts:
|
||||
* 1. REAL X.509 path: a self-signed root → leaf chain (relay-auth's committed mTLS fixtures) fed to
|
||||
* `verifyPeer` as DER bytes, exercising the production `defaultParseX509` chain walk + DER→PEM
|
||||
* conversion end-to-end against a fake host registry.
|
||||
* 2. Deterministic seam: an injected `ParseCert` isolates the DER→PEM conversion + registry-gating +
|
||||
* fail-closed mapping without depending on fixture contents.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { defaultParseX509, spiffeIdFor, type ParseCert, type ParsedCert } from 'relay-auth'
|
||||
import type { HostRegistryPort } from 'relay-auth'
|
||||
import { createMtlsVerifier, derToPem } from '../../src/wiring/mtls-verifier.js'
|
||||
import { fakeHostRegistry, makeHostRecord } from '../../src/wiring/memory-stores.js'
|
||||
|
||||
// Reuse the committed real mTLS fixtures (leaf chains to ca-chain; SPIFFE account/acct-A/host/host-1).
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '../../../relay-auth/test/fixtures/mtls')
|
||||
const readFixture = (name: string): string => readFileSync(join(FIXTURES, name), 'utf8')
|
||||
|
||||
const leafPem = readFixture('leaf.pem')
|
||||
const caChainPem = readFixture('ca-chain.pem')
|
||||
const foreignLeafPem = readFixture('foreign-leaf.pem')
|
||||
|
||||
const leafDer = new Uint8Array(new X509Certificate(leafPem).raw)
|
||||
const foreignDer = new Uint8Array(new X509Certificate(foreignLeafPem).raw)
|
||||
|
||||
// A time strictly inside the leaf's validity window (fixtures are long-lived; do not hardcode).
|
||||
const parsedLeaf = defaultParseX509(leafPem, caChainPem)
|
||||
const IN_WINDOW = parsedLeaf.notBefore + 60
|
||||
|
||||
const enrolledHosts = (): HostRegistryPort =>
|
||||
fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1')])
|
||||
|
||||
describe('derToPem (DER → PEM)', () => {
|
||||
it('wraps DER as a 64-column PEM CERTIFICATE block that round-trips back to the same DER', () => {
|
||||
const pem = derToPem(leafDer)
|
||||
expect(pem.startsWith('-----BEGIN CERTIFICATE-----\n')).toBe(true)
|
||||
expect(pem.trimEnd().endsWith('-----END CERTIFICATE-----')).toBe(true)
|
||||
// No base64 body line exceeds the PEM 64-column width.
|
||||
const body = pem.split('\n').slice(1, -2)
|
||||
for (const line of body) expect(line.length).toBeLessThanOrEqual(64)
|
||||
// Parsing the produced PEM yields byte-identical DER.
|
||||
expect(new Uint8Array(new X509Certificate(pem).raw)).toEqual(leafDer)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMtlsVerifier — real X.509 path (INV14)', () => {
|
||||
it('accepts an enrolled, in-date leaf chaining to the pinned CA → {hostId, accountId}', async () => {
|
||||
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
|
||||
expect(await v.verifyPeer(leafDer)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
|
||||
})
|
||||
|
||||
it('refuses a leaf signed by a foreign CA not in the pinned bundle → null', async () => {
|
||||
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
|
||||
expect(await v.verifyPeer(foreignDer)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a valid leaf whose host is NOT in the registry (INV4) → null', async () => {
|
||||
const v = createMtlsVerifier({ caChainPem, hosts: fakeHostRegistry([]), now: () => IN_WINDOW })
|
||||
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses when the registry account ≠ the cert SPIFFE account → null', async () => {
|
||||
const hosts = fakeHostRegistry([makeHostRecord('acct-B', 'host-1', 'sub-host-1')])
|
||||
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
|
||||
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a revoked host (INV12/registry gate) → null', async () => {
|
||||
const hosts = fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1', 'revoked')])
|
||||
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
|
||||
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses an expired leaf → null', async () => {
|
||||
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notAfter + 100 })
|
||||
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a not-yet-valid leaf → null', async () => {
|
||||
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notBefore - 100 })
|
||||
expect(await v.verifyPeer(leafDer)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMtlsVerifier — fail-closed on malformed / absent input', () => {
|
||||
it('returns null for an empty DER (no client cert presented)', async () => {
|
||||
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
|
||||
expect(await v.verifyPeer(new Uint8Array(0))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for garbage DER bytes that are not a certificate', async () => {
|
||||
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
|
||||
expect(await v.verifyPeer(new Uint8Array([1, 2, 3, 4, 5]))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMtlsVerifier — construction validation (INV14)', () => {
|
||||
it('throws when the CA bundle is empty', () => {
|
||||
expect(() => createMtlsVerifier({ caChainPem: '', hosts: enrolledHosts(), now: () => IN_WINDOW })).toThrow()
|
||||
})
|
||||
|
||||
it('throws when the CA bundle has no PEM CERTIFICATE block', () => {
|
||||
expect(() =>
|
||||
createMtlsVerifier({ caChainPem: 'not a certificate', hosts: enrolledHosts(), now: () => IN_WINDOW }),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Deterministic seam: isolate DER→PEM + registry gating from fixture contents ──────────────────
|
||||
const okParsed = (spiffeUri: string): ParsedCert => ({
|
||||
spiffeUri,
|
||||
notBefore: 0,
|
||||
notAfter: 4_000_000_000,
|
||||
chainValid: true,
|
||||
})
|
||||
|
||||
describe('createMtlsVerifier — ParseCert seam', () => {
|
||||
it('feeds verifyAgentCert the exact PEM produced by derToPem from the input DER', async () => {
|
||||
let seenLeafPem: string | null = null
|
||||
const capturingParse: ParseCert = (leaf) => {
|
||||
seenLeafPem = leaf
|
||||
return okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com'))
|
||||
}
|
||||
const der = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1])
|
||||
const v = createMtlsVerifier({
|
||||
caChainPem,
|
||||
hosts: enrolledHosts(),
|
||||
now: () => 1_000_000,
|
||||
parse: capturingParse,
|
||||
})
|
||||
expect(await v.verifyPeer(der)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
|
||||
expect(seenLeafPem).toBe(derToPem(der))
|
||||
})
|
||||
|
||||
it('fails closed (null) and reports to onError when the registry lookup throws', async () => {
|
||||
const errors: unknown[] = []
|
||||
const throwingHosts: HostRegistryPort = {
|
||||
getById: async () => {
|
||||
throw new Error('registry unavailable')
|
||||
},
|
||||
}
|
||||
const v = createMtlsVerifier({
|
||||
caChainPem,
|
||||
hosts: throwingHosts,
|
||||
now: () => 1_000_000,
|
||||
onError: (e) => errors.push(e),
|
||||
parse: () => okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com')),
|
||||
})
|
||||
expect(await v.verifyPeer(new Uint8Array([1, 2, 3]))).toBeNull()
|
||||
expect(errors).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
237
relay-run/tests/wiring/revocation-subscriber.test.ts
Normal file
237
relay-run/tests/wiring/revocation-subscriber.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts'
|
||||
import {
|
||||
startRevocationSubscriber,
|
||||
type ActiveTunnelRef,
|
||||
type RedisSubscriber,
|
||||
} from '../../src/wiring/revocation-subscriber.js'
|
||||
|
||||
/** Fake ioredis subscriber-mode client: records subscribe/unsubscribe and lets a test emit frames. */
|
||||
class FakeRedisSubscriber implements RedisSubscriber {
|
||||
readonly subscribed: string[] = []
|
||||
readonly unsubscribed: string[] = []
|
||||
private readonly handlers = new Set<(channel: string, message: string) => void>()
|
||||
|
||||
async subscribe(channel: string): Promise<number> {
|
||||
this.subscribed.push(channel)
|
||||
return this.subscribed.length
|
||||
}
|
||||
async unsubscribe(channel: string): Promise<number> {
|
||||
this.unsubscribed.push(channel)
|
||||
return this.unsubscribed.length
|
||||
}
|
||||
on(_event: 'message', listener: (channel: string, message: string) => void): this {
|
||||
this.handlers.add(listener)
|
||||
return this
|
||||
}
|
||||
off(_event: 'message', listener: (channel: string, message: string) => void): this {
|
||||
this.handlers.delete(listener)
|
||||
return this
|
||||
}
|
||||
/** Test driver: deliver a raw pub/sub frame to every registered listener. */
|
||||
emit(channel: string, message: string): void {
|
||||
for (const h of [...this.handlers]) h(channel, message)
|
||||
}
|
||||
get listenerCount(): number {
|
||||
return this.handlers.size
|
||||
}
|
||||
}
|
||||
|
||||
/** Fake relay node: a live-tunnel map + a closeStream that records + removes the host (models the
|
||||
* real whole-host teardown mutating the tunnel set, so snapshot-safety is exercised). */
|
||||
function makeNode(initial: readonly ActiveTunnelRef[]) {
|
||||
const tunnels = new Map(initial.map((t) => [t.hostId, t]))
|
||||
const closed: string[] = []
|
||||
return {
|
||||
closed,
|
||||
// Live iterator on purpose: the subscriber must snapshot before tearing down.
|
||||
activeTunnels: () => tunnels.values(),
|
||||
closeStream: (hostId: string): void => {
|
||||
closed.push(hostId)
|
||||
tunnels.delete(hostId)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const AT = 1_700_000_000
|
||||
|
||||
function killMessage(signal: KillSignal): string {
|
||||
return JSON.stringify(signal)
|
||||
}
|
||||
|
||||
describe('startRevocationSubscriber', () => {
|
||||
it('subscribes to the relay:revocations channel on start', () => {
|
||||
const redisSubscriber = new FakeRedisSubscriber()
|
||||
startRevocationSubscriber({ redisSubscriber, node: makeNode([]) })
|
||||
|
||||
expect(redisSubscriber.subscribed).toEqual([RELAY_REVOCATIONS_CHANNEL])
|
||||
expect(redisSubscriber.listenerCount).toBe(1)
|
||||
})
|
||||
|
||||
it('tears down only the host a host-scoped signal names', () => {
|
||||
const hostA = randomUUID()
|
||||
const hostB = randomUUID()
|
||||
const redisSubscriber = new FakeRedisSubscriber()
|
||||
const node = makeNode([
|
||||
{ hostId: hostA, accountId: randomUUID() },
|
||||
{ hostId: hostB, accountId: randomUUID() },
|
||||
])
|
||||
const onApplied = vi.fn()
|
||||
startRevocationSubscriber({ redisSubscriber, node, onApplied })
|
||||
|
||||
redisSubscriber.emit(
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
killMessage({ scope: { kind: 'host', hostId: hostA }, at: AT, reason: 'compromised' }),
|
||||
)
|
||||
|
||||
expect(node.closed).toEqual([hostA])
|
||||
expect(onApplied).toHaveBeenCalledTimes(1)
|
||||
expect(onApplied).toHaveBeenCalledWith(expect.objectContaining({ at: AT }), 1)
|
||||
})
|
||||
|
||||
it('tears down every host under an account-scoped signal, leaving other accounts running', () => {
|
||||
const acct1 = randomUUID()
|
||||
const acct2 = randomUUID()
|
||||
const hostA = randomUUID()
|
||||
const hostB = randomUUID()
|
||||
const hostC = randomUUID()
|
||||
const redisSubscriber = new FakeRedisSubscriber()
|
||||
const node = makeNode([
|
||||
{ hostId: hostA, accountId: acct1 },
|
||||
{ hostId: hostB, accountId: acct1 },
|
||||
{ hostId: hostC, accountId: acct2 },
|
||||
])
|
||||
startRevocationSubscriber({ redisSubscriber, node })
|
||||
|
||||
redisSubscriber.emit(
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
killMessage({ scope: { kind: 'account', accountId: acct1 }, at: AT, reason: 'billing' }),
|
||||
)
|
||||
|
||||
expect(node.closed.sort()).toEqual([hostA, hostB].sort())
|
||||
expect(node.closed).not.toContain(hostC)
|
||||
})
|
||||
|
||||
it('tears down every live host on a global-scoped signal', () => {
|
||||
const hosts = [
|
||||
{ hostId: randomUUID(), accountId: randomUUID() },
|
||||
{ hostId: randomUUID(), accountId: randomUUID() },
|
||||
{ hostId: randomUUID(), accountId: randomUUID() },
|
||||
]
|
||||
const redisSubscriber = new FakeRedisSubscriber()
|
||||
const node = makeNode(hosts)
|
||||
startRevocationSubscriber({ redisSubscriber, node })
|
||||
|
||||
redisSubscriber.emit(
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
killMessage({ scope: { kind: 'global' }, at: AT, reason: 'kill-switch' }),
|
||||
)
|
||||
|
||||
expect(node.closed.sort()).toEqual(hosts.map((h) => h.hostId).sort())
|
||||
})
|
||||
|
||||
it('is a no-op for a host-scoped signal naming a host this node does not serve', () => {
|
||||
const served = randomUUID()
|
||||
const redisSubscriber = new FakeRedisSubscriber()
|
||||
const node = makeNode([{ hostId: served, accountId: randomUUID() }])
|
||||
const onApplied = vi.fn()
|
||||
startRevocationSubscriber({ redisSubscriber, node, onApplied })
|
||||
|
||||
redisSubscriber.emit(
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
killMessage({ scope: { kind: 'host', hostId: randomUUID() }, at: AT, reason: 'other-node' }),
|
||||
)
|
||||
|
||||
expect(node.closed).toEqual([])
|
||||
expect(onApplied).toHaveBeenCalledWith(expect.anything(), 0)
|
||||
})
|
||||
|
||||
it('drops a malformed (non-JSON) message: no teardown, counted as dropped', () => {
|
||||
const redisSubscriber = new FakeRedisSubscriber()
|
||||
const node = makeNode([{ hostId: randomUUID(), accountId: randomUUID() }])
|
||||
const onDropped = vi.fn()
|
||||
const onApplied = vi.fn()
|
||||
startRevocationSubscriber({ redisSubscriber, node, onDropped, onApplied })
|
||||
|
||||
redisSubscriber.emit(RELAY_REVOCATIONS_CHANNEL, '{ this is not json')
|
||||
|
||||
expect(node.closed).toEqual([])
|
||||
expect(onDropped).toHaveBeenCalledTimes(1)
|
||||
expect(onApplied).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a schema-invalid signal (non-uuid host / missing fields): no teardown', () => {
|
||||
const redisSubscriber = new FakeRedisSubscriber()
|
||||
const node = makeNode([{ hostId: randomUUID(), accountId: randomUUID() }])
|
||||
const onDropped = vi.fn()
|
||||
startRevocationSubscriber({ redisSubscriber, node, onDropped })
|
||||
|
||||
// hostId is not a UUID → RevocationScopeSchema rejects.
|
||||
redisSubscriber.emit(
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
JSON.stringify({ scope: { kind: 'host', hostId: 'not-a-uuid' }, at: AT, reason: 'x' }),
|
||||
)
|
||||
// Missing `at` → KillSignalSchema rejects.
|
||||
redisSubscriber.emit(
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
JSON.stringify({ scope: { kind: 'global' }, reason: 'x' }),
|
||||
)
|
||||
|
||||
expect(node.closed).toEqual([])
|
||||
expect(onDropped).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores messages published on a different channel', () => {
|
||||
const hostA = randomUUID()
|
||||
const redisSubscriber = new FakeRedisSubscriber()
|
||||
const node = makeNode([{ hostId: hostA, accountId: randomUUID() }])
|
||||
const onDropped = vi.fn()
|
||||
const onApplied = vi.fn()
|
||||
startRevocationSubscriber({ redisSubscriber, node, onDropped, onApplied })
|
||||
|
||||
redisSubscriber.emit(
|
||||
'some:other:channel',
|
||||
killMessage({ scope: { kind: 'host', hostId: hostA }, at: AT, reason: 'wrong-channel' }),
|
||||
)
|
||||
|
||||
expect(node.closed).toEqual([])
|
||||
expect(onDropped).not.toHaveBeenCalled()
|
||||
expect(onApplied).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('close() removes the message listener, unsubscribes, and is idempotent', () => {
|
||||
const hostA = randomUUID()
|
||||
const redisSubscriber = new FakeRedisSubscriber()
|
||||
const node = makeNode([{ hostId: hostA, accountId: randomUUID() }])
|
||||
const sub = startRevocationSubscriber({ redisSubscriber, node })
|
||||
|
||||
sub.close()
|
||||
sub.close() // idempotent — no throw, no double-unsubscribe
|
||||
|
||||
expect(redisSubscriber.unsubscribed).toEqual([RELAY_REVOCATIONS_CHANNEL])
|
||||
expect(redisSubscriber.listenerCount).toBe(0)
|
||||
|
||||
// A frame delivered after close must not tear anything down.
|
||||
redisSubscriber.emit(
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
killMessage({ scope: { kind: 'host', hostId: hostA }, at: AT, reason: 'after-close' }),
|
||||
)
|
||||
expect(node.closed).toEqual([])
|
||||
})
|
||||
|
||||
it('routes a rejected subscribe() to onError instead of swallowing it', async () => {
|
||||
const failing: RedisSubscriber = {
|
||||
subscribe: () => Promise.reject(new Error('redis down')),
|
||||
unsubscribe: async () => 0,
|
||||
on: () => failing,
|
||||
off: () => failing,
|
||||
}
|
||||
const onError = vi.fn()
|
||||
startRevocationSubscriber({ redisSubscriber: failing, node: makeNode([]), onError })
|
||||
|
||||
await Promise.resolve() // let the rejected subscribe() microtask settle
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error)
|
||||
})
|
||||
})
|
||||
128
relay-run/tests/wiring/route-resolver.test.ts
Normal file
128
relay-run/tests/wiring/route-resolver.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { HostRecord, HostStatus } from 'control-plane/src/model/records.js'
|
||||
import {
|
||||
createStoreRouteResolver,
|
||||
type HostSubdomainLookup,
|
||||
} from '../../src/wiring/route-resolver.js'
|
||||
|
||||
const NOW_ISO = '2026-07-06T00:00:00.000Z'
|
||||
|
||||
/** Build a full §4.2 HostRecord fixture keyed by its subdomain label. */
|
||||
function makeHost(subdomain: string, status: HostStatus = 'online'): HostRecord {
|
||||
return {
|
||||
hostId: randomUUID(),
|
||||
accountId: randomUUID(),
|
||||
subdomain,
|
||||
agentPubkey: new Uint8Array([1, 2, 3]),
|
||||
enrollFpr: 'fpr:' + subdomain,
|
||||
status,
|
||||
lastSeen: NOW_ISO,
|
||||
createdAt: NOW_ISO,
|
||||
revokedAt: status === 'revoked' ? NOW_ISO : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal HostStore lookup fake: exact-match subdomain → record, else null (matches pg.ts). */
|
||||
function fakeHosts(records: readonly HostRecord[]): HostSubdomainLookup {
|
||||
const bySub = new Map(records.map((r) => [r.subdomain, r]))
|
||||
return {
|
||||
getBySubdomain: vi.fn(async (subdomain: string) => bySub.get(subdomain) ?? null),
|
||||
}
|
||||
}
|
||||
|
||||
describe('createStoreRouteResolver', () => {
|
||||
it('resolves a known online host to {hostId, accountId, subdomain}', async () => {
|
||||
// Arrange
|
||||
const host = makeHost('alice')
|
||||
const resolver = createStoreRouteResolver({ hosts: fakeHosts([host]) })
|
||||
|
||||
// Act
|
||||
const resolved = await resolver.resolveSubdomain('alice')
|
||||
|
||||
// Assert
|
||||
expect(resolved).toEqual({
|
||||
hostId: host.hostId,
|
||||
accountId: host.accountId,
|
||||
subdomain: 'alice',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null (fail-closed) for an unknown subdomain', async () => {
|
||||
// Arrange
|
||||
const resolver = createStoreRouteResolver({ hosts: fakeHosts([makeHost('alice')]) })
|
||||
|
||||
// Act
|
||||
const resolved = await resolver.resolveSubdomain('nobody')
|
||||
|
||||
// Assert
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('fails closed (returns null) for a revoked host even though the row still exists', async () => {
|
||||
// Arrange
|
||||
const resolver = createStoreRouteResolver({ hosts: fakeHosts([makeHost('alice', 'revoked')]) })
|
||||
|
||||
// Act
|
||||
const resolved = await resolver.resolveSubdomain('alice')
|
||||
|
||||
// Assert
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves offline and draining hosts (only revoked fails closed)', async () => {
|
||||
// Arrange
|
||||
const offline = makeHost('bob', 'offline')
|
||||
const draining = makeHost('carol', 'draining')
|
||||
const resolver = createStoreRouteResolver({ hosts: fakeHosts([offline, draining]) })
|
||||
|
||||
// Act + Assert
|
||||
expect(await resolver.resolveSubdomain('bob')).toEqual({
|
||||
hostId: offline.hostId,
|
||||
accountId: offline.accountId,
|
||||
subdomain: 'bob',
|
||||
})
|
||||
expect(await resolver.resolveSubdomain('carol')).toEqual({
|
||||
hostId: draining.hostId,
|
||||
accountId: draining.accountId,
|
||||
subdomain: 'carol',
|
||||
})
|
||||
})
|
||||
|
||||
it('derives identity only from the store record, not the caller (INV3)', async () => {
|
||||
// Arrange: the stored record carries the authoritative accountId/hostId.
|
||||
const host = makeHost('alice')
|
||||
const resolver = createStoreRouteResolver({ hosts: fakeHosts([host]) })
|
||||
|
||||
// Act
|
||||
const resolved = await resolver.resolveSubdomain('alice')
|
||||
|
||||
// Assert: values come from the record, never fabricated from the input label.
|
||||
expect(resolved?.accountId).toBe(host.accountId)
|
||||
expect(resolved?.hostId).toBe(host.hostId)
|
||||
})
|
||||
|
||||
it('passes the exact subdomain label through to getBySubdomain', async () => {
|
||||
// Arrange
|
||||
const hosts = fakeHosts([makeHost('alice')])
|
||||
const resolver = createStoreRouteResolver({ hosts })
|
||||
|
||||
// Act
|
||||
await resolver.resolveSubdomain('alice')
|
||||
|
||||
// Assert
|
||||
expect(hosts.getBySubdomain).toHaveBeenCalledWith('alice')
|
||||
expect(hosts.getBySubdomain).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('propagates store errors (no silent swallow)', async () => {
|
||||
// Arrange: a store that throws (e.g. DB unavailable) must NOT be masked as a null resolve.
|
||||
const boom = new Error('db down')
|
||||
const resolver = createStoreRouteResolver({
|
||||
hosts: { getBySubdomain: vi.fn(async () => { throw boom }) },
|
||||
})
|
||||
|
||||
// Act + Assert
|
||||
await expect(resolver.resolveSubdomain('alice')).rejects.toThrow('db down')
|
||||
})
|
||||
})
|
||||
265
relay-run/tests/wiring/stores-pg.test.ts
Normal file
265
relay-run/tests/wiring/stores-pg.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { AuditEvent } from 'relay-auth'
|
||||
import { NO_STEPUP_POLICY } from 'relay-auth/src/human/stepup/stepup.js'
|
||||
import type { QueryFn } from 'control-plane/src/db/pool.js'
|
||||
import { createRelayEnforceDeps, type RedisLike } from '../../src/wiring/stores-pg.js'
|
||||
|
||||
// ── Fakes ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface QueryCall {
|
||||
readonly sql: string
|
||||
readonly params: readonly unknown[]
|
||||
}
|
||||
|
||||
/** A `QueryFn` that records every call and returns rows chosen by `rowsFor`. */
|
||||
function makeFakeQuery(
|
||||
rowsFor: (sql: string, params: readonly unknown[]) => unknown[],
|
||||
): { query: QueryFn; calls: QueryCall[] } {
|
||||
const calls: QueryCall[] = []
|
||||
const query: QueryFn = async <T>(sql: string, params: readonly unknown[]): Promise<readonly T[]> => {
|
||||
calls.push({ sql, params })
|
||||
return rowsFor(sql, params) as T[]
|
||||
}
|
||||
return { query, calls }
|
||||
}
|
||||
|
||||
interface RedisCall {
|
||||
readonly method: string
|
||||
readonly args: readonly unknown[]
|
||||
}
|
||||
|
||||
/** A `RedisLike` mock whose methods can be overridden per test; every call is recorded. */
|
||||
function makeMockRedis(over: Partial<RedisLike> = {}): RedisLike & { calls: RedisCall[] } {
|
||||
const calls: RedisCall[] = []
|
||||
const record = (method: string, args: unknown[]): void => void calls.push({ method, args })
|
||||
const redis: RedisLike = {
|
||||
exists: over.exists ?? (async (key) => (record('exists', [key]), 0)),
|
||||
set: over.set ?? (async (key, value, mode) => (record('set', [key, value, mode]), 'OK')),
|
||||
expireat: over.expireat ?? (async (key, ts) => (record('expireat', [key, ts]), 1)),
|
||||
eval: over.eval ?? (async (...args) => (record('eval', args), 1)),
|
||||
}
|
||||
return Object.assign(redis, { calls })
|
||||
}
|
||||
|
||||
const NOOP_QUERY: QueryFn = async () => []
|
||||
|
||||
// ── hosts (Postgres pass-through) ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createRelayEnforceDeps.hosts', () => {
|
||||
it('maps a CP host row to a relay-auth HostRecord', async () => {
|
||||
const { query, calls } = makeFakeQuery(() => [
|
||||
{
|
||||
host_id: 'host-1',
|
||||
account_id: 'acct-1',
|
||||
subdomain: 'demo',
|
||||
agent_pubkey: Buffer.from([1, 2, 3]),
|
||||
enroll_fpr: 'fpr-1',
|
||||
status: 'online',
|
||||
last_seen: '2026-07-06T00:00:00.000Z',
|
||||
created_at: '2026-07-05T00:00:00.000Z',
|
||||
revoked_at: null,
|
||||
},
|
||||
])
|
||||
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
|
||||
|
||||
const host = await deps.hosts.getById('host-1')
|
||||
|
||||
expect(host).not.toBeNull()
|
||||
expect(host?.hostId).toBe('host-1')
|
||||
expect(host?.accountId).toBe('acct-1')
|
||||
expect(host?.subdomain).toBe('demo')
|
||||
expect(host?.status).toBe('online')
|
||||
expect(host?.revokedAt).toBeNull()
|
||||
expect(Array.from(host?.agentPubkey ?? [])).toEqual([1, 2, 3])
|
||||
// accountId came from the stored row, never fabricated (INV3).
|
||||
expect(calls[0]?.params).toEqual(['host-1'])
|
||||
})
|
||||
|
||||
it('returns null for an unknown host', async () => {
|
||||
const { query } = makeFakeQuery(() => [])
|
||||
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
|
||||
expect(await deps.hosts.getById('nope')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── sessions (Postgres, remapped to the port shape) ──────────────────────────────────────────────
|
||||
|
||||
describe('createRelayEnforceDeps.sessions', () => {
|
||||
it('maps a session row to { hostId, accountId }', async () => {
|
||||
const { query } = makeFakeQuery(() => [
|
||||
{
|
||||
session_id: 'sess-1',
|
||||
host_id: 'host-1',
|
||||
account_id: 'acct-1',
|
||||
created_at: '2026-07-06T00:00:00.000Z',
|
||||
last_attach_at: '2026-07-06T00:00:00.000Z',
|
||||
},
|
||||
])
|
||||
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
|
||||
|
||||
expect(await deps.sessions.getById('sess-1')).toEqual({ hostId: 'host-1', accountId: 'acct-1' })
|
||||
})
|
||||
|
||||
it('returns null for an unknown session', async () => {
|
||||
const { query } = makeFakeQuery(() => [])
|
||||
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
|
||||
expect(await deps.sessions.getById('nope')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── revocation (Redis) ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createRelayEnforceDeps.revocation', () => {
|
||||
it('isRevoked is true iff the revoked:<jti> key exists', async () => {
|
||||
const present = createRelayEnforceDeps({
|
||||
query: NOOP_QUERY,
|
||||
redis: makeMockRedis({ exists: async () => 1 }),
|
||||
})
|
||||
const absent = createRelayEnforceDeps({
|
||||
query: NOOP_QUERY,
|
||||
redis: makeMockRedis({ exists: async () => 0 }),
|
||||
})
|
||||
expect(await present.revocation.isRevoked('j1')).toBe(true)
|
||||
expect(await absent.revocation.isRevoked('j1')).toBe(false)
|
||||
})
|
||||
|
||||
it('revokeJti sets revoked:<jti> and pins its expiry to exp', async () => {
|
||||
const redis = makeMockRedis()
|
||||
const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis })
|
||||
|
||||
await deps.revocation.revokeJti('j1', 1_800_000_000)
|
||||
|
||||
expect(redis.calls).toEqual([
|
||||
{ method: 'set', args: ['revoked:j1', '1', undefined] },
|
||||
{ method: 'expireat', args: ['revoked:j1', 1_800_000_000] },
|
||||
])
|
||||
})
|
||||
|
||||
it('consumeOnce burns the jti: first use wins (SET NX), replays lose', async () => {
|
||||
let existing = false
|
||||
const redis = makeMockRedis({
|
||||
set: async (_k, _v, mode) => {
|
||||
if (mode !== 'NX') return 'OK'
|
||||
if (existing) return null
|
||||
existing = true
|
||||
return 'OK'
|
||||
},
|
||||
})
|
||||
const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis })
|
||||
|
||||
expect(await deps.revocation.consumeOnce('j1', 1_800_000_000)).toBe(true)
|
||||
expect(await deps.revocation.consumeOnce('j1', 1_800_000_000)).toBe(false)
|
||||
// expiry is only pinned on the winning first use.
|
||||
expect(redis.calls.filter((c) => c.method === 'expireat')).toEqual([
|
||||
{ method: 'expireat', args: ['used:j1', 1_800_000_000] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ── buckets (Redis token bucket) ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createRelayEnforceDeps.buckets', () => {
|
||||
it('namespaces the key and forwards refill/burst/now + a computed ttl to the Lua script', async () => {
|
||||
let evalArgs: readonly unknown[] = []
|
||||
const redis = makeMockRedis({
|
||||
eval: async (...args) => {
|
||||
evalArgs = args
|
||||
return 1
|
||||
},
|
||||
})
|
||||
const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis })
|
||||
|
||||
const ok = await deps.buckets.take('connect:acct:a1', 1, 60, 1000)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
// eval(script, numKeys, key, refillPerSec, burst, now, ttl)
|
||||
expect(evalArgs[1]).toBe(1) // numKeys
|
||||
expect(evalArgs[2]).toBe('bucket:connect:acct:a1')
|
||||
expect(evalArgs[3]).toBe(1) // refillPerSec
|
||||
expect(evalArgs[4]).toBe(60) // burst
|
||||
expect(evalArgs[5]).toBe(1000) // now
|
||||
expect(evalArgs[6]).toBe(61) // ttl = ceil(60/1) + 1
|
||||
})
|
||||
|
||||
it('maps a 0 result to throttled (false)', async () => {
|
||||
const deps = createRelayEnforceDeps({
|
||||
query: NOOP_QUERY,
|
||||
redis: makeMockRedis({ eval: async () => 0 }),
|
||||
})
|
||||
expect(await deps.buckets.take('preauth:ip:x', 1, 60, 1000)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── audit (Postgres audit_log, metadata only) ────────────────────────────────────────────────────
|
||||
|
||||
describe('createRelayEnforceDeps.audit', () => {
|
||||
it('maps an AuditEvent onto the audit_log row, folding non-column fields into meta (INV10)', async () => {
|
||||
const { query, calls } = makeFakeQuery(() => [])
|
||||
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
|
||||
const event: AuditEvent = {
|
||||
ts: '2026-07-06T00:00:00.000Z',
|
||||
action: 'attach',
|
||||
principalId: 'cred-1',
|
||||
accountId: 'acct-1',
|
||||
hostId: 'host-1',
|
||||
sessionId: 'sess-1',
|
||||
jti: 'jti-1',
|
||||
outcome: 'allow',
|
||||
reason: 'ok',
|
||||
remoteAddrHash: 'iphash',
|
||||
}
|
||||
|
||||
await deps.audit.append(event)
|
||||
|
||||
const params = calls[0]?.params ?? []
|
||||
expect(params[0]).toBe('attach') // action
|
||||
expect(params[1]).toBe('cred-1') // principal_id
|
||||
expect(params[2]).toBe('acct-1') // account_id
|
||||
expect(params[3]).toBe('host-1') // host_id
|
||||
expect(params[4]).toBe('2026-07-06T00:00:00.000Z') // ts
|
||||
expect(JSON.parse(params[5] as string)).toEqual({
|
||||
outcome: 'allow',
|
||||
reason: 'ok',
|
||||
remoteAddrHash: 'iphash',
|
||||
sessionId: 'sess-1',
|
||||
jti: 'jti-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits null sessionId/jti from meta', async () => {
|
||||
const { query, calls } = makeFakeQuery(() => [])
|
||||
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
|
||||
const event: AuditEvent = {
|
||||
ts: '2026-07-06T00:00:00.000Z',
|
||||
action: 'attach',
|
||||
principalId: '',
|
||||
accountId: '',
|
||||
hostId: null,
|
||||
sessionId: null,
|
||||
jti: null,
|
||||
outcome: 'deny',
|
||||
reason: 'bad_origin',
|
||||
remoteAddrHash: 'iphash',
|
||||
}
|
||||
|
||||
await deps.audit.append(event)
|
||||
|
||||
expect(calls[0]?.params[3]).toBeNull() // host_id
|
||||
expect(JSON.parse((calls[0]?.params[5] as string) ?? '{}')).toEqual({
|
||||
outcome: 'deny',
|
||||
reason: 'bad_origin',
|
||||
remoteAddrHash: 'iphash',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── stepUpPolicyFor (staging) ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createRelayEnforceDeps.stepUpPolicyFor', () => {
|
||||
it('returns NO_STEPUP_POLICY (staging single-operator)', () => {
|
||||
const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis: makeMockRedis() })
|
||||
// host argument is irrelevant in staging; the policy is never-required.
|
||||
expect(deps.stepUpPolicyFor({} as never)).toBe(NO_STEPUP_POLICY)
|
||||
expect(deps.stepUpPolicyFor({} as never).required).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user