feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
64
agent/test/acceptance/cafeDemo.test.ts
Normal file
64
agent/test/acceptance/cafeDemo.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../../src/config/agentConfig.js'
|
||||
import { createLogger } from '../../src/log/logger.js'
|
||||
import { holdTunnel, dataHeader } from '../../src/transport/tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from '../../src/transport/streamRouter.js'
|
||||
import { FakeWs } from '../fixtures/fakes.js'
|
||||
|
||||
/**
|
||||
* T18 café-demo agent slice: pair (implied) → dial (mock) → OPEN → loopback splice against a stub
|
||||
* ws://127.0.0.1:3000 echo server → bytes flow BOTH ways (EXPLORE §5 demo, agent portion).
|
||||
*/
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
const flush = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
describe('café-demo agent slice (T18)', () => {
|
||||
it('splices bytes both ways through the loopback echo', async () => {
|
||||
const upstream = new FakeWs()
|
||||
const tunnel = holdTunnel(upstream)
|
||||
const loopback = new FakeWs()
|
||||
const dial = vi.fn(async () => loopback)
|
||||
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, createLogger('error', () => {}))
|
||||
tunnel.dispatchTo(router, { onPing: () => {}, onPong: () => {} })
|
||||
|
||||
// relay → agent: OPEN + a keystroke
|
||||
ws_emitOpen(upstream, OPEN)
|
||||
await flush()
|
||||
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
|
||||
upstream.emitMessage(encodeMuxFrame(dataHeader(5, 3), new Uint8Array([104, 105, 10]))) // "hi\n"
|
||||
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||
|
||||
// agent ← base app: echo output flows back as DATA upstream
|
||||
loopback.emit('message', new Uint8Array([79, 75])) // "OK"
|
||||
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('data')
|
||||
expect([...last.payload]).toEqual([79, 75])
|
||||
})
|
||||
})
|
||||
|
||||
function ws_emitOpen(upstream: FakeWs, open: MuxOpen): void {
|
||||
const payload = encodeOpen(open)
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
53
agent/test/agentConfig.test.ts
Normal file
53
agent/test/agentConfig.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentConfigSchema, isLoopbackWsUrl, loadAgentConfig } from '../src/config/agentConfig.js'
|
||||
|
||||
const base = {
|
||||
relayUrl: 'wss://relay.example.com/agent',
|
||||
enrollUrl: 'https://example.com/enroll',
|
||||
stateDir: '/tmp/wta',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: null,
|
||||
hostId: null,
|
||||
}
|
||||
|
||||
describe('AgentConfig validation', () => {
|
||||
it('parses a valid config', () => {
|
||||
expect(() => AgentConfigSchema.parse(base)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-wss relayUrl', () => {
|
||||
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'http://relay.example.com' })).toThrow()
|
||||
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'ws://relay.example.com' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-https enrollUrl', () => {
|
||||
expect(() => AgentConfigSchema.parse({ ...base, enrollUrl: 'http://example.com/enroll' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-loopback localTargetUrl (anti-SSRF)', () => {
|
||||
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
|
||||
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://evil.example.com:3000' })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts loopback localTargetUrl variants', () => {
|
||||
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://127.5.5.5:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
|
||||
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
|
||||
})
|
||||
|
||||
it('loadAgentConfig fails fast on a missing relayUrl', () => {
|
||||
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
|
||||
})
|
||||
|
||||
it('loadAgentConfig reads env and lets argv override', () => {
|
||||
const cfg = loadAgentConfig(
|
||||
{ RELAY_URL: 'wss://a/agent', ENROLL_URL: 'https://a/enroll' } as unknown as NodeJS.ProcessEnv,
|
||||
{ subdomain: 'host-42' },
|
||||
)
|
||||
expect(cfg.relayUrl).toBe('wss://a/agent')
|
||||
expect(cfg.subdomain).toBe('host-42')
|
||||
expect(cfg.localTargetUrl).toBe('ws://127.0.0.1:3000')
|
||||
})
|
||||
})
|
||||
62
agent/test/backoff.test.ts
Normal file
62
agent/test/backoff.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createBackoff, reconnectLoop, BACKOFF_CAP_MS } from '../src/transport/backoff.js'
|
||||
import type { Tunnel } from '../src/transport/tunnel.js'
|
||||
|
||||
describe('createBackoff (T10)', () => {
|
||||
it('follows 1s,2s,4s…cap 30s', () => {
|
||||
const b = createBackoff()
|
||||
const seq = [b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs()]
|
||||
expect(seq).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000])
|
||||
expect(BACKOFF_CAP_MS).toBe(30_000)
|
||||
})
|
||||
|
||||
it('reset() returns to 1s', () => {
|
||||
const b = createBackoff()
|
||||
b.nextDelayMs()
|
||||
b.nextDelayMs()
|
||||
b.reset()
|
||||
expect(b.nextDelayMs()).toBe(1000)
|
||||
})
|
||||
|
||||
it('jitter stays within [0.5×, 1×]', () => {
|
||||
const b = createBackoff({ jitter: true, rng: () => 0 })
|
||||
expect(b.nextDelayMs()).toBe(500) // 1000 * 0.5
|
||||
const b2 = createBackoff({ jitter: true, rng: () => 1 })
|
||||
expect(b2.nextDelayMs()).toBe(1000) // 1000 * 1.0
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconnectLoop (T10, INV12)', () => {
|
||||
const fakeTunnel = {} as Tunnel
|
||||
|
||||
it('resolves on the first successful dial and resets backoff', async () => {
|
||||
const dial = vi.fn().mockRejectedValueOnce(new Error('down')).mockResolvedValueOnce(fakeTunnel)
|
||||
const backoff = createBackoff()
|
||||
const reset = vi.spyOn(backoff, 'reset')
|
||||
const sleeps: number[] = []
|
||||
const result = await reconnectLoop(dial, backoff, () => false, async (ms) => {
|
||||
sleeps.push(ms)
|
||||
})
|
||||
expect(result).toBe(fakeTunnel)
|
||||
expect(sleeps).toEqual([1000])
|
||||
expect(reset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a revoked host does not reconnect (INV12)', async () => {
|
||||
const dial = vi.fn().mockResolvedValue(fakeTunnel)
|
||||
const result = await reconnectLoop(dial, createBackoff(), () => true, async () => {})
|
||||
expect(dial).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('stops retrying once revoked mid-loop', async () => {
|
||||
let revoked = false
|
||||
const dial = vi.fn(async () => {
|
||||
revoked = true
|
||||
throw new Error('down')
|
||||
})
|
||||
const result = await reconnectLoop(dial, createBackoff(), () => revoked, async () => {})
|
||||
expect(result).toBeNull()
|
||||
expect(dial).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
27
agent/test/buildBinary.test.ts
Normal file
27
agent/test/buildBinary.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BINARY_TARGETS, buildBinaryConfig } from '../src/dist/buildBinary.js'
|
||||
|
||||
describe('buildBinaryConfig (T16)', () => {
|
||||
it('produces a bun --compile spec per target with cli.ts entry', () => {
|
||||
for (const target of BINARY_TARGETS) {
|
||||
const spec = buildBinaryConfig(target)
|
||||
expect(spec.tool).toBe('bun')
|
||||
expect(spec.entry).toBe('src/cli.ts')
|
||||
expect(spec.target).toBe(target)
|
||||
expect(spec.bunTarget).toContain('bun-')
|
||||
expect(spec.outfile).toContain(target)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries the INV11 forbidden-dep tripwire (no terminal parser in the bundle)', () => {
|
||||
const spec = buildBinaryConfig('darwin-arm64')
|
||||
expect(spec.forbiddenDeps).toContain('xterm')
|
||||
expect(spec.forbiddenDeps).toContain('ansi')
|
||||
})
|
||||
|
||||
it('covers the four supported triples', () => {
|
||||
expect([...BINARY_TARGETS].sort()).toEqual(
|
||||
['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'],
|
||||
)
|
||||
})
|
||||
})
|
||||
111
agent/test/cli.test.ts
Normal file
111
agent/test/cli.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import type { Keystore } from '../src/keys/keystore.js'
|
||||
import type { AgentIdentity } from '../src/keys/identity.js'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function fakeIdentity(): AgentIdentity {
|
||||
return {
|
||||
publicKey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr',
|
||||
sign: () => new Uint8Array(64),
|
||||
exportPrivatePkcs8Pem: () => 'PEM',
|
||||
privateKeyObject: () => ({}) as never,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeKeystore(enrolled: boolean): Keystore {
|
||||
return {
|
||||
saveIdentity: vi.fn(),
|
||||
loadIdentity: () => (enrolled ? fakeIdentity() : null),
|
||||
saveCert: vi.fn(),
|
||||
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
|
||||
saveContentSecret: vi.fn(),
|
||||
loadContentSecret: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
|
||||
const out: string[] = []
|
||||
const d: CliDeps = {
|
||||
loadConfig: () => CFG,
|
||||
openKeystore: () => fakeKeystore(enrolled),
|
||||
generateIdentity: fakeIdentity,
|
||||
redeem: async (): Promise<EnrollResult> => ({
|
||||
hostId: 'h-1',
|
||||
subdomain: 'host-42',
|
||||
cert: 'C',
|
||||
caChain: 'CA',
|
||||
hostContentSecret: new Uint8Array([1]),
|
||||
}),
|
||||
runTunnel: async () => 0,
|
||||
installService: vi.fn(async () => {}),
|
||||
uninstallService: vi.fn(async () => {}),
|
||||
print: (l) => out.push(l),
|
||||
...overrides,
|
||||
}
|
||||
return { d, out }
|
||||
}
|
||||
|
||||
describe('parseArgs (T5)', () => {
|
||||
it('parses `pair ABCD-1234`', () => {
|
||||
expect(parseArgs(['pair', 'ABCD-1234'])).toEqual({ command: 'pair', code: 'ABCD-1234', flags: {} })
|
||||
})
|
||||
|
||||
it('rejects an unknown command', () => {
|
||||
expect(() => parseArgs(['frobnicate'])).toThrow(CliUsageError)
|
||||
})
|
||||
|
||||
it('requires a code on pair', () => {
|
||||
expect(() => parseArgs(['pair'])).toThrow(CliUsageError)
|
||||
})
|
||||
|
||||
it('collects flags', () => {
|
||||
expect(parseArgs(['pair', 'X', '--install'])).toEqual({
|
||||
command: 'pair',
|
||||
code: 'X',
|
||||
flags: { install: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCli (T5)', () => {
|
||||
it('pair happy path calls redeem and prints no secrets', async () => {
|
||||
const { d, out } = deps()
|
||||
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
|
||||
expect(code).toBe(0)
|
||||
expect(out.join('\n')).toContain('host-42')
|
||||
expect(out.join('\n')).not.toContain('PEM')
|
||||
})
|
||||
|
||||
it('pair --install installs the service', async () => {
|
||||
const install = vi.fn(async () => {})
|
||||
const { d } = deps({ installService: install })
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
expect(install).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('run before pairing fails fast', async () => {
|
||||
const { d } = deps({}, false)
|
||||
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
|
||||
})
|
||||
|
||||
it('status prints no key/cert material (INV9)', async () => {
|
||||
const { d, out } = deps({}, true)
|
||||
await runCli({ command: 'status', flags: {} }, d)
|
||||
const joined = out.join('\n')
|
||||
expect(joined).toContain('subdomain: host-42')
|
||||
expect(joined).not.toContain('PEM')
|
||||
expect(joined).not.toContain('CA')
|
||||
})
|
||||
})
|
||||
32
agent/test/csr.test.ts
Normal file
32
agent/test/csr.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { buildCsr } from '../src/enroll/csr.js'
|
||||
|
||||
describe('PKCS#10 CSR (T4)', () => {
|
||||
it('emits a PEM CERTIFICATE REQUEST', () => {
|
||||
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
|
||||
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
|
||||
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
|
||||
})
|
||||
|
||||
it('does not contain private-key material (INV4)', () => {
|
||||
const id = generateIdentity()
|
||||
const csr = buildCsr(id, 'host-42')
|
||||
expect(csr).not.toContain('PRIVATE KEY')
|
||||
expect(csr).not.toContain(id.exportPrivatePkcs8Pem().split('\n')[1]!)
|
||||
})
|
||||
|
||||
it('produces a syntactically decodable DER structure', () => {
|
||||
// Node can't parse a CSR directly, but the base64 body must be valid DER (a SEQUENCE).
|
||||
const csr = buildCsr(generateIdentity(), 'host-42')
|
||||
const b64 = csr
|
||||
.replace(/-----[A-Z ]+-----/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
const der = Buffer.from(b64, 'base64')
|
||||
expect(der[0]).toBe(0x30) // outer SEQUENCE tag
|
||||
expect(der.length).toBeGreaterThan(64)
|
||||
// sanity: X509Certificate exists (crypto available) — unrelated smoke to keep import used
|
||||
expect(typeof X509Certificate).toBe('function')
|
||||
})
|
||||
})
|
||||
112
agent/test/dial.test.ts
Normal file
112
agent/test/dial.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
CertExpiredError,
|
||||
NotEnrolledError,
|
||||
buildTlsOptions,
|
||||
dialRelay,
|
||||
type RawTlsWs,
|
||||
type TlsWsConstructor,
|
||||
} from '../src/transport/dial.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay.example.com/agent',
|
||||
enrollUrl: 'https://example.com/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function enrolledKs() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateIdentity())
|
||||
ks.saveCert('CERTPEM', 'CAPEM')
|
||||
return { dir, ks }
|
||||
}
|
||||
|
||||
const future: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||
const past: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() - 1000) })
|
||||
|
||||
describe('buildTlsOptions (T12, INV14/INV4)', () => {
|
||||
it('wires cert + key + CA and forces rejectUnauthorized true', () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const tls = buildTlsOptions(ks, { certParser: future })
|
||||
expect(tls.cert).toBe('CERTPEM')
|
||||
expect(tls.ca).toBe('CAPEM')
|
||||
expect(tls.key).toContain('PRIVATE KEY') // in-process key PEM
|
||||
expect(tls.rejectUnauthorized).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('carries NO bearer/agent token (mTLS is the auth)', () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const tls = buildTlsOptions(ks, { certParser: future })
|
||||
expect(JSON.stringify(tls)).not.toMatch(/token|authorization|bearer/i)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('missing cert → NotEnrolledError (fail-fast)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
|
||||
expect(() => buildTlsOptions(openKeystore(dir))).toThrow(NotEnrolledError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('expired cert → CertExpiredError', () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
expect(() => buildTlsOptions(ks, { certParser: past })).toThrow(CertExpiredError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dialRelay (T12)', () => {
|
||||
it('constructs the wss client with the TLS options and resolves on open', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
let capturedUrl = ''
|
||||
let capturedOpts: unknown
|
||||
class FakeTlsWs implements RawTlsWs {
|
||||
constructor(url: string, opts: unknown) {
|
||||
capturedUrl = url
|
||||
capturedOpts = opts
|
||||
queueMicrotask(() => this.openCb?.())
|
||||
}
|
||||
private openCb?: () => void
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
on(): void {}
|
||||
once(ev: string, cb: () => void): void {
|
||||
if (ev === 'open') this.openCb = cb
|
||||
}
|
||||
}
|
||||
const ws = await dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
|
||||
expect(capturedUrl).toBe('wss://relay.example.com/agent')
|
||||
expect((capturedOpts as { rejectUnauthorized: boolean }).rejectUnauthorized).toBe(true)
|
||||
expect(ws).toBeDefined()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects when the server errors before open (bad chain / MITM)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
class FakeTlsWs implements RawTlsWs {
|
||||
private errCb?: (e: unknown) => void
|
||||
constructor() {
|
||||
queueMicrotask(() => this.errCb?.(new Error('unable to verify leaf signature')))
|
||||
}
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
on(): void {}
|
||||
once(ev: string, cb: (e: unknown) => void): void {
|
||||
if (ev === 'error') this.errCb = cb
|
||||
}
|
||||
}
|
||||
const p = dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
|
||||
await expect(p).rejects.toThrow(/verify/)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
63
agent/test/fixtures/fakes.ts
vendored
Normal file
63
agent/test/fixtures/fakes.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { WsLike, TimerLike } from '../../src/transport/seams.js'
|
||||
|
||||
/** In-memory WsLike double: records sent frames, lets tests emit inbound events. */
|
||||
export class FakeWs implements WsLike {
|
||||
readonly sent: Uint8Array[] = []
|
||||
closed = false
|
||||
private readonly listeners = new Map<string, Array<(...a: unknown[]) => void>>()
|
||||
|
||||
send(d: Uint8Array): void {
|
||||
this.sent.push(d)
|
||||
}
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
|
||||
const arr = this.listeners.get(ev) ?? []
|
||||
arr.push(cb)
|
||||
this.listeners.set(ev, arr)
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true
|
||||
this.emit('close')
|
||||
}
|
||||
emit(ev: string, ...args: unknown[]): void {
|
||||
for (const cb of this.listeners.get(ev) ?? []) cb(...args)
|
||||
}
|
||||
emitMessage(bytes: Uint8Array): void {
|
||||
this.emit('message', bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic controllable timer for heartbeat/rotation tests. */
|
||||
export class FakeTimer implements TimerLike {
|
||||
private seq = 0
|
||||
private readonly timeouts = new Map<number, { cb: () => void; ms: number }>()
|
||||
private readonly intervals = new Map<number, { cb: () => void; ms: number }>()
|
||||
|
||||
setTimeout(cb: () => void, ms: number): unknown {
|
||||
const id = this.seq++
|
||||
this.timeouts.set(id, { cb, ms })
|
||||
return id
|
||||
}
|
||||
clearTimeout(handle: unknown): void {
|
||||
this.timeouts.delete(handle as number)
|
||||
}
|
||||
setInterval(cb: () => void, ms: number): unknown {
|
||||
const id = this.seq++
|
||||
this.intervals.set(id, { cb, ms })
|
||||
return id
|
||||
}
|
||||
clearInterval(handle: unknown): void {
|
||||
this.intervals.delete(handle as number)
|
||||
}
|
||||
/** Fire every armed timeout whose delay is ≤ ms (single shot) and every interval once. */
|
||||
advance(ms: number): void {
|
||||
for (const [id, t] of [...this.timeouts]) {
|
||||
if (t.ms <= ms) {
|
||||
this.timeouts.delete(id)
|
||||
t.cb()
|
||||
}
|
||||
}
|
||||
for (const t of [...this.intervals.values()]) {
|
||||
if (t.ms <= ms) t.cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
37
agent/test/flowControl.test.ts
Normal file
37
agent/test/flowControl.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createFlowController } from '../src/transport/flowControl.js'
|
||||
|
||||
describe('FlowController (T11)', () => {
|
||||
it('pauses at zero credit and resumes on grant', () => {
|
||||
const fc = createFlowController()
|
||||
fc.initWindow(1, 10)
|
||||
expect(fc.consume(1, 8)).toBe(true)
|
||||
expect(fc.consume(1, 8)).toBe(false) // only 2 credit left → pause
|
||||
fc.grant(1, 16)
|
||||
expect(fc.consume(1, 8)).toBe(true)
|
||||
})
|
||||
|
||||
it('credit is per-stream: exhausting A does not pause B (starvation guard)', () => {
|
||||
const fc = createFlowController()
|
||||
fc.initWindow(1, 4)
|
||||
fc.initWindow(2, 100)
|
||||
expect(fc.consume(1, 4)).toBe(true)
|
||||
expect(fc.consume(1, 1)).toBe(false) // A exhausted
|
||||
expect(fc.consume(2, 50)).toBe(true) // B unaffected
|
||||
})
|
||||
|
||||
it('connection-level (streamId 0) credit caps the whole link', () => {
|
||||
const fc = createFlowController()
|
||||
fc.initWindow(0, 5) // connection window
|
||||
fc.initWindow(1, 1000)
|
||||
expect(fc.consume(1, 5)).toBe(true)
|
||||
expect(fc.consume(1, 1)).toBe(false) // connection window exhausted despite stream credit
|
||||
fc.grant(0, 10)
|
||||
expect(fc.consume(1, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('an uninitialized stream has no credit', () => {
|
||||
const fc = createFlowController()
|
||||
expect(fc.consume(42, 1)).toBe(false)
|
||||
})
|
||||
})
|
||||
53
agent/test/frpScaffold.test.ts
Normal file
53
agent/test/frpScaffold.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import {
|
||||
buildFrpcToml,
|
||||
isFrpRetired,
|
||||
spawnFrpc,
|
||||
type ChildLike,
|
||||
} from '../src/transport/frpScaffold.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: null,
|
||||
}
|
||||
|
||||
describe('frpScaffold (T6, v0.8 only)', () => {
|
||||
it('generates a loopback-only tls frpc.toml', () => {
|
||||
const toml = buildFrpcToml(CFG)
|
||||
expect(toml).toContain('local_ip = "127.0.0.1"')
|
||||
expect(toml).toContain('local_port = 3000')
|
||||
expect(toml).toContain('subdomain = "host-42"')
|
||||
expect(toml).toContain('tls_enable = true')
|
||||
expect(toml).not.toMatch(/local_ip = "(?!127\.0\.0\.1)/)
|
||||
})
|
||||
|
||||
it('refuses a non-loopback local target (anti-SSRF)', () => {
|
||||
expect(() => buildFrpcToml({ ...CFG, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
|
||||
})
|
||||
|
||||
it('propagates child exit to onExit', async () => {
|
||||
let exitHandler: ((code: number | null) => void) | null = null
|
||||
const child: ChildLike = {
|
||||
on: (_ev, cb) => {
|
||||
exitHandler = cb
|
||||
},
|
||||
kill: vi.fn(),
|
||||
}
|
||||
const scaffold = spawnFrpc(CFG, '/usr/bin/frpc', () => child)
|
||||
const seen: number[] = []
|
||||
scaffold.onExit((c) => seen.push(c))
|
||||
await scaffold.start()
|
||||
exitHandler!(7)
|
||||
expect(seen).toEqual([7])
|
||||
})
|
||||
|
||||
it('retirement guard: retired once ed25519', () => {
|
||||
expect(isFrpRetired('ed25519')).toBe(true)
|
||||
expect(isFrpRetired('token')).toBe(false)
|
||||
})
|
||||
})
|
||||
58
agent/test/heartbeat.test.ts
Normal file
58
agent/test/heartbeat.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeMuxFrame } from 'relay-contracts'
|
||||
import { holdTunnel } from '../src/transport/tunnel.js'
|
||||
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../src/transport/heartbeat.js'
|
||||
import { FakeWs, FakeTimer } from './fixtures/fakes.js'
|
||||
|
||||
describe('Heartbeat (T9, §4.1)', () => {
|
||||
it('replies PONG echoing the inbound PING token byte-exact', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const hb = createHeartbeat(tunnel, { timer: new FakeTimer() })
|
||||
const token = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
hb.onPing(token)
|
||||
const frame = decodeMuxFrame(ws.sent.at(-1)!)
|
||||
expect(frame.header.type).toBe('pong')
|
||||
expect(Buffer.from(frame.payload).equals(Buffer.from(token))).toBe(true)
|
||||
})
|
||||
|
||||
it('fires onDead when no PONG arrives within the interval', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const timer = new FakeTimer()
|
||||
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000 })
|
||||
let dead = false
|
||||
hb.onDead(() => {
|
||||
dead = true
|
||||
})
|
||||
hb.start() // sends first ping, arms deadline
|
||||
timer.advance(1000) // deadline fires, no pong seen
|
||||
expect(dead).toBe(true)
|
||||
})
|
||||
|
||||
it('does not fire onDead when PONG arrives in time', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const timer = new FakeTimer()
|
||||
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000, genToken: () => new Uint8Array(8) })
|
||||
let dead = false
|
||||
hb.onDead(() => {
|
||||
dead = true
|
||||
})
|
||||
hb.start()
|
||||
hb.onPong(new Uint8Array(8)) // clears pending before the deadline
|
||||
timer.advance(1000)
|
||||
expect(dead).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes the frozen 15s interval', () => {
|
||||
expect(HEARTBEAT_INTERVAL_MS).toBe(15_000)
|
||||
})
|
||||
|
||||
it('stop() cancels timers (no leak)', () => {
|
||||
const ws = new FakeWs()
|
||||
const hb = createHeartbeat(holdTunnel(ws), { timer: new FakeTimer(), intervalMs: 1000 })
|
||||
hb.start()
|
||||
expect(() => hb.stop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
146
agent/test/hostEndpoint.test.ts
Normal file
146
agent/test/hostEndpoint.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
AeadAlg,
|
||||
ClientHello,
|
||||
E2ESession,
|
||||
HandshakeResult,
|
||||
HostHello,
|
||||
} from 'relay-contracts'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import {
|
||||
MitmAbortError,
|
||||
createE2ETransform,
|
||||
makeHostHello,
|
||||
type CreateHostHandshake,
|
||||
type VerifyDeviceProof,
|
||||
} from '../src/e2e/hostEndpoint.js'
|
||||
import type { ReplaySealer } from '../src/e2e/replaySeal.js'
|
||||
|
||||
const ALG: AeadAlg = 'aes-256-gcm'
|
||||
|
||||
function clientHello(proof: string): ClientHello {
|
||||
return {
|
||||
clientEphPub: new Uint8Array([1, 2, 3]),
|
||||
clientNonce: new Uint8Array([4, 5, 6]),
|
||||
aeadOffer: [ALG],
|
||||
deviceAuthProof: proof,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeHandshake(keysByte: number): CreateHostHandshake {
|
||||
return () => ({
|
||||
async respond(): Promise<{ hello: HostHello; result: HandshakeResult }> {
|
||||
const result: HandshakeResult = {
|
||||
keys: {
|
||||
c2h: new Uint8Array([keysByte]) as never,
|
||||
h2c: new Uint8Array([keysByte + 1]) as never,
|
||||
},
|
||||
aead: ALG,
|
||||
transcript: new Uint8Array([0xff]),
|
||||
}
|
||||
const hello: HostHello = {
|
||||
hostEphPub: new Uint8Array([7]),
|
||||
hostNonce: new Uint8Array([8]),
|
||||
aeadChoice: ALG,
|
||||
enrollFpr: 'fpr',
|
||||
sig: new Uint8Array([9]),
|
||||
}
|
||||
return { hello, result }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The load-bearing MITM verifier: only a specific proof passes.
|
||||
const realVerifier: VerifyDeviceProof = async (proof) => proof === 'VALID-PROOF'
|
||||
|
||||
describe('makeHostHello (T15, anti-MITM)', () => {
|
||||
it('derives DirectionalKeys{c2h,h2c} on a valid proof (FIX 2 — no single sessionKey)', async () => {
|
||||
const { result } = await makeHostHello(clientHello('VALID-PROOF'), generateIdentity(), realVerifier, {
|
||||
createHostHandshake: fakeHandshake(0x10),
|
||||
})
|
||||
expect(result.keys).toHaveProperty('c2h')
|
||||
expect(result.keys).toHaveProperty('h2c')
|
||||
expect(result).not.toHaveProperty('sessionKey')
|
||||
})
|
||||
|
||||
it('ABORTS on a forged proof: no keys, no HostHello (MitmAbortError)', async () => {
|
||||
const respond = vi.fn()
|
||||
const spyHandshake: CreateHostHandshake = () => ({ respond: respond as never })
|
||||
await expect(
|
||||
makeHostHello(clientHello('FORGED'), generateIdentity(), realVerifier, {
|
||||
createHostHandshake: spyHandshake,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MitmAbortError)
|
||||
expect(respond).not.toHaveBeenCalled() // no key derivation reached
|
||||
})
|
||||
|
||||
it('no-stub guard: swapping the verifier for always-true makes the MITM test FAIL', async () => {
|
||||
const stub: VerifyDeviceProof = async () => true
|
||||
// With the stubbed verifier, the forged proof WRONGLY completes — proving the verifier is
|
||||
// load-bearing (a real build forbids this stub via the import guard below).
|
||||
const out = await makeHostHello(clientHello('FORGED'), generateIdentity(), stub, {
|
||||
createHostHandshake: fakeHandshake(0x20),
|
||||
})
|
||||
expect(out.result.keys).toBeDefined()
|
||||
})
|
||||
|
||||
it('no-stub CI guard: hostEndpoint.ts imports NO verifier from relay-e2e (FIX 6b)', () => {
|
||||
const raw = readFileSync(join(import.meta.dirname, '..', 'src', 'e2e', 'hostEndpoint.ts'), 'utf8')
|
||||
const src = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') // strip comments
|
||||
expect(src).not.toMatch(/import[^\n]*verifyDeviceAuthProof/)
|
||||
expect(src).not.toMatch(/from ['"]relay-e2e['"]/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createE2ETransform (T15)', () => {
|
||||
function fakeSession(): E2ESession {
|
||||
// Reversible transform that HIDES the plaintext (XOR) so INV2 assertions are meaningful.
|
||||
return {
|
||||
role: 'host',
|
||||
seal: (pt) => Uint8Array.from([0xe0, ...pt.map((b) => b ^ 0x55)]),
|
||||
open: (ct) => Uint8Array.from(ct.slice(1)).map((b) => b ^ 0x55),
|
||||
rederive: () => {},
|
||||
}
|
||||
}
|
||||
function fakeReplay(): ReplaySealer & { calls: number } {
|
||||
const r = {
|
||||
calls: 0,
|
||||
seal(_pt: Uint8Array) {
|
||||
r.calls += 1
|
||||
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }
|
||||
},
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
it('outbound seals under BOTH the live session and the replay key (FIX 3, INV2)', () => {
|
||||
const replay = fakeReplay()
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, replay)
|
||||
t.openStream(1)
|
||||
t.seedSession(1, fakeSession(), new Uint8Array([0x48])) // HostHello control frame
|
||||
const marker = new TextEncoder().encode('PLAIN')
|
||||
const sealed = t.outbound(1, marker)
|
||||
expect(replay.calls).toBe(1) // replay-bound seal happened
|
||||
expect(Buffer.from(sealed).includes(Buffer.from(marker))).toBe(false) // ciphertext, not plaintext
|
||||
expect(t.takeControlFrames!(1)).toEqual([new Uint8Array([0x48])]) // HostHello flushed once
|
||||
expect(t.takeControlFrames!(1)).toEqual([])
|
||||
})
|
||||
|
||||
it('inbound before seeding returns null (handshake pending); after, opens opaque', () => {
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||
const session = fakeSession()
|
||||
t.openStream(1)
|
||||
expect(t.inbound(1, new Uint8Array([1, 2]))).toBeNull()
|
||||
t.seedSession(1, session, new Uint8Array([0x48]))
|
||||
const ct = session.seal(new Uint8Array([9, 9])) // c2h ciphertext
|
||||
expect([...t.inbound(1, ct)!]).toEqual([9, 9])
|
||||
})
|
||||
|
||||
it('outbound before the session is established aborts (no silent plaintext leak)', () => {
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||
t.openStream(1)
|
||||
expect(() => t.outbound(1, new Uint8Array([1]))).toThrow(MitmAbortError)
|
||||
})
|
||||
})
|
||||
53
agent/test/identity.test.ts
Normal file
53
agent/test/identity.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
computeEnrollFpr,
|
||||
generateIdentity,
|
||||
identityFromPrivatePem,
|
||||
verifySignature,
|
||||
} from '../src/keys/identity.js'
|
||||
|
||||
describe('AgentIdentity (INV4)', () => {
|
||||
it('generates distinct keypairs', () => {
|
||||
const a = generateIdentity()
|
||||
const b = generateIdentity()
|
||||
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
|
||||
expect(a.publicKey.length).toBe(32)
|
||||
})
|
||||
|
||||
it('sign/verify round-trips', () => {
|
||||
const id = generateIdentity()
|
||||
const msg = new TextEncoder().encode('transcript-bytes')
|
||||
const sig = id.sign(msg)
|
||||
expect(verifySignature(id.publicKey, msg, sig)).toBe(true)
|
||||
expect(verifySignature(id.publicKey, new TextEncoder().encode('tampered'), sig)).toBe(false)
|
||||
})
|
||||
|
||||
it('enrollFpr is deterministic base64url(SHA-256(pubkey))', () => {
|
||||
const id = generateIdentity()
|
||||
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
|
||||
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
|
||||
})
|
||||
|
||||
it('reloads the same identity from PEM', () => {
|
||||
const id = generateIdentity()
|
||||
const pem = id.exportPrivatePkcs8Pem()
|
||||
const reloaded = identityFromPrivatePem(pem)
|
||||
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
|
||||
})
|
||||
|
||||
it('security: no API returns raw private-key bytes', () => {
|
||||
const id = generateIdentity()
|
||||
// The only private-key surface is a PEM export for the 0600 keystore and an in-process
|
||||
// KeyObject; there is NO Uint8Array/raw-bytes getter for the private key.
|
||||
expect('privateKey' in id).toBe(false)
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, '..', 'src', 'keys', 'identity.ts'),
|
||||
'utf8',
|
||||
)
|
||||
// No function exports raw private key bytes onto the network surface.
|
||||
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
|
||||
})
|
||||
})
|
||||
76
agent/test/install.test.ts
Normal file
76
agent/test/install.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import {
|
||||
RootRefusedError,
|
||||
detectPlatform,
|
||||
installService,
|
||||
uninstallService,
|
||||
type InstallDeps,
|
||||
} from '../src/service/install.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
|
||||
const writes: Array<[string, string]> = []
|
||||
const runs: Array<[string, readonly string[]]> = []
|
||||
return {
|
||||
writes,
|
||||
runs,
|
||||
writeFile: (p, c) => writes.push([p, c]),
|
||||
runCommand: async (cmd, args) => {
|
||||
runs.push([cmd, args])
|
||||
},
|
||||
getuid: () => uid,
|
||||
homedir: () => '/home/alice',
|
||||
username: () => 'alice',
|
||||
binPath: () => '/usr/local/bin/web-terminal-agent',
|
||||
}
|
||||
}
|
||||
|
||||
describe('detectPlatform (T17)', () => {
|
||||
it('maps darwin→launchd, linux→systemd, else null', () => {
|
||||
expect(detectPlatform('darwin')).toBe('launchd')
|
||||
expect(detectPlatform('linux')).toBe('systemd')
|
||||
expect(detectPlatform('win32')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installService (T17)', () => {
|
||||
it('refuses to install as root (negative, least privilege)', async () => {
|
||||
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
||||
})
|
||||
|
||||
it('systemd: writes a run-as-user unit and enables it', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d)
|
||||
const [, unit] = d.writes[0]!
|
||||
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
||||
expect(unit).toContain('User=alice')
|
||||
expect(unit).not.toContain('User=root')
|
||||
expect(unit).toContain('Restart=on-failure')
|
||||
expect(d.runs[0]![0]).toBe('systemctl')
|
||||
})
|
||||
|
||||
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d)
|
||||
const [path, plist] = d.writes[0]!
|
||||
expect(path).toContain('LaunchAgents')
|
||||
expect(plist).toContain('<string>run</string>')
|
||||
expect(plist).toContain('<key>KeepAlive</key>')
|
||||
expect(d.runs[0]![0]).toBe('launchctl')
|
||||
})
|
||||
|
||||
it('uninstall unloads cleanly', async () => {
|
||||
const d = deps()
|
||||
await uninstallService('launchd', d)
|
||||
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
|
||||
})
|
||||
})
|
||||
70
agent/test/keystore.test.ts
Normal file
70
agent/test/keystore.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
function freshDir(): string {
|
||||
const d = mkdtempSync(join(tmpdir(), 'wta-ks-'))
|
||||
dirs.push(d)
|
||||
return d
|
||||
}
|
||||
afterEach(() => {
|
||||
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function mode(path: string): number {
|
||||
return statSync(path).mode & 0o777
|
||||
}
|
||||
|
||||
describe('Keystore (INV4/INV5)', () => {
|
||||
it('persists the private key 0600 and reloads it', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const id = generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
|
||||
const reloaded = ks.loadIdentity()
|
||||
expect(reloaded).not.toBeNull()
|
||||
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
})
|
||||
|
||||
it('persists cert + CA chain 0600', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveCert('CERTPEM', 'CAPEM')
|
||||
expect(mode(join(dir, 'agent.cert.pem'))).toBe(0o600)
|
||||
expect(mode(join(dir, 'agent.ca.pem'))).toBe(0o600)
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
|
||||
})
|
||||
|
||||
it('FIX 3: round-trips hostContentSecret 0600', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const secret = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
ks.saveContentSecret(secret)
|
||||
expect(mode(join(dir, 'content.secret'))).toBe(0o600)
|
||||
expect(Buffer.from(ks.loadContentSecret()!).equals(Buffer.from(secret))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when nothing is stored yet', () => {
|
||||
const ks = openKeystore(freshDir())
|
||||
expect(ks.loadIdentity()).toBeNull()
|
||||
expect(ks.loadCert()).toBeNull()
|
||||
expect(ks.loadContentSecret()).toBeNull()
|
||||
})
|
||||
|
||||
it('throws a typed error on a corrupt key file', () => {
|
||||
const dir = freshDir()
|
||||
writeFileSync(join(dir, 'agent.key.pem'), 'not-a-pem')
|
||||
expect(() => openKeystore(dir).loadIdentity()).toThrow(KeystoreError)
|
||||
})
|
||||
|
||||
it('refuses to write into a group/world-accessible dir (INV5)', () => {
|
||||
const dir = join(freshDir(), 'wideopen')
|
||||
mkdirSync(dir, { mode: 0o755 })
|
||||
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
|
||||
})
|
||||
})
|
||||
43
agent/test/logger.test.ts
Normal file
43
agent/test/logger.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
|
||||
function capture() {
|
||||
const lines: string[] = []
|
||||
return { lines, sink: (l: string) => lines.push(l) }
|
||||
}
|
||||
|
||||
describe('redacting logger (INV9)', () => {
|
||||
it('never emits secret meta values', () => {
|
||||
const { lines, sink } = capture()
|
||||
const log = createLogger('debug', sink)
|
||||
log.log('info', 'enrolled', {
|
||||
privateKey: 'SECRET-PRIV-KEY',
|
||||
cert: 'SECRET-CERT',
|
||||
pairingCode: 'ABCD-1234',
|
||||
agentToken: 'tok-xyz',
|
||||
})
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).not.toContain('SECRET-PRIV-KEY')
|
||||
expect(joined).not.toContain('SECRET-CERT')
|
||||
expect(joined).not.toContain('ABCD-1234')
|
||||
expect(joined).not.toContain('tok-xyz')
|
||||
expect(joined).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it('passes non-secret meta (nonce) through', () => {
|
||||
const { lines, sink } = capture()
|
||||
const log = createLogger('debug', sink)
|
||||
log.log('debug', 'frame', { nonce: 'abc123', streamId: 7 })
|
||||
expect(lines[0]).toContain('abc123')
|
||||
expect(lines[0]).toContain('7')
|
||||
})
|
||||
|
||||
it('drops messages below the threshold level', () => {
|
||||
const { lines, sink } = capture()
|
||||
const log = createLogger('warn', sink)
|
||||
log.log('debug', 'noisy')
|
||||
log.log('error', 'boom')
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toContain('boom')
|
||||
})
|
||||
})
|
||||
49
agent/test/loopback.test.ts
Normal file
49
agent/test/loopback.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLoopbackUrl, dialLoopback, type RawWs, type WsConstructor } from '../src/transport/loopback.js'
|
||||
|
||||
describe('buildLoopbackUrl (T8)', () => {
|
||||
it('joins target + path without a double slash', () => {
|
||||
expect(buildLoopbackUrl('ws://127.0.0.1:3000', '/term?join=x')).toBe('ws://127.0.0.1:3000/term?join=x')
|
||||
expect(buildLoopbackUrl('ws://127.0.0.1:3000/', 'term')).toBe('ws://127.0.0.1:3000/term')
|
||||
})
|
||||
})
|
||||
|
||||
class FakeRawWs implements RawWs {
|
||||
static last: FakeRawWs | null = null
|
||||
readonly url: string
|
||||
readonly origin: string | undefined
|
||||
private readonly handlers = new Map<string, (...a: unknown[]) => void>()
|
||||
constructor(url: string, opts?: { headers?: Record<string, string> }) {
|
||||
this.url = url
|
||||
this.origin = opts?.headers?.['Origin']
|
||||
FakeRawWs.last = this
|
||||
}
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
on(): void {}
|
||||
once(event: string, cb: (...a: unknown[]) => void): void {
|
||||
this.handlers.set(event, cb)
|
||||
}
|
||||
fire(event: string, ...args: unknown[]): void {
|
||||
this.handlers.get(event)?.(...args)
|
||||
}
|
||||
}
|
||||
|
||||
describe('dialLoopback (T8)', () => {
|
||||
it('replays Origin and resolves on open', async () => {
|
||||
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
|
||||
const promise = dial('/term?join=x', 'https://host-42.term.example.com')
|
||||
FakeRawWs.last!.fire('open')
|
||||
const ws = await promise
|
||||
expect(FakeRawWs.last!.url).toBe('ws://127.0.0.1:3000/term?join=x')
|
||||
expect(FakeRawWs.last!.origin).toBe('https://host-42.term.example.com')
|
||||
expect(ws).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects on a pre-open error (base app down)', async () => {
|
||||
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
|
||||
const promise = dial('/term', 'https://x')
|
||||
FakeRawWs.last!.fire('error', new Error('ECONNREFUSED'))
|
||||
await expect(promise).rejects.toThrow('ECONNREFUSED')
|
||||
})
|
||||
})
|
||||
43
agent/test/originConfig.test.ts
Normal file
43
agent/test/originConfig.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ensureAllowedOrigin, subdomainOrigin, type OriginFsDeps } from '../src/service/originConfig.js'
|
||||
|
||||
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
||||
const store = { content: initial }
|
||||
const fs: OriginFsDeps = {
|
||||
exists: () => store.content !== null,
|
||||
read: () => store.content ?? '',
|
||||
write: (_p, c) => {
|
||||
store.content = c
|
||||
},
|
||||
}
|
||||
return { fs, get: () => store.content ?? '' }
|
||||
}
|
||||
|
||||
const PATH = '/etc/web-terminal.env'
|
||||
|
||||
describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
|
||||
it('composes https://<subdomain>.term.<domain>', () => {
|
||||
expect(subdomainOrigin('host-42', 'example.com')).toBe('https://host-42.term.example.com')
|
||||
})
|
||||
|
||||
it('appends the origin when the file has none', () => {
|
||||
const { fs, get } = memFs('PORT=3000\n')
|
||||
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||
expect(get()).toContain('ALLOWED_ORIGINS=https://host-42.term.example.com')
|
||||
expect(get()).toContain('PORT=3000')
|
||||
})
|
||||
|
||||
it('is idempotent (no duplicate on a second run)', () => {
|
||||
const { fs, get } = memFs('ALLOWED_ORIGINS=https://host-42.term.example.com\n')
|
||||
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||
const matches = get().match(/host-42\.term\.example\.com/g) ?? []
|
||||
expect(matches).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves existing origins (never weakens the Origin check)', () => {
|
||||
const { fs, get } = memFs('ALLOWED_ORIGINS=https://existing.example.com\n')
|
||||
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||
expect(get()).toContain('https://existing.example.com')
|
||||
expect(get()).toContain('https://host-42.term.example.com')
|
||||
})
|
||||
})
|
||||
105
agent/test/pair.test.ts
Normal file
105
agent/test/pair.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
DEFAULT_ENROLL_MODE,
|
||||
EnrollError,
|
||||
PairingCodeExpiredError,
|
||||
PairingCodeSpentError,
|
||||
redeemPairingCode,
|
||||
} from '../src/enroll/pair.js'
|
||||
|
||||
const ENROLL_URL = 'https://example.com/enroll'
|
||||
const VALID_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function freshKs() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-pair-'))
|
||||
return { dir, ks: openKeystore(dir) }
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function okBody(wrappedSecret: Uint8Array) {
|
||||
return {
|
||||
hostId: VALID_UUID,
|
||||
subdomain: 'host-42',
|
||||
cert: 'CERTPEM',
|
||||
caChain: 'CAPEM',
|
||||
hostContentSecret: encodeBase64UrlBytes(wrappedSecret),
|
||||
}
|
||||
}
|
||||
|
||||
describe('redeemPairingCode (§4.5, T4)', () => {
|
||||
it('defaults to ed25519 mode', () => {
|
||||
expect(DEFAULT_ENROLL_MODE).toBe('ed25519')
|
||||
})
|
||||
|
||||
it('sends only pubkey + csr, never the private key (INV4)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const id = generateIdentity()
|
||||
const fetchImpl = vi.fn(async (_u: string | URL | Request, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init!.body)) as Record<string, unknown>
|
||||
expect(body).toHaveProperty('agentPubkey')
|
||||
expect(body).toHaveProperty('csr')
|
||||
expect(JSON.stringify(body)).not.toContain('PRIVATE KEY')
|
||||
expect(body).not.toHaveProperty('privateKey')
|
||||
return jsonResponse(200, okBody(new Uint8Array([9, 9, 9])))
|
||||
})
|
||||
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, { fetchImpl: fetchImpl as unknown as typeof fetch })
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('stores cert + unwrapped content secret; wrapped bytes not persisted (FIX 3)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const id = generateIdentity()
|
||||
const wrapped = new Uint8Array([1, 2, 3])
|
||||
const unwrapped = new Uint8Array([7, 7, 7])
|
||||
const fetchImpl = async () => jsonResponse(200, okBody(wrapped))
|
||||
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
unwrapContentSecret: () => unwrapped,
|
||||
})
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
|
||||
const stored = ks.loadContentSecret()!
|
||||
expect(Buffer.from(stored).equals(Buffer.from(unwrapped))).toBe(true)
|
||||
expect(Buffer.from(stored).equals(Buffer.from(wrapped))).toBe(false)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps 409 → PairingCodeSpentError (single-use)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const fetchImpl = async () => jsonResponse(409, {})
|
||||
await expect(
|
||||
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).rejects.toBeInstanceOf(PairingCodeSpentError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps 410 → PairingCodeExpiredError', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const fetchImpl = async () => jsonResponse(410, {})
|
||||
await expect(
|
||||
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).rejects.toBeInstanceOf(PairingCodeExpiredError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects a schema-mismatched response (boundary validation)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const fetchImpl = async () => jsonResponse(200, { hostId: 'not-a-uuid', subdomain: 'x' })
|
||||
await expect(
|
||||
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).rejects.toBeInstanceOf(EnrollError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
72
agent/test/replaySeal.test.ts
Normal file
72
agent/test/replaySeal.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
|
||||
|
||||
/** Fake AEAD: key = tagged secret‖sessionId; ciphertext = plaintext XOR keyByte (marker hidden). */
|
||||
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } {
|
||||
const derivations: ReplayKeyParams[] = []
|
||||
return {
|
||||
derivations,
|
||||
deriveContentKey(params: ReplayKeyParams): AeadKey {
|
||||
derivations.push(params)
|
||||
const tag = new TextEncoder().encode(`${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}`)
|
||||
return tag as unknown as AeadKey
|
||||
},
|
||||
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
|
||||
const kb = (key as unknown as Uint8Array)[0] ?? 0x5a
|
||||
const ciphertext = plaintext.map((b) => b ^ kb)
|
||||
return { seq, nonce: new Uint8Array([Number(seq & 0xffn)]), ciphertext, tag: new Uint8Array([0xaa]) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const SECRET = new Uint8Array([1, 2, 3, 4])
|
||||
|
||||
describe('createReplaySealer (T19, FIX 3)', () => {
|
||||
it('derives K_content deterministically from (secret, sessionId, alg)', () => {
|
||||
const c1 = fakeCrypto()
|
||||
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c1)
|
||||
const c2 = fakeCrypto()
|
||||
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c2)
|
||||
expect(c1.derivations[0]).toEqual(c2.derivations[0])
|
||||
})
|
||||
|
||||
it('a different sessionId → a different key (per-session separation)', () => {
|
||||
const c = fakeCrypto()
|
||||
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
createReplaySealer(SECRET, 'sess-2', 'aes-256-gcm', c)
|
||||
expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId)
|
||||
})
|
||||
|
||||
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||
const marker = new TextEncoder().encode('SECRET-MARKER')
|
||||
const e0 = sealer.seal(marker)
|
||||
const e1 = sealer.seal(marker)
|
||||
expect(e0.seq).toBe(0n)
|
||||
expect(e1.seq).toBe(1n)
|
||||
expect(Buffer.from(e0.ciphertext).includes(Buffer.from(marker))).toBe(false)
|
||||
})
|
||||
|
||||
it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => {
|
||||
const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||
// model a live seal with a different key byte
|
||||
const liveKey = new Uint8Array([0x11]) as unknown as AeadKey
|
||||
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
|
||||
const rep = replay.seal(new Uint8Array([0x41, 0x42]))
|
||||
expect(Buffer.from(rep.ciphertext).equals(Buffer.from(live.ciphertext))).toBe(false)
|
||||
})
|
||||
|
||||
it('the hostContentSecret is never mutated', () => {
|
||||
const secret = new Uint8Array([9, 9, 9])
|
||||
const spy = vi.fn()
|
||||
createReplaySealer(secret, 's', 'aes-256-gcm', {
|
||||
deriveContentKey: (p) => {
|
||||
spy(p.hostContentSecret)
|
||||
return new Uint8Array([1]) as unknown as AeadKey
|
||||
},
|
||||
sealReplayFrame: (_k, seq, pt) => ({ seq, nonce: new Uint8Array(), ciphertext: pt, tag: new Uint8Array() }),
|
||||
})
|
||||
expect([...secret]).toEqual([9, 9, 9])
|
||||
})
|
||||
})
|
||||
69
agent/test/revocation.test.ts
Normal file
69
agent/test/revocation.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { GOAWAY_REASON_TO_CODE } from 'relay-contracts'
|
||||
import {
|
||||
applyGoAway,
|
||||
applyGoAwayCode,
|
||||
classifyGoAway,
|
||||
createRevocationState,
|
||||
} from '../src/lifecycle/revocation.js'
|
||||
|
||||
describe('classifyGoAway (T14, frozen 3-value union)', () => {
|
||||
it('maps operatorDrain and shutdown → drain, revoked → revoked', () => {
|
||||
expect(classifyGoAway('operatorDrain')).toBe('drain')
|
||||
expect(classifyGoAway('shutdown')).toBe('drain')
|
||||
expect(classifyGoAway('revoked')).toBe('revoked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createRevocationState (T14, INV12)', () => {
|
||||
it('revoke tears down once and makes isRevoked() true (suppresses reconnect)', () => {
|
||||
let teardowns = 0
|
||||
const state = createRevocationState(() => {
|
||||
teardowns += 1
|
||||
})
|
||||
expect(state.isRevoked()).toBe(false)
|
||||
state.markRevoked('goaway-revoked')
|
||||
expect(state.isRevoked()).toBe(true)
|
||||
state.markRevoked('operator') // idempotent
|
||||
expect(teardowns).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyGoAway / applyGoAwayCode (T14)', () => {
|
||||
it('a revoked GOAWAY tears down; a drain GOAWAY does not', () => {
|
||||
const revokeState = createRevocationState(() => {})
|
||||
expect(applyGoAway('revoked', revokeState)).toBe('revoked')
|
||||
expect(revokeState.isRevoked()).toBe(true)
|
||||
|
||||
const drainState = createRevocationState(() => {})
|
||||
expect(applyGoAway('operatorDrain', drainState)).toBe('drain')
|
||||
expect(applyGoAway('shutdown', drainState)).toBe('drain')
|
||||
expect(drainState.isRevoked()).toBe(false)
|
||||
})
|
||||
|
||||
it('decodes the frozen wire codes via decodeGoAwayReason', () => {
|
||||
const state = createRevocationState(() => {})
|
||||
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.operatorDrain, state)).toBe('drain')
|
||||
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.revoked, state)).toBe('revoked')
|
||||
})
|
||||
|
||||
it('an unknown reason code FAILS CLOSED to revoked (INV12 safety)', () => {
|
||||
const state = createRevocationState(() => {})
|
||||
expect(applyGoAwayCode(99, state)).toBe('revoked')
|
||||
expect(state.isRevoked()).toBe(true)
|
||||
})
|
||||
|
||||
it('cross-plan-drift guard: no bare integer reason literal in revocation.ts', () => {
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, '..', 'src', 'lifecycle', 'revocation.ts'),
|
||||
'utf8',
|
||||
)
|
||||
// The reason mapping must come only from the frozen union/decoder — never a hardcoded
|
||||
// `=== 2` / `reason: 2` style integer. (Comments are allowed to mention numbers.)
|
||||
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||
expect(code).not.toMatch(/reason\s*[=:]\s*\d/)
|
||||
expect(code).not.toMatch(/===\s*[123]\b/)
|
||||
})
|
||||
})
|
||||
120
agent/test/rotation.test.ts
Normal file
120
agent/test/rotation.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
computeRenewDelayMs,
|
||||
createCertRotator,
|
||||
renewCert,
|
||||
renewalUrlFor,
|
||||
} from '../src/certs/rotation.js'
|
||||
import { FakeTimer } from './fixtures/fakes.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://example.com/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function enrolledKs() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-rot-'))
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateIdentity())
|
||||
ks.saveCert('OLDCERT', 'OLDCA')
|
||||
return { dir, ks }
|
||||
}
|
||||
|
||||
function jsonRes(status: number, body: unknown): Response {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response
|
||||
}
|
||||
|
||||
describe('cert rotation math (T13)', () => {
|
||||
it('derives P3 renewal URL from enrollUrl', () => {
|
||||
expect(renewalUrlFor(CFG)).toBe('https://example.com/renew')
|
||||
})
|
||||
|
||||
it('renews renewBeforeMs before expiry, clamped at 0', () => {
|
||||
const now = new Date('2026-01-01T00:00:00Z')
|
||||
const parse = () => new Date('2026-01-01T01:00:00Z') // expires in 1h
|
||||
expect(computeRenewDelayMs('C', 5 * 60_000, now, parse)).toBe(55 * 60_000)
|
||||
const parsePast = () => new Date('2025-01-01T00:00:00Z')
|
||||
expect(computeRenewDelayMs('C', 5 * 60_000, now, parsePast)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renewCert (T13)', () => {
|
||||
it('installs a fresh cert atomically on success (same key)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const before = ks.loadIdentity()!.publicKey
|
||||
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }))
|
||||
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||
expect(out).toBe('rotated')
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
|
||||
// pubkey unchanged — only the cert rotated
|
||||
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('403 → revoked (⇒ T14 teardown, INV12)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const fetchImpl = async () => jsonRes(403, {})
|
||||
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||
expect(out).toBe('revoked')
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'OLDCERT', caChainPem: 'OLDCA' }) // untouched
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
describe('createCertRotator (T13)', () => {
|
||||
it('fires onRevoked when the scheduled renewal returns 403', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
fetchImpl: (async () => jsonRes(403, {})) as unknown as typeof fetch,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000), // expires 2s after epoch → delay ~1000ms
|
||||
})
|
||||
let revoked = false
|
||||
rotator.onRevoked(() => {
|
||||
revoked = true
|
||||
})
|
||||
rotator.start()
|
||||
timer.advance(1000) // scheduled renewal fires
|
||||
await flush()
|
||||
expect(revoked).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rotates and reschedules on a successful renewal (seamless)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000),
|
||||
})
|
||||
let rotated = 0
|
||||
rotator.onRotated(() => {
|
||||
rotated += 1
|
||||
})
|
||||
rotator.start()
|
||||
timer.advance(1000)
|
||||
await flush()
|
||||
expect(rotated).toBe(1)
|
||||
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
|
||||
rotator.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
30
agent/test/scaffold.test.ts
Normal file
30
agent/test/scaffold.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import * as contracts from 'relay-contracts'
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8'),
|
||||
) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
||||
|
||||
describe('package scaffold', () => {
|
||||
it('resolves the frozen relay-contracts surface', () => {
|
||||
// §4.1 codec + §4.4 shapes + §4.5 pairing must all be importable.
|
||||
expect(typeof contracts.encodeMuxFrame).toBe('function')
|
||||
expect(typeof contracts.decodeMuxFrame).toBe('function')
|
||||
expect(typeof contracts.decodeGoAwayReason).toBe('function')
|
||||
expect(contracts.EnrollResultSchema).toBeDefined()
|
||||
})
|
||||
|
||||
it('declares web-terminal-agent bin (INDEX §4.5 distribution)', () => {
|
||||
expect(pkg.dependencies?.['relay-contracts']).toBe('file:../relay-contracts')
|
||||
})
|
||||
|
||||
it('INV11 tripwire: no ANSI/xterm/vt100 dependency', () => {
|
||||
const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
||||
const forbidden = /xterm|ansi|vt100/i
|
||||
for (const name of Object.keys(allDeps)) {
|
||||
expect(forbidden.test(name), `forbidden terminal-parser dep: ${name}`).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
20
agent/test/seams.test.ts
Normal file
20
agent/test/seams.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
describe('transport seams (W0 cycle-breaker)', () => {
|
||||
it('is a type-only module with no runtime side effects or transport deps', async () => {
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, '..', 'src', 'transport', 'seams.ts'),
|
||||
'utf8',
|
||||
)
|
||||
// No runtime imports: the seam file must not pull ws/crypto/node runtime.
|
||||
expect(src).not.toMatch(/from ['"]ws['"]/)
|
||||
expect(src).not.toMatch(/from ['"]node:crypto['"]/)
|
||||
// Importing it must not throw or emit anything.
|
||||
const mod = await import('../src/transport/seams.js')
|
||||
expect(mod).toBeDefined()
|
||||
// Interfaces are erased at runtime, so the module has no runtime exports.
|
||||
expect(Object.keys(mod)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
59
agent/test/security/tripwires.test.ts
Normal file
59
agent/test/security/tripwires.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const SRC = join(import.meta.dirname, '..', '..', 'src')
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) out.push(...walk(path))
|
||||
else if (name.endsWith('.ts')) out.push(path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const files = walk(SRC)
|
||||
function code(path: string): string {
|
||||
return readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||
}
|
||||
|
||||
describe('agent security tripwires (T18, INDEX §3)', () => {
|
||||
it('INV9: no console.log anywhere in src', () => {
|
||||
for (const f of files) {
|
||||
expect(code(f), `console.log in ${f}`).not.toMatch(/console\.log/)
|
||||
}
|
||||
})
|
||||
|
||||
it('INV11: no ANSI/xterm/vt100 import anywhere in src (byte-shuttle)', () => {
|
||||
for (const f of files) {
|
||||
expect(code(f), `terminal-parser import in ${f}`).not.toMatch(/from ['"][^'"]*(xterm|ansi|vt100)[^'"]*['"]/)
|
||||
}
|
||||
})
|
||||
|
||||
it('INV2 anti-MITM: hostEndpoint imports NO verifier from relay-e2e (FIX 6b)', () => {
|
||||
const he = code(join(SRC, 'e2e', 'hostEndpoint.ts'))
|
||||
expect(he).not.toMatch(/from ['"]relay-e2e['"]/)
|
||||
expect(he).not.toMatch(/verifyDeviceAuthProof/)
|
||||
})
|
||||
|
||||
it('INV12: revocation.ts uses the frozen decoder, no bare integer reason literal', () => {
|
||||
const rev = code(join(SRC, 'lifecycle', 'revocation.ts'))
|
||||
expect(rev).toMatch(/decodeGoAwayReason/)
|
||||
expect(rev).not.toMatch(/reason\s*[=:]\s*\d/)
|
||||
expect(rev).not.toMatch(/===\s*[123]\b/)
|
||||
})
|
||||
|
||||
it('INV4: identity exposes no raw private-key byte export', () => {
|
||||
const id = code(join(SRC, 'keys', 'identity.ts'))
|
||||
expect(id).not.toMatch(/exportPrivateRaw|privateKeyBytes|rawPrivateKey/)
|
||||
})
|
||||
|
||||
it('every src module is well under the 800-line hard cap', () => {
|
||||
for (const f of files) {
|
||||
const lines = readFileSync(f, 'utf8').split('\n').length
|
||||
expect(lines, `${f} is ${lines} lines`).toBeLessThan(800)
|
||||
}
|
||||
})
|
||||
})
|
||||
140
agent/test/streamRouter.test.ts
Normal file
140
agent/test/streamRouter.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeMuxFrame, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from '../src/transport/streamRouter.js'
|
||||
import { FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
|
||||
const silentLogger = createLogger('error', () => {})
|
||||
const flush = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
function setup(dialImpl?: (path: string, origin: string) => Promise<FakeWs>) {
|
||||
const upstream = new FakeWs()
|
||||
const tunnel = holdTunnel(upstream)
|
||||
const loopbacks: FakeWs[] = []
|
||||
const dial = vi.fn(
|
||||
dialImpl ??
|
||||
(async () => {
|
||||
const ws = new FakeWs()
|
||||
loopbacks.push(ws)
|
||||
return ws
|
||||
}),
|
||||
)
|
||||
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, silentLogger)
|
||||
return { upstream, router, dial, loopbacks }
|
||||
}
|
||||
|
||||
function lastFrame(ws: FakeWs) {
|
||||
return decodeMuxFrame(ws.sent.at(-1)!)
|
||||
}
|
||||
|
||||
describe('StreamRouter (T8)', () => {
|
||||
it('dials loopback with the request path and replayed Origin', async () => {
|
||||
const { router, dial } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
})
|
||||
|
||||
it('INV1 defense-in-depth: foreign subdomain is RST and NEVER dialed', async () => {
|
||||
const { router, dial, upstream } = setup()
|
||||
router.handleOpen({ ...OPEN, subdomain: 'host-999' })
|
||||
await flush()
|
||||
expect(dial).not.toHaveBeenCalled()
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('close')
|
||||
expect(f.header.rst).toBe(true)
|
||||
expect(router.activeStreamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('splices loopback output → tunnel DATA', async () => {
|
||||
const { router, upstream, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
const bytes = new Uint8Array([9, 8, 7])
|
||||
loopbacks[0]!.emit('message', bytes)
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('data')
|
||||
expect(Buffer.from(f.payload).equals(Buffer.from(bytes))).toBe(true)
|
||||
})
|
||||
|
||||
it('forwards tunnel DATA → loopback socket (buffering pre-open)', async () => {
|
||||
const { router, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
router.handleData(5, new Uint8Array([1, 2])) // arrives before dial resolves → buffered
|
||||
await flush()
|
||||
router.handleData(5, new Uint8Array([3, 4]))
|
||||
expect(loopbacks[0]!.sent.map((b) => [...b])).toEqual([[1, 2], [3, 4]])
|
||||
})
|
||||
|
||||
it('DATA on an unknown stream is RST (illegal transition)', () => {
|
||||
const { router, upstream } = setup()
|
||||
router.handleData(999, new Uint8Array([1]))
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('close')
|
||||
expect(f.header.rst).toBe(true)
|
||||
})
|
||||
|
||||
it('two concurrent streams get separate sockets (no cross-stream bleed)', async () => {
|
||||
const { router, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
router.handleOpen({ ...OPEN, streamId: 6 })
|
||||
await flush()
|
||||
router.handleData(5, new Uint8Array([0xaa]))
|
||||
router.handleData(6, new Uint8Array([0xbb]))
|
||||
expect(loopbacks[0]!.sent).toHaveLength(1)
|
||||
expect(loopbacks[1]!.sent).toHaveLength(1)
|
||||
expect([...loopbacks[0]!.sent[0]!]).toEqual([0xaa])
|
||||
expect([...loopbacks[1]!.sent[0]!]).toEqual([0xbb])
|
||||
expect(router.activeStreamCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('CLOSE frees the loopback socket', async () => {
|
||||
const { router, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
router.handleClose(5, false)
|
||||
expect(loopbacks[0]!.closed).toBe(true)
|
||||
expect(router.activeStreamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('loopback connect-refused → RST upstream (typed, no swallow)', async () => {
|
||||
const { router, upstream } = setup(async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
})
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('close')
|
||||
expect(f.header.rst).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('opaque splice sanity (INV11)', () => {
|
||||
it('does not import any terminal parser (identity transform)', () => {
|
||||
const bytes = new Uint8Array([0x1b, 0x5b, 0x41])
|
||||
expect(identityTransform.outbound(1, bytes)).toBe(bytes)
|
||||
// encode/decode round-trip stays byte-exact
|
||||
const framed = encodeMuxFrame(dataHeader(1, bytes.length), bytes)
|
||||
expect(Buffer.from(decodeMuxFrame(framed).payload).equals(Buffer.from(bytes))).toBe(true)
|
||||
})
|
||||
})
|
||||
162
agent/test/tunnel.test.ts
Normal file
162
agent/test/tunnel.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
decodeMuxFrame,
|
||||
encodeMuxFrame,
|
||||
encodeOpen,
|
||||
encodeGoaway,
|
||||
type MuxFrameHeader,
|
||||
type MuxOpen,
|
||||
} from 'relay-contracts'
|
||||
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
|
||||
import { FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'deadbeef',
|
||||
capabilityTokenRef: 'jti-1',
|
||||
}
|
||||
|
||||
function openFrame(open: MuxOpen): Uint8Array {
|
||||
const payload = encodeOpen(open)
|
||||
const header: MuxFrameHeader = {
|
||||
version: 1,
|
||||
type: 'open',
|
||||
fin: false,
|
||||
rst: false,
|
||||
streamId: open.streamId,
|
||||
payloadLen: payload.length,
|
||||
}
|
||||
return encodeMuxFrame(header, payload)
|
||||
}
|
||||
|
||||
function stubHandlers() {
|
||||
return {
|
||||
handleOpen: vi.fn(),
|
||||
handleData: vi.fn(),
|
||||
handleClose: vi.fn(),
|
||||
}
|
||||
}
|
||||
const stubHb = { onPing: vi.fn(), onPong: vi.fn() }
|
||||
|
||||
describe('Tunnel (T7, §4.1)', () => {
|
||||
it('round-trips a frame and yields decoded header+payload via onFrame', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const seen: Array<{ h: MuxFrameHeader; p: Uint8Array }> = []
|
||||
tunnel.onFrame((h, p) => seen.push({ h, p }))
|
||||
ws.emitMessage(openFrame(OPEN))
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]!.h.type).toBe('open')
|
||||
expect(seen[0]!.h.streamId).toBe(5)
|
||||
})
|
||||
|
||||
it('dispatches OPEN/DATA to the router', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
ws.emitMessage(openFrame(OPEN))
|
||||
expect(handlers.handleOpen).toHaveBeenCalledOnce()
|
||||
const data = new Uint8Array([1, 2, 3])
|
||||
ws.emitMessage(encodeMuxFrame(dataHeader(5, 3), data))
|
||||
expect(handlers.handleData).toHaveBeenCalledWith(5, expect.any(Uint8Array))
|
||||
})
|
||||
|
||||
it('INV11: DATA payload passes through opaque (no parsing)', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
const opaque = new Uint8Array([0x1b, 0x5b, 0x33, 0x31, 0x6d]) // looks like an ANSI seq
|
||||
ws.emitMessage(encodeMuxFrame(dataHeader(7, opaque.length), opaque))
|
||||
const forwarded = handlers.handleData.mock.calls[0]![1] as Uint8Array
|
||||
expect(Buffer.from(forwarded).equals(Buffer.from(opaque))).toBe(true)
|
||||
})
|
||||
|
||||
it('drains after inbound GOAWAY: no new OPEN accepted', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
const goaway = encodeGoaway(0, 'operatorDrain')
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
|
||||
goaway,
|
||||
),
|
||||
)
|
||||
ws.emitMessage(openFrame({ ...OPEN, streamId: 9 }))
|
||||
expect(handlers.handleOpen).not.toHaveBeenCalled()
|
||||
// and it RST'd the refused stream
|
||||
const last = decodeMuxFrame(ws.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('close')
|
||||
expect(last.header.rst).toBe(true)
|
||||
})
|
||||
|
||||
it('malformed framing does not crash the tunnel', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
tunnel.dispatchTo(stubHandlers(), stubHb)
|
||||
expect(() => ws.emitMessage(new Uint8Array([0, 1, 2]))).not.toThrow()
|
||||
})
|
||||
|
||||
it('dispatches CLOSE→handleClose and PONG→heartbeat.onPong', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
const hb = { onPing: vi.fn(), onPong: vi.fn() }
|
||||
tunnel.dispatchTo(handlers, hb)
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame({ version: 1, type: 'close', fin: false, rst: true, streamId: 5, payloadLen: 0 }, new Uint8Array(0)),
|
||||
)
|
||||
expect(handlers.handleClose).toHaveBeenCalledWith(5, true)
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame({ version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 2 }, new Uint8Array([1, 2])),
|
||||
)
|
||||
expect(hb.onPong).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('send/goAway/sendRst/close emit well-formed frames', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
tunnel.send(dataHeader(3, 2), new Uint8Array([9, 9]))
|
||||
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('data')
|
||||
tunnel.goAway(3, 'shutdown')
|
||||
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('goaway')
|
||||
tunnel.sendRst(3)
|
||||
const rst = decodeMuxFrame(ws.sent.at(-1)!)
|
||||
expect(rst.header.type).toBe('close')
|
||||
expect(rst.header.rst).toBe(true)
|
||||
tunnel.close()
|
||||
expect(ws.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('windowUpdate frames are dispatched without disturbing the stream handlers', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame({ version: 1, type: 'windowUpdate', fin: false, rst: false, streamId: 1, payloadLen: 4 }, new Uint8Array([0, 0, 0, 5])),
|
||||
)
|
||||
expect(handlers.handleData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onGoAway surfaces the frozen reason', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const reasons: string[] = []
|
||||
tunnel.onGoAway((r) => reasons.push(r))
|
||||
const goaway = encodeGoaway(3, 'revoked')
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
|
||||
goaway,
|
||||
),
|
||||
)
|
||||
expect(reasons).toEqual(['revoked'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user