sync.disconnect.test.ts

12.2 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  it("PB-26-701: closes when individually valid frames overflow the queued inbox", async () => {99    let mockWs: MockWebSocketInstance;100    const closeSpy = vi.fn(function(this: MockWebSocketInstance, code = 1000) {101      Promise.resolve().then(() => this.simulateClose(code));102    });103    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function() {104      mockWs = createMockWebSocket();105      mockWs.close = closeSpy.bind(mockWs);106      return mockWs;107    }));108109    const peerKp = generateKeyPair();110    const sessionPromise = openSyncSessionAtUrl(111      "ws://127.0.0.1:9999/",112      "LAN peer",113      TEST_IDENTITY,114      Array.from(peerKp.publicKey, (b) => b.toString(16).padStart(2, "0")).join(""),115      TEST_RENDEZVOUS_ID,116    );117    await new Promise((resolve) => setTimeout(resolve, 0));118    mockWs!.simulateOpen();119    await new Promise((resolve) => setTimeout(resolve, 0));120121    // The first message satisfies the parked handshake read. The rest queue.122    // Individually valid tiny frames must still hit a finite message budget.123    for (let i = 0; i < 258; i++) mockWs!.simulateMessage(new ArrayBuffer(1));124125    expect(closeSpy).toHaveBeenCalledWith(1009, "sync inbox limit");126    mockWs!.simulateMessage(new ArrayBuffer(1));127    expect(closeSpy).toHaveBeenCalledTimes(1);128    await expect(sessionPromise).rejects.toThrow();129    vi.unstubAllGlobals();130  });131132  // T-020a: onerror during handshake → session promise rejects, no hang.133  // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close()134  // to drain the readers queue. Without this, the Noise KK initiator's receive()135  // would park forever waiting for msg2.136  //137  // Bug this catches: removing the onerror → ws.close() bridge causes the138  // session to hang when the peer errors without sending a close frame.139  it("T-020a: WebSocket onerror during handshake → session promise rejects within event loop tick", 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 = openSyncSessionAtUrl(151      "ws://127.0.0.1:9999/",152      "LAN peer",153      TEST_IDENTITY,154      peerHex,155      TEST_RENDEZVOUS_ID,156    );157158    // Allow the WebSocket constructor and initial setup to run159    await new Promise((r) => setTimeout(r, 0));160161    // Simulate the WebSocket connecting successfully162    mockWs!.simulateOpen();163164    // Allow the initiator to send msg1165    await new Promise((r) => setTimeout(r, 0));166167    // The initiator sent msg1 and is now parked on receive() waiting for msg2.168    // Simulate a peer error (e.g., peer crashes after receiving msg1).169    mockWs!.simulateError();170171    // The session promise must reject (not hang).172    await expect(sessionPromise).rejects.toThrow(/closed|error/i);173174    vi.unstubAllGlobals();175  });176177  // T-020b: onclose during handshake (after msg1 sent) → session promise rejects.178  // This covers the case where the peer closes the WebSocket cleanly (e.g., an179  // auth check on the server side before sending msg2) — the initiator's parked180  // receive() must reject rather than hang.181  it("T-020b: WebSocket onclose during handshake → session promise rejects", async () => {182    let mockWs: MockWebSocketInstance;183    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {184      mockWs = createMockWebSocket();185      return mockWs;186    });187    vi.stubGlobal("WebSocket", MockWebSocketCtor);188189    const peerKp = generateKeyPair();190    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");191192    const sessionPromise = openSyncSessionAtUrl(193      "ws://127.0.0.1:9999/",194      "LAN peer",195      TEST_IDENTITY,196      peerHex,197      TEST_RENDEZVOUS_ID,198    );199200    await new Promise((r) => setTimeout(r, 0));201    mockWs!.simulateOpen();202    await new Promise((r) => setTimeout(r, 0));203204    // Initiator has sent msg1 and is waiting for msg2.205    // Simulate a clean close from the peer (no error first).206    mockWs!.simulateClose();207208    await expect(sessionPromise).rejects.toThrow(/closed|error/i);209210    vi.unstubAllGlobals();211  });212213  // T-020c: WebSocket connect timeout — connection never opens.214  // `connectWebSocket` wraps the connection in a timeout. If the timeout fires,215  // `openSyncSessionAtUrl` must reject with a timeout error.216  //217  // The default timeout is 5000ms (DEFAULT_CONNECT_TIMEOUT_MS). We pass a218  // very short timeout (10ms) to make the test fast.219  it("T-020c: WebSocket connect timeout fires → openSyncSessionAtUrl rejects with timeout error", async () => {220    let mockWs: MockWebSocketInstance;221    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {222      mockWs = createMockWebSocket();223      // Never call simulateOpen() — timeout must fire224      return mockWs;225    });226    vi.stubGlobal("WebSocket", MockWebSocketCtor);227228    const peerKp = generateKeyPair();229    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");230231    // Pass a very short connectTimeoutMs so the test doesn't take 5 seconds232    const sessionPromise = openSyncSessionAtUrl(233      "ws://127.0.0.1:9999/",234      "LAN peer",235      TEST_IDENTITY,236      peerHex,237      TEST_RENDEZVOUS_ID,238      { connectTimeoutMs: 10 },239    );240241    await expect(sessionPromise).rejects.toThrow(/timed out|timeout/i);242243    vi.unstubAllGlobals();244  }, 15_000);245246  it("T-020c2: an occupied-but-unspliced relay path times out the handshake", 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 = openSyncSessionAtUrl(257      "ws://127.0.0.1:9999/",258      "LAN peer",259      TEST_IDENTITY,260      peerHex,261      TEST_RENDEZVOUS_ID,262      { handshakeTimeoutMs: 10 },263    );264265    await new Promise((r) => setTimeout(r, 0));266    mockWs!.simulateOpen();267    await expect(sessionPromise).rejects.toThrow(/handshake_timeout/);268    vi.unstubAllGlobals();269  });270271  // T-020d: oversized frame → ws.close() is called, session rejects.272  // MV-3: if the peer sends a frame > SYNC_LIMITS.MAX_FRAME_BYTES, the transport273  // must close and reject rather than buffer or decrypt the oversized data.274  //275  // Bug this catches: removing the frame-size check would allow a peer to send276  // a multi-GiB single frame that OOM-kills the extension background script.277  it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {278    let mockWs: MockWebSocketInstance;279    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {280      mockWs = createMockWebSocket();281      return mockWs;282    });283    vi.stubGlobal("WebSocket", MockWebSocketCtor);284285    const peerKp = generateKeyPair();286    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");287288    const sessionPromise = openSyncSessionAtUrl(289      "ws://127.0.0.1:9999/",290      "LAN peer",291      TEST_IDENTITY,292      peerHex,293      TEST_RENDEZVOUS_ID,294    );295296    await new Promise((r) => setTimeout(r, 0));297    mockWs!.simulateOpen();298    await new Promise((r) => setTimeout(r, 0));299300    // Send a frame that exceeds SYNC_LIMITS.MAX_FRAME_BYTES (24 MiB)301    const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);302    mockWs!.simulateMessage(bigFrame);303304    await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);305306    vi.unstubAllGlobals();307  });308309  it("T-020e: relay close 1009 surfaces a distinct frame-too-large error", async () => {310    let mockWs: MockWebSocketInstance;311    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {312      mockWs = createMockWebSocket();313      return mockWs;314    });315    vi.stubGlobal("WebSocket", MockWebSocketCtor);316317    const peerKp = generateKeyPair();318    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");319    const sessionPromise = openSyncSessionAtUrl(320      "ws://127.0.0.1:9999/",321      "LAN peer",322      TEST_IDENTITY,323      peerHex,324      TEST_RENDEZVOUS_ID,325    );326327    await new Promise((r) => setTimeout(r, 0));328    mockWs!.simulateOpen();329    await new Promise((r) => setTimeout(r, 0));330    mockWs!.simulateClose(1009);331332    await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);333    vi.unstubAllGlobals();334  });335});