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 } { 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() }) })