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 it("T-020c2: an occupied-but-unspliced relay path times out the handshake", async () => {210 let mockWs: MockWebSocketInstance;211 const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {212 mockWs = createMockWebSocket();213 return mockWs;214 });215 vi.stubGlobal("WebSocket", MockWebSocketCtor);216217 const peerKp = generateKeyPair();218 const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");219 const sessionPromise = openSyncSession(220 { ip: "127.0.0.1", port: 9999 },221 TEST_IDENTITY,222 peerHex,223 TEST_RENDEZVOUS_ID,224 { handshakeTimeoutMs: 10 },225 );226227 await new Promise((r) => setTimeout(r, 0));228 mockWs!.simulateOpen();229 await expect(sessionPromise).rejects.toThrow(/handshake_timeout/);230 vi.unstubAllGlobals();231 });232233 234 235 236 237 238 239 it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {240 let mockWs: MockWebSocketInstance;241 const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {242 mockWs = createMockWebSocket();243 return mockWs;244 });245 vi.stubGlobal("WebSocket", MockWebSocketCtor);246247 const peerKp = generateKeyPair();248 const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");249250 const sessionPromise = openSyncSession(251 { ip: "127.0.0.1", port: 9999 },252 TEST_IDENTITY,253 peerHex,254 TEST_RENDEZVOUS_ID,255 );256257 await new Promise((r) => setTimeout(r, 0));258 mockWs!.simulateOpen();259 await new Promise((r) => setTimeout(r, 0));260261 262 const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);263 mockWs!.simulateMessage(bigFrame);264265 await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);266267 vi.unstubAllGlobals();268 });269270 it("T-020e: relay close 1009 surfaces a distinct frame-too-large error", async () => {271 let mockWs: MockWebSocketInstance;272 const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {273 mockWs = createMockWebSocket();274 return mockWs;275 });276 vi.stubGlobal("WebSocket", MockWebSocketCtor);277278 const peerKp = generateKeyPair();279 const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");280 const sessionPromise = openSyncSession(281 { ip: "127.0.0.1", port: 9999 },282 TEST_IDENTITY,283 peerHex,284 TEST_RENDEZVOUS_ID,285 );286287 await new Promise((r) => setTimeout(r, 0));288 mockWs!.simulateOpen();289 await new Promise((r) => setTimeout(r, 0));290 mockWs!.simulateClose(1009);291292 await expect(sessionPromise).rejects.toThrow(/frame too large.*1009/i);293 vi.unstubAllGlobals();294 });295});