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 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 = openSyncSessionAtUrl(117 "ws://127.0.0.1:9999/",118 "LAN peer",119 TEST_IDENTITY,120 peerHex,121 TEST_RENDEZVOUS_ID,122 );123124 125 await new Promise((r) => setTimeout(r, 0));126127 128 mockWs!.simulateOpen();129130 131 await new Promise((r) => setTimeout(r, 0));132133 134 135 mockWs!.simulateError();136137 138 await expect(sessionPromise).rejects.toThrow(/closed|error/i);139140 vi.unstubAllGlobals();141 });142143 144 145 146 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 171 172 mockWs!.simulateClose();173174 await expect(sessionPromise).rejects.toThrow(/closed|error/i);175176 vi.unstubAllGlobals();177 });178179 180 181 182 183 184 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 190 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 198 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 238 239 240 241 242 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 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});