// T-020: WebSocket sync-disconnect handling (PCH-01-026 #9) // // Why this test exists: `openSyncSessionAtUrl` in sync-transport.ts // wires three handlers onto the WebSocket: // - onerror: calls ws.close() to unblock parked receive() calls // - onclose: sets `closed = true`, drains the readers queue with empty bytes // - receive(): rejects with "WebSocket closed" when the drained 0-byte signal arrives // // PCH-01-026 #9 added the explicit ws.close() call in onerror so that a peer // error that is NOT followed by onclose still unblocks the receive() queue. // No test verifies that a WebSocket error or close during the Noise KK // handshake produces a rejected promise (not a hang). A regression that // removes the onerror→close bridge causes the session to hang indefinitely // when the peer disconnects after msg1 but before msg2. // // Test approach: mock the WebSocket class globally. The mock lets us: // - Control when onerror / onclose fires // - Inspect what the session tried to send // - Verify the promise rejects (not hangs) import { describe, expect, it, vi } from "vitest"; import { generateKeyPair } from "@stablelib/x25519"; import { openSyncSessionAtUrl, type SyncPeerIdentity } from "./sync-transport"; import { SYNC_LIMITS } from "./sync-limits"; const TEST_KEYS = generateKeyPair(); const TEST_RENDEZVOUS_ID = "A".repeat(43); const TEST_IDENTITY: SyncPeerIdentity = { deviceId: "test-device", deviceName: "Test", deviceType: "extension", longTermPublicKeyHex: Array.from( TEST_KEYS.publicKey, (b) => b.toString(16).padStart(2, "0"), ).join(""), longTermPrivateKey: TEST_KEYS.secretKey, }; // --------------------------------------------------------------------------- // Minimal WebSocket mock // --------------------------------------------------------------------------- interface MockWebSocketInstance { binaryType: string; onmessage: ((ev: MessageEvent) => void) | null; onerror: (() => void) | null; onclose: ((ev: CloseEvent) => void) | null; onopen: ((ev: Event) => void) | null; sentFrames: ArrayBuffer[]; send(data: ArrayBuffer | Uint8Array): void; close(code?: number, reason?: string): void; simulateOpen(): void; simulateError(): void; simulateClose(code?: number): void; simulateMessage(data: ArrayBuffer): void; } function createMockWebSocket(): MockWebSocketInstance { const instance: MockWebSocketInstance = { binaryType: "arraybuffer", onmessage: null, onerror: null, onclose: null, onopen: null, sentFrames: [], send(data: ArrayBuffer | Uint8Array) { if (data instanceof Uint8Array) { instance.sentFrames.push(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer); } else { instance.sentFrames.push(data); } }, close(code = 1000) { // Simulate the browser firing onclose after close() is called Promise.resolve().then(() => instance.simulateClose(code)); }, simulateOpen() { instance.onopen?.call(instance, new Event("open")); }, simulateError() { instance.onerror?.call(instance); }, simulateClose(code = 1000) { instance.onclose?.call(instance, { code } as CloseEvent); }, simulateMessage(data: ArrayBuffer) { instance.onmessage?.call(instance, { data } as MessageEvent); }, }; return instance; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("T-020: WebSocket sync-disconnect handling", () => { // T-020a: onerror during handshake → session promise rejects, no hang. // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close() // to drain the readers queue. Without this, the Noise KK initiator's receive() // would park forever waiting for msg2. // // Bug this catches: removing the onerror → ws.close() bridge causes the // session to hang when the peer errors without sending a close frame. it("T-020a: WebSocket onerror during handshake → session promise rejects within event loop tick", async () => { let mockWs: MockWebSocketInstance; const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) { mockWs = createMockWebSocket(); return mockWs; }); vi.stubGlobal("WebSocket", MockWebSocketCtor); const peerKp = generateKeyPair(); const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join(""); const sessionPromise = openSyncSessionAtUrl( "ws://127.0.0.1:9999/", "LAN peer", TEST_IDENTITY, peerHex, TEST_RENDEZVOUS_ID, ); // Allow the WebSocket constructor and initial setup to run await new Promise((r) => setTimeout(r, 0)); // Simulate the WebSocket connecting successfully mockWs!.simulateOpen(); // Allow the initiator to send msg1 await new Promise((r) => setTimeout(r, 0)); // The initiator sent msg1 and is now parked on receive() waiting for msg2. // Simulate a peer error (e.g., peer crashes after receiving msg1). mockWs!.simulateError(); // The session promise must reject (not hang). await expect(sessionPromise).rejects.toThrow(/closed|error/i); vi.unstubAllGlobals(); }); // T-020b: onclose during handshake (after msg1 sent) → session promise rejects. // This covers the case where the peer closes the WebSocket cleanly (e.g., an // auth check on the server side before sending msg2) — the initiator's parked // receive() must reject rather than hang. it("T-020b: WebSocket onclose during handshake → session promise rejects", async () => { let mockWs: MockWebSocketInstance; const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) { mockWs = createMockWebSocket(); return mockWs; }); vi.stubGlobal("WebSocket", MockWebSocketCtor); const peerKp = generateKeyPair(); const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join(""); const sessionPromise = openSyncSessionAtUrl( "ws://127.0.0.1:9999/", "LAN peer", TEST_IDENTITY, peerHex, TEST_RENDEZVOUS_ID, ); await new Promise((r) => setTimeout(r, 0)); mockWs!.simulateOpen(); await new Promise((r) => setTimeout(r, 0)); // Initiator has sent msg1 and is waiting for msg2. // Simulate a clean close from the peer (no error first). mockWs!.simulateClose(); await expect(sessionPromise).rejects.toThrow(/closed|error/i); vi.unstubAllGlobals(); }); // T-020c: WebSocket connect timeout — connection never opens. // `connectWebSocket` wraps the connection in a timeout. If the timeout fires, // `openSyncSessionAtUrl` must reject with a timeout error. // // The default timeout is 5000ms (DEFAULT_CONNECT_TIMEOUT_MS). We pass a // very short timeout (10ms) to make the test fast. it("T-020c: WebSocket connect timeout fires → openSyncSessionAtUrl rejects with timeout error", async () => { let mockWs: MockWebSocketInstance; const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) { mockWs = createMockWebSocket(); // Never call simulateOpen() — timeout must fire return mockWs; }); vi.stubGlobal("WebSocket", MockWebSocketCtor); const peerKp = generateKeyPair(); const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join(""); // Pass a very short connectTimeoutMs so the test doesn't take 5 seconds const sessionPromise = openSyncSessionAtUrl( "ws://127.0.0.1:9999/", "LAN peer", TEST_IDENTITY, peerHex, TEST_RENDEZVOUS_ID, { connectTimeoutMs: 10 }, ); await expect(sessionPromise).rejects.toThrow(/timed out|timeout/i); vi.unstubAllGlobals(); }, 15_000); it("T-020c2: an occupied-but-unspliced relay path times out the handshake", async () => { let mockWs: MockWebSocketInstance; const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) { mockWs = createMockWebSocket(); return mockWs; }); vi.stubGlobal("WebSocket", MockWebSocketCtor); const peerKp = generateKeyPair(); const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join(""); const sessionPromise = openSyncSessionAtUrl( "ws://127.0.0.1:9999/", "LAN peer", TEST_IDENTITY, peerHex, TEST_RENDEZVOUS_ID, { handshakeTimeoutMs: 10 }, ); await new Promise((r) => setTimeout(r, 0)); mockWs!.simulateOpen(); await expect(sessionPromise).rejects.toThrow(/handshake_timeout/); vi.unstubAllGlobals(); }); // T-020d: oversized frame → ws.close() is called, session rejects. // MV-3: if the peer sends a frame > SYNC_LIMITS.MAX_FRAME_BYTES, the transport // must close and reject rather than buffer or decrypt the oversized data. // // Bug this catches: removing the frame-size check would allow a peer to send // a multi-GiB single frame that OOM-kills the extension background script. it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => { let mockWs: MockWebSocketInstance; const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) { mockWs = createMockWebSocket(); return mockWs; }); vi.stubGlobal("WebSocket", MockWebSocketCtor); const peerKp = generateKeyPair(); const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join(""); const sessionPromise = openSyncSessionAtUrl( "ws://127.0.0.1:9999/", "LAN peer", TEST_IDENTITY, peerHex, TEST_RENDEZVOUS_ID, ); await new Promise((r) => setTimeout(r, 0)); mockWs!.simulateOpen(); await new Promise((r) => setTimeout(r, 0)); // Send a frame that exceeds SYNC_LIMITS.MAX_FRAME_BYTES (24 MiB) const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1); mockWs!.simulateMessage(bigFrame); await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i); vi.unstubAllGlobals(); }); it("T-020e: relay close 1009 surfaces a distinct frame-too-large error", async () => { let mockWs: MockWebSocketInstance; const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) { mockWs = createMockWebSocket(); return mockWs; }); vi.stubGlobal("WebSocket", MockWebSocketCtor); const peerKp = generateKeyPair(); const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join(""); const sessionPromise = openSyncSessionAtUrl( "ws://127.0.0.1:9999/", "LAN peer", TEST_IDENTITY, peerHex, TEST_RENDEZVOUS_ID, ); await new Promise((r) => setTimeout(r, 0)); mockWs!.simulateOpen(); await new Promise((r) => setTimeout(r, 0)); mockWs!.simulateClose(1009); await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i); vi.unstubAllGlobals(); }); });