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 "./chirp-sync-transport";24import { SYNC_LIMITS } from "./sync-limits";2526// ---------------------------------------------------------------------------27// Minimal WebSocket mock28// ---------------------------------------------------------------------------2930interface MockWebSocketInstance {31  binaryType: string;32  onmessage: ((ev: MessageEvent) => void) | null;33  onerror: (() => void) | null;34  onclose: ((ev: Event) => void) | null;35  onopen: ((ev: Event) => void) | null;36  sentFrames: ArrayBuffer[];37  send(data: ArrayBuffer | Uint8Array): void;38  close(): void;39  simulateOpen(): void;40  simulateError(): void;41  simulateClose(): void;42  simulateMessage(data: ArrayBuffer): void;43}4445function createMockWebSocket(): MockWebSocketInstance {46  const instance: MockWebSocketInstance = {47    binaryType: "arraybuffer",48    onmessage: null,49    onerror: null,50    onclose: null,51    onopen: null,52    sentFrames: [],53    send(data: ArrayBuffer | Uint8Array) {54      if (data instanceof Uint8Array) {55        instance.sentFrames.push(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer);56      } else {57        instance.sentFrames.push(data);58      }59    },60    close() {61      // Simulate the browser firing onclose after close() is called62      Promise.resolve().then(() => instance.simulateClose());63    },64    simulateOpen() {65      instance.onopen?.call(instance, new Event("open"));66    },67    simulateError() {68      instance.onerror?.call(instance);69    },70    simulateClose() {71      instance.onclose?.call(instance, new Event("close"));72    },73    simulateMessage(data: ArrayBuffer) {74      instance.onmessage?.call(instance, { data } as MessageEvent);75    },76  };77  return instance;78}7980// ---------------------------------------------------------------------------81// Tests82// ---------------------------------------------------------------------------8384describe("T-020: WebSocket sync-disconnect handling", () => {85  // T-020a: onerror during handshake → session promise rejects, no hang.86  // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close()87  // to drain the readers queue. Without this, the Noise XX initiator's receive()88  // would park forever waiting for msg2.89  //90  // Bug this catches: removing the onerror → ws.close() bridge causes the91  // session to hang when the peer errors without sending a close frame.92  it("T-020a: WebSocket onerror during handshake → session promise rejects within event loop tick", async () => {93    let mockWs: MockWebSocketInstance;94    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {95      mockWs = createMockWebSocket();96      return mockWs;97    });98    vi.stubGlobal("WebSocket", MockWebSocketCtor);99100    const ourKp = generateKeyPair();101    const peerKp = generateKeyPair();102    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");103104    const sessionPromise = openChirpSyncSession(105      { ip: "127.0.0.1", port: 9999 },106      ourKp.secretKey,107      ourKp.publicKey,108      peerHex,109    );110111    // Allow the WebSocket constructor and initial setup to run112    await new Promise((r) => setTimeout(r, 0));113114    // Simulate the WebSocket connecting successfully115    mockWs!.simulateOpen();116117    // Allow the initiator to send msg1118    await new Promise((r) => setTimeout(r, 0));119120    // The initiator sent msg1 and is now parked on receive() waiting for msg2.121    // Simulate a peer error (e.g., peer crashes after receiving msg1).122    mockWs!.simulateError();123124    // The session promise must reject (not hang).125    await expect(sessionPromise).rejects.toThrow(/closed|error/i);126127    vi.unstubAllGlobals();128  });129130  // T-020b: onclose during handshake (after msg1 sent) → session promise rejects.131  // This covers the case where the peer closes the WebSocket cleanly (e.g., an132  // auth check on the server side before sending msg2) — the initiator's parked133  // receive() must reject rather than hang.134  it("T-020b: WebSocket onclose during handshake → session promise rejects", async () => {135    let mockWs: MockWebSocketInstance;136    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {137      mockWs = createMockWebSocket();138      return mockWs;139    });140    vi.stubGlobal("WebSocket", MockWebSocketCtor);141142    const ourKp = generateKeyPair();143    const peerKp = generateKeyPair();144    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");145146    const sessionPromise = openChirpSyncSession(147      { ip: "127.0.0.1", port: 9999 },148      ourKp.secretKey,149      ourKp.publicKey,150      peerHex,151    );152153    await new Promise((r) => setTimeout(r, 0));154    mockWs!.simulateOpen();155    await new Promise((r) => setTimeout(r, 0));156157    // Initiator has sent msg1 and is waiting for msg2.158    // Simulate a clean close from the peer (no error first).159    mockWs!.simulateClose();160161    await expect(sessionPromise).rejects.toThrow(/closed|error/i);162163    vi.unstubAllGlobals();164  });165166  // T-020c: WebSocket connect timeout — connection never opens.167  // `connectWebSocket` wraps the connection in a timeout. If the timeout fires,168  // `openChirpSyncSession` must reject with a timeout error.169  //170  // The default timeout is 5000ms (DEFAULT_CONNECT_TIMEOUT_MS). We pass a171  // very short timeout (10ms) to make the test fast.172  it("T-020c: WebSocket connect timeout fires → openChirpSyncSession rejects with timeout error", async () => {173    let mockWs: MockWebSocketInstance;174    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {175      mockWs = createMockWebSocket();176      // Never call simulateOpen() — timeout must fire177      return mockWs;178    });179    vi.stubGlobal("WebSocket", MockWebSocketCtor);180181    const ourKp = generateKeyPair();182    const peerKp = generateKeyPair();183    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");184185    // Pass a very short connectTimeoutMs so the test doesn't take 5 seconds186    const sessionPromise = openChirpSyncSession(187      { ip: "127.0.0.1", port: 9999 },188      ourKp.secretKey,189      ourKp.publicKey,190      peerHex,191      { connectTimeoutMs: 10 },192    );193194    await expect(sessionPromise).rejects.toThrow(/timed out|timeout/i);195196    vi.unstubAllGlobals();197  }, 15_000);198199  // T-020d: oversized frame → ws.close() is called, session rejects.200  // MV-3: if the peer sends a frame > SYNC_LIMITS.MAX_FRAME_BYTES, the transport201  // must close and reject rather than buffer or decrypt the oversized data.202  //203  // Bug this catches: removing the frame-size check would allow a peer to send204  // a multi-GiB single frame that OOM-kills the extension background script.205  it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {206    let mockWs: MockWebSocketInstance;207    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {208      mockWs = createMockWebSocket();209      return mockWs;210    });211    vi.stubGlobal("WebSocket", MockWebSocketCtor);212213    const ourKp = generateKeyPair();214    const peerKp = generateKeyPair();215    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");216217    const sessionPromise = openChirpSyncSession(218      { ip: "127.0.0.1", port: 9999 },219      ourKp.secretKey,220      ourKp.publicKey,221      peerHex,222    );223224    await new Promise((r) => setTimeout(r, 0));225    mockWs!.simulateOpen();226    await new Promise((r) => setTimeout(r, 0));227228    // Send a frame that exceeds SYNC_LIMITS.MAX_FRAME_BYTES (50 MiB)229    const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);230    mockWs!.simulateMessage(bigFrame);231232    await expect(sessionPromise).rejects.toThrow(/closed|error/i);233234    vi.unstubAllGlobals();235  });236});