sync.disconnect.test.ts

10.8 KB · raw

1// T-020: WebSocket sync-disconnect handling (PCH-01-026 #9)2//3// Why this test exists: `openSyncSession` in sync-transport.ts4// wires three handlers onto the WebSocket:5//   - onerror: calls ws.close() to unblock parked receive() calls6//   - onclose: sets `closed = true`, drains the readers queue with empty bytes7//   - receive(): rejects with "WebSocket closed" when the drained 0-byte signal arrives8//9// PCH-01-026 #9 added the explicit ws.close() call in onerror so that a peer10// error that is NOT followed by onclose still unblocks the receive() queue.11// No test verifies that a WebSocket error or close during the Noise KK12// handshake produces a rejected promise (not a hang). A regression that13// removes the onerror→close bridge causes the session to hang indefinitely14// when the peer disconnects after msg1 but before msg2.15//16// Test approach: mock the WebSocket class globally. The mock lets us:17//   - Control when onerror / onclose fires18//   - Inspect what the session tried to send19//   - Verify the promise rejects (not hangs)2021import { describe, expect, it, vi } from "vitest";22import { generateKeyPair } from "@stablelib/x25519";23import { openSyncSession, type SyncPeerIdentity } from "./sync-transport";24import { SYNC_LIMITS } from "./sync-limits";2526const TEST_KEYS = generateKeyPair();27const TEST_RENDEZVOUS_ID = "A".repeat(43);28const TEST_IDENTITY: SyncPeerIdentity = {29  deviceId: "test-device",30  deviceName: "Test",31  deviceType: "extension",32  longTermPublicKeyHex: Array.from(33    TEST_KEYS.publicKey,34    (b) => b.toString(16).padStart(2, "0"),35  ).join(""),36  longTermPrivateKey: TEST_KEYS.secretKey,37};3839// ---------------------------------------------------------------------------40// Minimal WebSocket mock41// ---------------------------------------------------------------------------4243interface MockWebSocketInstance {44  binaryType: string;45  onmessage: ((ev: MessageEvent) => void) | null;46  onerror: (() => void) | null;47  onclose: ((ev: CloseEvent) => void) | null;48  onopen: ((ev: Event) => void) | null;49  sentFrames: ArrayBuffer[];50  send(data: ArrayBuffer | Uint8Array): void;51  close(code?: number, reason?: string): void;52  simulateOpen(): void;53  simulateError(): void;54  simulateClose(code?: number): void;55  simulateMessage(data: ArrayBuffer): void;56}5758function createMockWebSocket(): MockWebSocketInstance {59  const instance: MockWebSocketInstance = {60    binaryType: "arraybuffer",61    onmessage: null,62    onerror: null,63    onclose: null,64    onopen: null,65    sentFrames: [],66    send(data: ArrayBuffer | Uint8Array) {67      if (data instanceof Uint8Array) {68        instance.sentFrames.push(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer);69      } else {70        instance.sentFrames.push(data);71      }72    },73    close(code = 1000) {74      // Simulate the browser firing onclose after close() is called75      Promise.resolve().then(() => instance.simulateClose(code));76    },77    simulateOpen() {78      instance.onopen?.call(instance, new Event("open"));79    },80    simulateError() {81      instance.onerror?.call(instance);82    },83    simulateClose(code = 1000) {84      instance.onclose?.call(instance, { code } as CloseEvent);85    },86    simulateMessage(data: ArrayBuffer) {87      instance.onmessage?.call(instance, { data } as MessageEvent);88    },89  };90  return instance;91}9293// ---------------------------------------------------------------------------94// Tests95// ---------------------------------------------------------------------------9697describe("T-020: WebSocket sync-disconnect handling", () => {98  // T-020a: onerror during handshake → session promise rejects, no hang.99  // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close()100  // to drain the readers queue. Without this, the Noise KK initiator's receive()101  // would park forever waiting for msg2.102  //103  // Bug this catches: removing the onerror → ws.close() bridge causes the104  // session to hang when the peer errors without sending a close frame.105  it("T-020a: WebSocket onerror during handshake → session promise rejects within event loop tick", async () => {106    let mockWs: MockWebSocketInstance;107    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {108      mockWs = createMockWebSocket();109      return mockWs;110    });111    vi.stubGlobal("WebSocket", MockWebSocketCtor);112113    const peerKp = generateKeyPair();114    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");115116    const sessionPromise = openSyncSession(117      { ip: "127.0.0.1", port: 9999 },118      TEST_IDENTITY,119      peerHex,120      TEST_RENDEZVOUS_ID,121    );122123    // Allow the WebSocket constructor and initial setup to run124    await new Promise((r) => setTimeout(r, 0));125126    // Simulate the WebSocket connecting successfully127    mockWs!.simulateOpen();128129    // Allow the initiator to send msg1130    await new Promise((r) => setTimeout(r, 0));131132    // The initiator sent msg1 and is now parked on receive() waiting for msg2.133    // Simulate a peer error (e.g., peer crashes after receiving msg1).134    mockWs!.simulateError();135136    // The session promise must reject (not hang).137    await expect(sessionPromise).rejects.toThrow(/closed|error/i);138139    vi.unstubAllGlobals();140  });141142  // T-020b: onclose during handshake (after msg1 sent) → session promise rejects.143  // This covers the case where the peer closes the WebSocket cleanly (e.g., an144  // auth check on the server side before sending msg2) — the initiator's parked145  // receive() must reject rather than hang.146  it("T-020b: WebSocket onclose during handshake → session promise rejects", async () => {147    let mockWs: MockWebSocketInstance;148    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {149      mockWs = createMockWebSocket();150      return mockWs;151    });152    vi.stubGlobal("WebSocket", MockWebSocketCtor);153154    const peerKp = generateKeyPair();155    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");156157    const sessionPromise = openSyncSession(158      { ip: "127.0.0.1", port: 9999 },159      TEST_IDENTITY,160      peerHex,161      TEST_RENDEZVOUS_ID,162    );163164    await new Promise((r) => setTimeout(r, 0));165    mockWs!.simulateOpen();166    await new Promise((r) => setTimeout(r, 0));167168    // Initiator has sent msg1 and is waiting for msg2.169    // Simulate a clean close from the peer (no error first).170    mockWs!.simulateClose();171172    await expect(sessionPromise).rejects.toThrow(/closed|error/i);173174    vi.unstubAllGlobals();175  });176177  // T-020c: WebSocket connect timeout — connection never opens.178  // `connectWebSocket` wraps the connection in a timeout. If the timeout fires,179  // `openSyncSession` must reject with a timeout error.180  //181  // The default timeout is 5000ms (DEFAULT_CONNECT_TIMEOUT_MS). We pass a182  // very short timeout (10ms) to make the test fast.183  it("T-020c: WebSocket connect timeout fires → openSyncSession rejects with timeout error", async () => {184    let mockWs: MockWebSocketInstance;185    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {186      mockWs = createMockWebSocket();187      // Never call simulateOpen() — timeout must fire188      return mockWs;189    });190    vi.stubGlobal("WebSocket", MockWebSocketCtor);191192    const peerKp = generateKeyPair();193    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");194195    // Pass a very short connectTimeoutMs so the test doesn't take 5 seconds196    const sessionPromise = openSyncSession(197      { ip: "127.0.0.1", port: 9999 },198      TEST_IDENTITY,199      peerHex,200      TEST_RENDEZVOUS_ID,201      { connectTimeoutMs: 10 },202    );203204    await expect(sessionPromise).rejects.toThrow(/timed out|timeout/i);205206    vi.unstubAllGlobals();207  }, 15_000);208209  it("T-020c2: an occupied-but-unspliced relay path times out the handshake", async () => {210    let mockWs: MockWebSocketInstance;211    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {212      mockWs = createMockWebSocket();213      return mockWs;214    });215    vi.stubGlobal("WebSocket", MockWebSocketCtor);216217    const peerKp = generateKeyPair();218    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");219    const sessionPromise = openSyncSession(220      { ip: "127.0.0.1", port: 9999 },221      TEST_IDENTITY,222      peerHex,223      TEST_RENDEZVOUS_ID,224      { handshakeTimeoutMs: 10 },225    );226227    await new Promise((r) => setTimeout(r, 0));228    mockWs!.simulateOpen();229    await expect(sessionPromise).rejects.toThrow(/handshake_timeout/);230    vi.unstubAllGlobals();231  });232233  // T-020d: oversized frame → ws.close() is called, session rejects.234  // MV-3: if the peer sends a frame > SYNC_LIMITS.MAX_FRAME_BYTES, the transport235  // must close and reject rather than buffer or decrypt the oversized data.236  //237  // Bug this catches: removing the frame-size check would allow a peer to send238  // a multi-GiB single frame that OOM-kills the extension background script.239  it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {240    let mockWs: MockWebSocketInstance;241    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {242      mockWs = createMockWebSocket();243      return mockWs;244    });245    vi.stubGlobal("WebSocket", MockWebSocketCtor);246247    const peerKp = generateKeyPair();248    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");249250    const sessionPromise = openSyncSession(251      { ip: "127.0.0.1", port: 9999 },252      TEST_IDENTITY,253      peerHex,254      TEST_RENDEZVOUS_ID,255    );256257    await new Promise((r) => setTimeout(r, 0));258    mockWs!.simulateOpen();259    await new Promise((r) => setTimeout(r, 0));260261    // Send a frame that exceeds SYNC_LIMITS.MAX_FRAME_BYTES (24 MiB)262    const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);263    mockWs!.simulateMessage(bigFrame);264265    await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);266267    vi.unstubAllGlobals();268  });269270  it("T-020e: relay close 1009 surfaces a distinct frame-too-large error", async () => {271    let mockWs: MockWebSocketInstance;272    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {273      mockWs = createMockWebSocket();274      return mockWs;275    });276    vi.stubGlobal("WebSocket", MockWebSocketCtor);277278    const peerKp = generateKeyPair();279    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");280    const sessionPromise = openSyncSession(281      { ip: "127.0.0.1", port: 9999 },282      TEST_IDENTITY,283      peerHex,284      TEST_RENDEZVOUS_ID,285    );286287    await new Promise((r) => setTimeout(r, 0));288    mockWs!.simulateOpen();289    await new Promise((r) => setTimeout(r, 0));290    mockWs!.simulateClose(1009);291292    await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);293    vi.unstubAllGlobals();294  });295});