sync.disconnect.test.ts

10.0 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  // T-020d: oversized frame → ws.close() is called, session rejects.210  // MV-3: if the peer sends a frame > SYNC_LIMITS.MAX_FRAME_BYTES, the transport211  // must close and reject rather than buffer or decrypt the oversized data.212  //213  // Bug this catches: removing the frame-size check would allow a peer to send214  // a multi-GiB single frame that OOM-kills the extension background script.215  it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {216    let mockWs: MockWebSocketInstance;217    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {218      mockWs = createMockWebSocket();219      return mockWs;220    });221    vi.stubGlobal("WebSocket", MockWebSocketCtor);222223    const peerKp = generateKeyPair();224    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");225226    const sessionPromise = openSyncSession(227      { ip: "127.0.0.1", port: 9999 },228      TEST_IDENTITY,229      peerHex,230      TEST_RENDEZVOUS_ID,231    );232233    await new Promise((r) => setTimeout(r, 0));234    mockWs!.simulateOpen();235    await new Promise((r) => setTimeout(r, 0));236237    // Send a frame that exceeds SYNC_LIMITS.MAX_FRAME_BYTES (24 MiB)238    const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);239    mockWs!.simulateMessage(bigFrame);240241    await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);242243    vi.unstubAllGlobals();244  });245246  it("T-020e: relay close 1009 surfaces a distinct frame-too-large error", async () => {247    let mockWs: MockWebSocketInstance;248    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {249      mockWs = createMockWebSocket();250      return mockWs;251    });252    vi.stubGlobal("WebSocket", MockWebSocketCtor);253254    const peerKp = generateKeyPair();255    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");256    const sessionPromise = openSyncSession(257      { ip: "127.0.0.1", port: 9999 },258      TEST_IDENTITY,259      peerHex,260      TEST_RENDEZVOUS_ID,261    );262263    await new Promise((r) => setTimeout(r, 0));264    mockWs!.simulateOpen();265    await new Promise((r) => setTimeout(r, 0));266    mockWs!.simulateClose(1009);267268    await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);269    vi.unstubAllGlobals();270  });271});