sync.disconnect.test.ts

8.7 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 NK12// 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_IDENTITY: SyncPeerIdentity = {27  deviceId: "test-device",28  deviceName: "Test",29  deviceType: "extension",30  longTermPublicKeyHex: "11".repeat(32),31};3233// ---------------------------------------------------------------------------34// Minimal WebSocket mock35// ---------------------------------------------------------------------------3637interface MockWebSocketInstance {38  binaryType: string;39  onmessage: ((ev: MessageEvent) => void) | null;40  onerror: (() => void) | null;41  onclose: ((ev: Event) => void) | null;42  onopen: ((ev: Event) => void) | null;43  sentFrames: ArrayBuffer[];44  send(data: ArrayBuffer | Uint8Array): void;45  close(): void;46  simulateOpen(): void;47  simulateError(): void;48  simulateClose(): void;49  simulateMessage(data: ArrayBuffer): void;50}5152function createMockWebSocket(): MockWebSocketInstance {53  const instance: MockWebSocketInstance = {54    binaryType: "arraybuffer",55    onmessage: null,56    onerror: null,57    onclose: null,58    onopen: null,59    sentFrames: [],60    send(data: ArrayBuffer | Uint8Array) {61      if (data instanceof Uint8Array) {62        instance.sentFrames.push(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer);63      } else {64        instance.sentFrames.push(data);65      }66    },67    close() {68      // Simulate the browser firing onclose after close() is called69      Promise.resolve().then(() => instance.simulateClose());70    },71    simulateOpen() {72      instance.onopen?.call(instance, new Event("open"));73    },74    simulateError() {75      instance.onerror?.call(instance);76    },77    simulateClose() {78      instance.onclose?.call(instance, new Event("close"));79    },80    simulateMessage(data: ArrayBuffer) {81      instance.onmessage?.call(instance, { data } as MessageEvent);82    },83  };84  return instance;85}8687// ---------------------------------------------------------------------------88// Tests89// ---------------------------------------------------------------------------9091describe("T-020: WebSocket sync-disconnect handling", () => {92  // T-020a: onerror during handshake → session promise rejects, no hang.93  // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close()94  // to drain the readers queue. Without this, the Noise NK initiator's receive()95  // would park forever waiting for msg2.96  //97  // Bug this catches: removing the onerror → ws.close() bridge causes the98  // session to hang when the peer errors without sending a close frame.99  it("T-020a: WebSocket onerror during handshake → session promise rejects within event loop tick", async () => {100    let mockWs: MockWebSocketInstance;101    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {102      mockWs = createMockWebSocket();103      return mockWs;104    });105    vi.stubGlobal("WebSocket", MockWebSocketCtor);106107    const peerKp = generateKeyPair();108    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");109110    const sessionPromise = openSyncSession(111      { ip: "127.0.0.1", port: 9999 },112      TEST_IDENTITY,113      peerHex,114    );115116    // Allow the WebSocket constructor and initial setup to run117    await new Promise((r) => setTimeout(r, 0));118119    // Simulate the WebSocket connecting successfully120    mockWs!.simulateOpen();121122    // Allow the initiator to send msg1123    await new Promise((r) => setTimeout(r, 0));124125    // The initiator sent msg1 and is now parked on receive() waiting for msg2.126    // Simulate a peer error (e.g., peer crashes after receiving msg1).127    mockWs!.simulateError();128129    // The session promise must reject (not hang).130    await expect(sessionPromise).rejects.toThrow(/closed|error/i);131132    vi.unstubAllGlobals();133  });134135  // T-020b: onclose during handshake (after msg1 sent) → session promise rejects.136  // This covers the case where the peer closes the WebSocket cleanly (e.g., an137  // auth check on the server side before sending msg2) — the initiator's parked138  // receive() must reject rather than hang.139  it("T-020b: WebSocket onclose during handshake → session promise rejects", async () => {140    let mockWs: MockWebSocketInstance;141    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {142      mockWs = createMockWebSocket();143      return mockWs;144    });145    vi.stubGlobal("WebSocket", MockWebSocketCtor);146147    const peerKp = generateKeyPair();148    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");149150    const sessionPromise = openSyncSession(151      { ip: "127.0.0.1", port: 9999 },152      TEST_IDENTITY,153      peerHex,154    );155156    await new Promise((r) => setTimeout(r, 0));157    mockWs!.simulateOpen();158    await new Promise((r) => setTimeout(r, 0));159160    // Initiator has sent msg1 and is waiting for msg2.161    // Simulate a clean close from the peer (no error first).162    mockWs!.simulateClose();163164    await expect(sessionPromise).rejects.toThrow(/closed|error/i);165166    vi.unstubAllGlobals();167  });168169  // T-020c: WebSocket connect timeout — connection never opens.170  // `connectWebSocket` wraps the connection in a timeout. If the timeout fires,171  // `openSyncSession` must reject with a timeout error.172  //173  // The default timeout is 5000ms (DEFAULT_CONNECT_TIMEOUT_MS). We pass a174  // very short timeout (10ms) to make the test fast.175  it("T-020c: WebSocket connect timeout fires → openSyncSession rejects with timeout error", async () => {176    let mockWs: MockWebSocketInstance;177    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {178      mockWs = createMockWebSocket();179      // Never call simulateOpen() — timeout must fire180      return mockWs;181    });182    vi.stubGlobal("WebSocket", MockWebSocketCtor);183184    const peerKp = generateKeyPair();185    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");186187    // Pass a very short connectTimeoutMs so the test doesn't take 5 seconds188    const sessionPromise = openSyncSession(189      { ip: "127.0.0.1", port: 9999 },190      TEST_IDENTITY,191      peerHex,192      { connectTimeoutMs: 10 },193    );194195    await expect(sessionPromise).rejects.toThrow(/timed out|timeout/i);196197    vi.unstubAllGlobals();198  }, 15_000);199200  // T-020d: oversized frame → ws.close() is called, session rejects.201  // MV-3: if the peer sends a frame > SYNC_LIMITS.MAX_FRAME_BYTES, the transport202  // must close and reject rather than buffer or decrypt the oversized data.203  //204  // Bug this catches: removing the frame-size check would allow a peer to send205  // a multi-GiB single frame that OOM-kills the extension background script.206  it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {207    let mockWs: MockWebSocketInstance;208    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {209      mockWs = createMockWebSocket();210      return mockWs;211    });212    vi.stubGlobal("WebSocket", MockWebSocketCtor);213214    const peerKp = generateKeyPair();215    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");216217    const sessionPromise = openSyncSession(218      { ip: "127.0.0.1", port: 9999 },219      TEST_IDENTITY,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 SYNC_LIMITS.MAX_FRAME_BYTES (50 MiB)228    const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);229    mockWs!.simulateMessage(bigFrame);230231    await expect(sessionPromise).rejects.toThrow(/closed|error/i);232233    vi.unstubAllGlobals();234  });235});