Files
web-terminal/term-relay/test/revocation-subscriber.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

142 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, test, expect, vi } from 'vitest'
import { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS, type KillSignal } from 'relay-contracts'
import { startRevocationSubscriber, hostsForScope, type Subscription } from '../data-plane/revocation-subscriber.js'
import { DRAIN_REASON } from '../data-plane/drain.js'
import type { AgentTunnel } from '../data-plane/agent-listener.js'
const UUID_H = '11111111-1111-4111-8111-111111111111'
const UUID_H2 = '22222222-2222-4222-8222-222222222222'
const UUID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
function tunnel(hostId: string, accountId: string): AgentTunnel {
return { hostId, accountId, session: {} as AgentTunnel['session'], closeTunnel: () => {} }
}
function fakeBus() {
let handler: ((raw: string) => void) | null = null
const sub: Subscription = { close: vi.fn() }
return {
subscribe: (channel: string, onMessage: (raw: string) => void) => {
expect(channel).toBe(RELAY_REVOCATIONS_CHANNEL)
handler = onMessage
return sub
},
publish: (signal: KillSignal | string) => handler?.(typeof signal === 'string' ? signal : JSON.stringify(signal)),
sub,
}
}
describe('hostsForScope (T14, pure blast-radius selector)', () => {
const live = new Map([
[UUID_H, tunnel(UUID_H, UUID_A)],
[UUID_H2, tunnel(UUID_H2, 'other-acct')],
])
const accountOf = (h: string) => live.get(h)?.accountId
test('host scope: only if this node serves it', () => {
expect(hostsForScope({ kind: 'host', hostId: UUID_H }, live, accountOf)).toEqual([UUID_H])
expect(hostsForScope({ kind: 'host', hostId: 'nope' }, live, accountOf)).toEqual([])
})
test('account scope: only the accounts hosts on this node', () => {
expect(hostsForScope({ kind: 'account', accountId: UUID_A }, live, accountOf)).toEqual([UUID_H])
})
test('global scope: every live host', () => {
expect(hostsForScope({ kind: 'global' }, live, accountOf)).toEqual([UUID_H, UUID_H2])
})
})
describe('revocation subscriber (T14, INV12/FIX 4)', () => {
function setup(tunnels: Map<string, AgentTunnel>) {
const bus = fakeBus()
const drainHost = vi.fn(async () => {})
const onApplied = vi.fn()
const onDropped = vi.fn()
const clock = { t: 0 }
startRevocationSubscriber({
subscribe: bus.subscribe,
drainHost,
tunnels: () => tunnels,
now: () => clock.t,
onApplied,
onDropped,
})
return { bus, drainHost, onApplied, onDropped, clock }
}
test('host signal → drainHost(H, revoked) once, within the push budget', async () => {
const { bus, drainHost } = setup(new Map([[UUID_H, tunnel(UUID_H, UUID_A)]]))
bus.publish({ scope: { kind: 'host', hostId: UUID_H }, at: 1, reason: 'compromised' })
await new Promise((r) => setTimeout(r, 0))
expect(drainHost).toHaveBeenCalledTimes(1)
expect(drainHost).toHaveBeenCalledWith(UUID_H, DRAIN_REASON.revoked)
expect(REVOCATION_PUSH_BUDGET_MS).toBe(2000)
})
test('a host NOT on this node → no-op (0 calls, no cross-tenant reach)', async () => {
const { bus, drainHost } = setup(new Map([[UUID_H, tunnel(UUID_H, UUID_A)]]))
bus.publish({ scope: { kind: 'host', hostId: UUID_H2 }, at: 1, reason: 'x' })
await new Promise((r) => setTimeout(r, 0))
expect(drainHost).not.toHaveBeenCalled()
})
test('account scope tears down only that accounts hosts (sibling account keeps flowing, INV1)', async () => {
const { bus, drainHost } = setup(
new Map([
[UUID_H, tunnel(UUID_H, UUID_A)],
[UUID_H2, tunnel(UUID_H2, 'other-acct')],
]),
)
bus.publish({ scope: { kind: 'account', accountId: UUID_A }, at: 1, reason: 'x' })
await new Promise((r) => setTimeout(r, 0))
expect(drainHost).toHaveBeenCalledTimes(1)
expect(drainHost).toHaveBeenCalledWith(UUID_H, DRAIN_REASON.revoked)
})
test('global scope tears down every live tunnel', async () => {
const { bus, drainHost } = setup(
new Map([
[UUID_H, tunnel(UUID_H, UUID_A)],
[UUID_H2, tunnel(UUID_H2, 'other-acct')],
]),
)
bus.publish({ scope: { kind: 'global' }, at: 1, reason: 'x' })
await new Promise((r) => setTimeout(r, 0))
expect(drainHost).toHaveBeenCalledTimes(2)
})
test('malformed signal → dropped + counted, no teardown, subscriber stays alive', async () => {
const { bus, drainHost, onDropped } = setup(new Map([[UUID_H, tunnel(UUID_H, UUID_A)]]))
bus.publish('{ not valid json')
bus.publish(JSON.stringify({ scope: { kind: 'nonsense' }, at: 1, reason: 'x' }))
await new Promise((r) => setTimeout(r, 0))
expect(onDropped).toHaveBeenCalledTimes(2)
expect(drainHost).not.toHaveBeenCalled()
// Still alive: a good signal after the poison ones is applied.
bus.publish({ scope: { kind: 'host', hostId: UUID_H }, at: 2, reason: 'x' })
await new Promise((r) => setTimeout(r, 0))
expect(drainHost).toHaveBeenCalledTimes(1)
})
test('onApplied reports metadata only (count + elapsed), never the reason as payload (INV10)', async () => {
const { bus, onApplied } = setup(new Map([[UUID_H, tunnel(UUID_H, UUID_A)]]))
bus.publish({ scope: { kind: 'host', hostId: UUID_H }, at: 1, reason: 'super-secret-reason' })
await new Promise((r) => setTimeout(r, 0))
expect(onApplied).toHaveBeenCalledTimes(1)
const [, hostsAffected, elapsed] = onApplied.mock.calls[0]!
expect(hostsAffected).toBe(1)
expect(typeof elapsed).toBe('number')
})
test('close() unsubscribes cleanly', () => {
const bus = fakeBus()
const handle = startRevocationSubscriber({
subscribe: bus.subscribe,
drainHost: vi.fn(async () => {}),
tunnels: () => new Map(),
now: () => 0,
})
handle.close()
expect(bus.sub.close).toHaveBeenCalledTimes(1)
})
})