Files
web-terminal/term-relay/test/drain.test.ts
Yaojia Wang 2af57e6686 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.
2026-07-02 06:10:16 +02:00

75 lines
3.0 KiB
TypeScript

import { describe, test, expect, vi } from 'vitest'
import { drainNode, drainHost, DRAIN_REASON } from '../data-plane/drain.js'
import type { AgentTunnel, RouteRegistrar } from '../data-plane/agent-listener.js'
import type { MuxSession } from '../mux/mux-session.js'
function fakeTunnel(hostId: string): AgentTunnel & { drainSpy: ReturnType<typeof vi.fn> } {
const drainSpy = vi.fn()
const session = { openStream: vi.fn(), onWire: vi.fn(), drain: drainSpy, close: vi.fn() } satisfies MuxSession
return { hostId, accountId: 'a', session, closeTunnel: vi.fn(), drainSpy }
}
function registrar(): RouteRegistrar {
return { register: vi.fn(async () => {}), heartbeat: vi.fn(async () => {}), deregister: vi.fn(async () => {}) }
}
describe('graceful drain + GOAWAY (T11, INV7/INV12)', () => {
test('operatorDrain: GOAWAY sent, teardown deferred to the grace window', async () => {
const t = fakeTunnel('h1')
const reg = registrar()
const scheduled: (() => void)[] = []
await drainNode({
tunnels: () => new Map([['h1', t]]),
registrar: reg,
reason: DRAIN_REASON.operatorDrain,
inFlightGraceMs: 5000,
setTimeoutFn: (fn) => scheduled.push(fn),
})
expect(t.drainSpy).toHaveBeenCalledWith(0, DRAIN_REASON.operatorDrain) // GOAWAY
expect(t.closeTunnel).not.toHaveBeenCalled() // still within grace
scheduled.forEach((f) => f()) // grace elapses
await new Promise((r) => setTimeout(r, 0))
expect(t.closeTunnel).toHaveBeenCalled()
expect(reg.deregister).toHaveBeenCalledWith('h1')
})
test('revoked: IMMEDIATE teardown regardless of a large grace (Finding-2/INV12)', async () => {
const t = fakeTunnel('h1')
const reg = registrar()
const setTimeoutFn = vi.fn()
await drainHost('h1', {
tunnels: () => new Map([['h1', t]]),
registrar: reg,
reason: DRAIN_REASON.revoked,
inFlightGraceMs: 30_000, // must be ignored
setTimeoutFn,
})
expect(t.drainSpy).toHaveBeenCalledWith(0, DRAIN_REASON.revoked)
expect(t.closeTunnel).toHaveBeenCalled() // immediate
expect(reg.deregister).toHaveBeenCalledWith('h1')
expect(setTimeoutFn).not.toHaveBeenCalled() // grace never scheduled
})
test('drainHost targets exactly one host; siblings keep running', async () => {
const h1 = fakeTunnel('h1')
const h2 = fakeTunnel('h2')
const reg = registrar()
await drainHost('h1', {
tunnels: () => new Map([['h1', h1], ['h2', h2]]),
registrar: reg,
reason: DRAIN_REASON.revoked,
inFlightGraceMs: 0,
})
expect(h1.closeTunnel).toHaveBeenCalled()
expect(h2.closeTunnel).not.toHaveBeenCalled()
expect(reg.deregister).toHaveBeenCalledWith('h1')
expect(reg.deregister).not.toHaveBeenCalledWith('h2')
})
test('drainHost for an absent host is a no-op (deny-by-default, never a broader kill)', async () => {
const reg = registrar()
await drainHost('ghost', { tunnels: () => new Map(), registrar: reg, reason: DRAIN_REASON.revoked, inFlightGraceMs: 0 })
expect(reg.deregister).not.toHaveBeenCalled()
})
})