Files
web-terminal/agent/test/runTunnel.test.ts
Yaojia Wang aa1912b962 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).
2026-07-06 16:13:34 +02:00

129 lines
4.6 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
import type { AgentConfig } from '../src/config/agentConfig.js'
import type { Keystore } from '../src/keys/keystore.js'
import { createBackoff } from '../src/transport/backoff.js'
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
/**
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
*/
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 KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
function 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,
),
)
}
function emitGoAwayRevoked(upstream: FakeWs): void {
const payload = encodeGoaway(0, 'revoked')
upstream.emitMessage(
encodeMuxFrame(
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
payload,
),
)
}
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
return {
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
sleep: async () => {},
backoff: createBackoff(),
...over,
}
}
describe('runTunnel supervisor (C2)', () => {
it('splices bytes both ways through the loopback', async () => {
const upstream = new FakeWs()
const loopback = new FakeWs()
const connectRelay = vi.fn(async () => upstream)
const dialLoopback = vi.fn(async () => loopback)
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
await flush()
emitOpen(upstream, OPEN)
await flush()
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
const last = decodeMuxFrame(upstream.sent.at(-1)!)
expect(last.header.type).toBe('data')
expect([...last.payload]).toEqual([79, 75])
await handle.stop()
expect(await handle.done).toBe(0)
})
it('reconnects after the tunnel dies', async () => {
const sockets = [new FakeWs(), new FakeWs()]
let i = 0
const connectRelay = vi.fn(async () => sockets[i++]!)
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
await flush()
expect(connectRelay).toHaveBeenCalledTimes(1)
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
await flush()
expect(connectRelay).toHaveBeenCalledTimes(2)
await handle.stop()
})
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
const connectRelay = vi.fn(async () => new FakeWs())
let socket: FakeWs | undefined
const wrapped = vi.fn(async () => {
socket = new FakeWs()
return socket
})
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
await flush()
expect(wrapped).toHaveBeenCalledTimes(1)
emitGoAwayRevoked(socket!)
await flush()
expect(await handle.done).toBe(0)
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
expect(connectRelay).not.toHaveBeenCalled()
})
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
const connectRelay = vi.fn(async () => new FakeWs())
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
await flush()
await handle.stop()
expect(await handle.done).toBe(0)
expect(connectRelay).toHaveBeenCalledTimes(1)
})
})