123456789101112131415161718192021import { 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};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 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 122 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 133 134 135 136 137 138 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 159 await new Promise((r) => setTimeout(r, 0));160161 162 mockWs!.simulateOpen();163164 165 await new Promise((r) => setTimeout(r, 0));166167 168 169 mockWs!.simulateError();170171 172 await expect(sessionPromise).rejects.toThrow(/closed|error/i);173174 vi.unstubAllGlobals();175 });176177 178 179 180 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 205 206 mockWs!.simulateClose();207208 await expect(sessionPromise).rejects.toThrow(/closed|error/i);209210 vi.unstubAllGlobals();211 });212213 214 215 216 217 218 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 224 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 232 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 272 273 274 275 276 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 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});