sync.disconnect.test.ts

10.9 KB · raw

1// T-020: WebSocket sync-disconnect handling (PCH-01-026 #9)2//3// Why this test exists: `openSyncSessionAtUrl` 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 { openSyncSessionAtUrl, 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 = openSyncSessionAtUrl(117      "ws://127.0.0.1:9999/",118      "LAN peer",119      TEST_IDENTITY,120      peerHex,121      TEST_RENDEZVOUS_ID,122    );123124    // Allow the WebSocket constructor and initial setup to run125    await new Promise((r) => setTimeout(r, 0));126127    // Simulate the WebSocket connecting successfully128    mockWs!.simulateOpen();129130    // Allow the initiator to send msg1131    await new Promise((r) => setTimeout(r, 0));132133    // The initiator sent msg1 and is now parked on receive() waiting for msg2.134    // Simulate a peer error (e.g., peer crashes after receiving msg1).135    mockWs!.simulateError();136137    // The session promise must reject (not hang).138    await expect(sessionPromise).rejects.toThrow(/closed|error/i);139140    vi.unstubAllGlobals();141  });142143  // T-020b: onclose during handshake (after msg1 sent) → session promise rejects.144  // This covers the case where the peer closes the WebSocket cleanly (e.g., an145  // auth check on the server side before sending msg2) — the initiator's parked146  // receive() must reject rather than hang.147  it("T-020b: WebSocket onclose during handshake → session promise rejects", async () => {148    let mockWs: MockWebSocketInstance;149    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {150      mockWs = createMockWebSocket();151      return mockWs;152    });153    vi.stubGlobal("WebSocket", MockWebSocketCtor);154155    const peerKp = generateKeyPair();156    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");157158    const sessionPromise = openSyncSessionAtUrl(159      "ws://127.0.0.1:9999/",160      "LAN peer",161      TEST_IDENTITY,162      peerHex,163      TEST_RENDEZVOUS_ID,164    );165166    await new Promise((r) => setTimeout(r, 0));167    mockWs!.simulateOpen();168    await new Promise((r) => setTimeout(r, 0));169170    // Initiator has sent msg1 and is waiting for msg2.171    // Simulate a clean close from the peer (no error first).172    mockWs!.simulateClose();173174    await expect(sessionPromise).rejects.toThrow(/closed|error/i);175176    vi.unstubAllGlobals();177  });178179  // T-020c: WebSocket connect timeout — connection never opens.180  // `connectWebSocket` wraps the connection in a timeout. If the timeout fires,181  // `openSyncSessionAtUrl` must reject with a timeout error.182  //183  // The default timeout is 5000ms (DEFAULT_CONNECT_TIMEOUT_MS). We pass a184  // very short timeout (10ms) to make the test fast.185  it("T-020c: WebSocket connect timeout fires → openSyncSessionAtUrl rejects with timeout error", async () => {186    let mockWs: MockWebSocketInstance;187    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {188      mockWs = createMockWebSocket();189      // Never call simulateOpen() — timeout must fire190      return mockWs;191    });192    vi.stubGlobal("WebSocket", MockWebSocketCtor);193194    const peerKp = generateKeyPair();195    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");196197    // Pass a very short connectTimeoutMs so the test doesn't take 5 seconds198    const sessionPromise = openSyncSessionAtUrl(199      "ws://127.0.0.1:9999/",200      "LAN peer",201      TEST_IDENTITY,202      peerHex,203      TEST_RENDEZVOUS_ID,204      { connectTimeoutMs: 10 },205    );206207    await expect(sessionPromise).rejects.toThrow(/timed out|timeout/i);208209    vi.unstubAllGlobals();210  }, 15_000);211212  it("T-020c2: an occupied-but-unspliced relay path times out the handshake", async () => {213    let mockWs: MockWebSocketInstance;214    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {215      mockWs = createMockWebSocket();216      return mockWs;217    });218    vi.stubGlobal("WebSocket", MockWebSocketCtor);219220    const peerKp = generateKeyPair();221    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");222    const sessionPromise = openSyncSessionAtUrl(223      "ws://127.0.0.1:9999/",224      "LAN peer",225      TEST_IDENTITY,226      peerHex,227      TEST_RENDEZVOUS_ID,228      { handshakeTimeoutMs: 10 },229    );230231    await new Promise((r) => setTimeout(r, 0));232    mockWs!.simulateOpen();233    await expect(sessionPromise).rejects.toThrow(/handshake_timeout/);234    vi.unstubAllGlobals();235  });236237  // T-020d: oversized frame → ws.close() is called, session rejects.238  // MV-3: if the peer sends a frame > SYNC_LIMITS.MAX_FRAME_BYTES, the transport239  // must close and reject rather than buffer or decrypt the oversized data.240  //241  // Bug this catches: removing the frame-size check would allow a peer to send242  // a multi-GiB single frame that OOM-kills the extension background script.243  it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {244    let mockWs: MockWebSocketInstance;245    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {246      mockWs = createMockWebSocket();247      return mockWs;248    });249    vi.stubGlobal("WebSocket", MockWebSocketCtor);250251    const peerKp = generateKeyPair();252    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");253254    const sessionPromise = openSyncSessionAtUrl(255      "ws://127.0.0.1:9999/",256      "LAN peer",257      TEST_IDENTITY,258      peerHex,259      TEST_RENDEZVOUS_ID,260    );261262    await new Promise((r) => setTimeout(r, 0));263    mockWs!.simulateOpen();264    await new Promise((r) => setTimeout(r, 0));265266    // Send a frame that exceeds SYNC_LIMITS.MAX_FRAME_BYTES (24 MiB)267    const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);268    mockWs!.simulateMessage(bigFrame);269270    await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);271272    vi.unstubAllGlobals();273  });274275  it("T-020e: relay close 1009 surfaces a distinct frame-too-large error", async () => {276    let mockWs: MockWebSocketInstance;277    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {278      mockWs = createMockWebSocket();279      return mockWs;280    });281    vi.stubGlobal("WebSocket", MockWebSocketCtor);282283    const peerKp = generateKeyPair();284    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");285    const sessionPromise = openSyncSessionAtUrl(286      "ws://127.0.0.1:9999/",287      "LAN peer",288      TEST_IDENTITY,289      peerHex,290      TEST_RENDEZVOUS_ID,291    );292293    await new Promise((r) => setTimeout(r, 0));294    mockWs!.simulateOpen();295    await new Promise((r) => setTimeout(r, 0));296    mockWs!.simulateClose(1009);297298    await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);299    vi.unstubAllGlobals();300  });301});