sync.disconnect.test.ts

8.8 KB · raw

1// T-020: WebSocket sync-disconnect handling (PCH-01-026 #9)2//3// Why this test exists: `openChirpSyncSession` in chirp-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 XX12// 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 { openChirpSyncSession } from "../../shared/chirp-sync-transport";2425// ---------------------------------------------------------------------------26// Minimal WebSocket mock27// ---------------------------------------------------------------------------2829interface MockWebSocketInstance {30  binaryType: string;31  onmessage: ((ev: MessageEvent) => void) | null;32  onerror: (() => void) | null;33  onclose: (() => void) | null;34  onopen: (() => void) | null;35  sentFrames: ArrayBuffer[];36  send(data: ArrayBuffer | Uint8Array): void;37  close(): void;38  simulateOpen(): void;39  simulateError(): void;40  simulateClose(): void;41  simulateMessage(data: ArrayBuffer): void;42}4344function createMockWebSocket(): MockWebSocketInstance {45  const instance: MockWebSocketInstance = {46    binaryType: "arraybuffer",47    onmessage: null,48    onerror: null,49    onclose: null,50    onopen: null,51    sentFrames: [],52    send(data: ArrayBuffer | Uint8Array) {53      if (data instanceof Uint8Array) {54        instance.sentFrames.push(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));55      } else {56        instance.sentFrames.push(data);57      }58    },59    close() {60      // Simulate the browser firing onclose after close() is called61      Promise.resolve().then(() => instance.simulateClose());62    },63    simulateOpen() {64      instance.onopen?.call(instance, new Event("open"));65    },66    simulateError() {67      instance.onerror?.call(instance);68    },69    simulateClose() {70      instance.onclose?.call(instance, new Event("close"));71    },72    simulateMessage(data: ArrayBuffer) {73      instance.onmessage?.call(instance, { data } as MessageEvent);74    },75  };76  return instance;77}7879// ---------------------------------------------------------------------------80// Tests81// ---------------------------------------------------------------------------8283describe("T-020: WebSocket sync-disconnect handling", () => {84  // T-020a: onerror during handshake → session promise rejects, no hang.85  // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close()86  // to drain the readers queue. Without this, the Noise XX initiator's receive()87  // would park forever waiting for msg2.88  //89  // Bug this catches: removing the onerror → ws.close() bridge causes the90  // session to hang when the peer errors without sending a close frame.91  it("T-020a: WebSocket onerror during handshake → session promise rejects within event loop tick", async () => {92    let mockWs: MockWebSocketInstance;93    const MockWebSocketCtor = vi.fn().mockImplementation((_url: string) => {94      mockWs = createMockWebSocket();95      return mockWs;96    });97    vi.stubGlobal("WebSocket", MockWebSocketCtor);9899    const ourKp = generateKeyPair();100    const peerKp = generateKeyPair();101    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");102103    const sessionPromise = openChirpSyncSession(104      { ip: "127.0.0.1", port: 9999 },105      ourKp.secretKey,106      ourKp.publicKey,107      peerHex,108    );109110    // Allow the WebSocket constructor and initial setup to run111    await new Promise((r) => setTimeout(r, 0));112113    // Simulate the WebSocket connecting successfully114    mockWs!.simulateOpen();115116    // Allow the initiator to send msg1117    await new Promise((r) => setTimeout(r, 0));118119    // The initiator sent msg1 and is now parked on receive() waiting for msg2.120    // Simulate a peer error (e.g., peer crashes after receiving msg1).121    mockWs!.simulateError();122123    // The session promise must reject (not hang).124    await expect(sessionPromise).rejects.toThrow(/closed|error/i);125126    vi.unstubAllGlobals();127  });128129  // T-020b: onclose during handshake (after msg1 sent) → session promise rejects.130  // This covers the case where the peer closes the WebSocket cleanly (e.g., an131  // auth check on the server side before sending msg2) — the initiator's parked132  // receive() must reject rather than hang.133  it("T-020b: WebSocket onclose during handshake → session promise rejects", async () => {134    let mockWs: MockWebSocketInstance;135    const MockWebSocketCtor = vi.fn().mockImplementation((_url: string) => {136      mockWs = createMockWebSocket();137      return mockWs;138    });139    vi.stubGlobal("WebSocket", MockWebSocketCtor);140141    const ourKp = generateKeyPair();142    const peerKp = generateKeyPair();143    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");144145    const sessionPromise = openChirpSyncSession(146      { ip: "127.0.0.1", port: 9999 },147      ourKp.secretKey,148      ourKp.publicKey,149      peerHex,150    );151152    await new Promise((r) => setTimeout(r, 0));153    mockWs!.simulateOpen();154    await new Promise((r) => setTimeout(r, 0));155156    // Initiator has sent msg1 and is waiting for msg2.157    // Simulate a clean close from the peer (no error first).158    mockWs!.simulateClose();159160    await expect(sessionPromise).rejects.toThrow(/closed|error/i);161162    vi.unstubAllGlobals();163  });164165  // T-020c: WebSocket connect timeout — connection never opens.166  // `connectWebSocket` wraps the connection in a timeout. If the timeout fires,167  // `openChirpSyncSession` must reject with a timeout error.168  //169  // The default timeout is 5000ms (DEFAULT_CONNECT_TIMEOUT_MS). We pass a170  // very short timeout (10ms) to make the test fast.171  it("T-020c: WebSocket connect timeout fires → openChirpSyncSession rejects with timeout error", async () => {172    let mockWs: MockWebSocketInstance;173    const MockWebSocketCtor = vi.fn().mockImplementation((_url: string) => {174      mockWs = createMockWebSocket();175      // Never call simulateOpen() — timeout must fire176      return mockWs;177    });178    vi.stubGlobal("WebSocket", MockWebSocketCtor);179180    const ourKp = generateKeyPair();181    const peerKp = generateKeyPair();182    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");183184    // Pass a very short connectTimeoutMs so the test doesn't take 5 seconds185    const sessionPromise = openChirpSyncSession(186      { ip: "127.0.0.1", port: 9999 },187      ourKp.secretKey,188      ourKp.publicKey,189      peerHex,190      { connectTimeoutMs: 10 },191    );192193    await expect(sessionPromise).rejects.toThrow(/timed out|timeout/i);194195    vi.unstubAllGlobals();196  }, 15_000);197198  // T-020d: oversized frame → ws.close() is called, session rejects.199  // MV-3: if the peer sends a frame > SYNC_LIMITS.MAX_FRAME_BYTES, the transport200  // must close and reject rather than buffer or decrypt the oversized data.201  //202  // Bug this catches: removing the frame-size check would allow a peer to send203  // a multi-GiB single frame that OOM-kills the extension background script.204  it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {205    let mockWs: MockWebSocketInstance;206    const MockWebSocketCtor = vi.fn().mockImplementation((_url: string) => {207      mockWs = createMockWebSocket();208      return mockWs;209    });210    vi.stubGlobal("WebSocket", MockWebSocketCtor);211212    const ourKp = generateKeyPair();213    const peerKp = generateKeyPair();214    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");215216    const sessionPromise = openChirpSyncSession(217      { ip: "127.0.0.1", port: 9999 },218      ourKp.secretKey,219      ourKp.publicKey,220      peerHex,221    );222223    await new Promise((r) => setTimeout(r, 0));224    mockWs!.simulateOpen();225    await new Promise((r) => setTimeout(r, 0));226227    // Send a frame that exceeds MAX_FRAME_BYTES (10 MiB per import-limits)228    const MAX_FRAME_BYTES = 10 * 1024 * 1024;229    const bigFrame = new ArrayBuffer(MAX_FRAME_BYTES + 1);230    mockWs!.simulateMessage(bigFrame);231232    await expect(sessionPromise).rejects.toThrow(/closed|error/i);233234    vi.unstubAllGlobals();235  });236});