123456789101112131415161718192021import { 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_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};383940414243interface 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 75 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}929394959697describe("T-020: WebSocket sync-disconnect handling", () => {98 99 100 101 102 103 104 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 = openSyncSession(117 { ip: "127.0.0.1", port: 9999 },118 TEST_IDENTITY,119 peerHex,120 TEST_RENDEZVOUS_ID,121 );122123 124 await new Promise((r) => setTimeout(r, 0));125126 127 mockWs!.simulateOpen();128129 130 await new Promise((r) => setTimeout(r, 0));131132 133 134 mockWs!.simulateError();135136 137 await expect(sessionPromise).rejects.toThrow(/closed|error/i);138139 vi.unstubAllGlobals();140 });141142 143 144 145 146 it("T-020b: WebSocket onclose during handshake → session promise rejects", async () => {147 let mockWs: MockWebSocketInstance;148 const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {149 mockWs = createMockWebSocket();150 return mockWs;151 });152 vi.stubGlobal("WebSocket", MockWebSocketCtor);153154 const peerKp = generateKeyPair();155 const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");156157 const sessionPromise = openSyncSession(158 { ip: "127.0.0.1", port: 9999 },159 TEST_IDENTITY,160 peerHex,161 TEST_RENDEZVOUS_ID,162 );163164 await new Promise((r) => setTimeout(r, 0));165 mockWs!.simulateOpen();166 await new Promise((r) => setTimeout(r, 0));167168 169 170 mockWs!.simulateClose();171172 await expect(sessionPromise).rejects.toThrow(/closed|error/i);173174 vi.unstubAllGlobals();175 });176177 178 179 180 181 182 183 it("T-020c: WebSocket connect timeout fires → openSyncSession rejects with timeout error", async () => {184 let mockWs: MockWebSocketInstance;185 const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {186 mockWs = createMockWebSocket();187 188 return mockWs;189 });190 vi.stubGlobal("WebSocket", MockWebSocketCtor);191192 const peerKp = generateKeyPair();193 const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");194195 196 const sessionPromise = openSyncSession(197 { ip: "127.0.0.1", port: 9999 },198 TEST_IDENTITY,199 peerHex,200 TEST_RENDEZVOUS_ID,201 { connectTimeoutMs: 10 },202 );203204 await expect(sessionPromise).rejects.toThrow(/timed out|timeout/i);205206 vi.unstubAllGlobals();207 }, 15_000);208209 210 211 212 213 214 215 it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {216 let mockWs: MockWebSocketInstance;217 const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {218 mockWs = createMockWebSocket();219 return mockWs;220 });221 vi.stubGlobal("WebSocket", MockWebSocketCtor);222223 const peerKp = generateKeyPair();224 const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");225226 const sessionPromise = openSyncSession(227 { ip: "127.0.0.1", port: 9999 },228 TEST_IDENTITY,229 peerHex,230 TEST_RENDEZVOUS_ID,231 );232233 await new Promise((r) => setTimeout(r, 0));234 mockWs!.simulateOpen();235 await new Promise((r) => setTimeout(r, 0));236237 238 const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);239 mockWs!.simulateMessage(bigFrame);240241 await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);242243 vi.unstubAllGlobals();244 });245246 it("T-020e: relay close 1009 surfaces a distinct frame-too-large error", 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 = openSyncSession(257 { ip: "127.0.0.1", port: 9999 },258 TEST_IDENTITY,259 peerHex,260 TEST_RENDEZVOUS_ID,261 );262263 await new Promise((r) => setTimeout(r, 0));264 mockWs!.simulateOpen();265 await new Promise((r) => setTimeout(r, 0));266 mockWs!.simulateClose(1009);267268 await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);269 vi.unstubAllGlobals();270 });271});