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_IDENTITY: SyncPeerIdentity = {27 deviceId: "test-device",28 deviceName: "Test",29 deviceType: "extension",30 longTermPublicKeyHex: "11".repeat(32),31};323334353637interface MockWebSocketInstance {38 binaryType: string;39 onmessage: ((ev: MessageEvent) => void) | null;40 onerror: (() => void) | null;41 onclose: ((ev: Event) => void) | null;42 onopen: ((ev: Event) => void) | null;43 sentFrames: ArrayBuffer[];44 send(data: ArrayBuffer | Uint8Array): void;45 close(): void;46 simulateOpen(): void;47 simulateError(): void;48 simulateClose(): void;49 simulateMessage(data: ArrayBuffer): void;50}5152function createMockWebSocket(): MockWebSocketInstance {53 const instance: MockWebSocketInstance = {54 binaryType: "arraybuffer",55 onmessage: null,56 onerror: null,57 onclose: null,58 onopen: null,59 sentFrames: [],60 send(data: ArrayBuffer | Uint8Array) {61 if (data instanceof Uint8Array) {62 instance.sentFrames.push(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer);63 } else {64 instance.sentFrames.push(data);65 }66 },67 close() {68 69 Promise.resolve().then(() => instance.simulateClose());70 },71 simulateOpen() {72 instance.onopen?.call(instance, new Event("open"));73 },74 simulateError() {75 instance.onerror?.call(instance);76 },77 simulateClose() {78 instance.onclose?.call(instance, new Event("close"));79 },80 simulateMessage(data: ArrayBuffer) {81 instance.onmessage?.call(instance, { data } as MessageEvent);82 },83 };84 return instance;85}868788899091describe("T-020: WebSocket sync-disconnect handling", () => {92 93 94 95 96 97 98 99 it("T-020a: WebSocket onerror during handshake → session promise rejects within event loop tick", async () => {100 let mockWs: MockWebSocketInstance;101 const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {102 mockWs = createMockWebSocket();103 return mockWs;104 });105 vi.stubGlobal("WebSocket", MockWebSocketCtor);106107 const peerKp = generateKeyPair();108 const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");109110 const sessionPromise = openSyncSession(111 { ip: "127.0.0.1", port: 9999 },112 TEST_IDENTITY,113 peerHex,114 );115116 117 await new Promise((r) => setTimeout(r, 0));118119 120 mockWs!.simulateOpen();121122 123 await new Promise((r) => setTimeout(r, 0));124125 126 127 mockWs!.simulateError();128129 130 await expect(sessionPromise).rejects.toThrow(/closed|error/i);131132 vi.unstubAllGlobals();133 });134135 136 137 138 139 it("T-020b: WebSocket onclose during handshake → session promise rejects", 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 = openSyncSession(151 { ip: "127.0.0.1", port: 9999 },152 TEST_IDENTITY,153 peerHex,154 );155156 await new Promise((r) => setTimeout(r, 0));157 mockWs!.simulateOpen();158 await new Promise((r) => setTimeout(r, 0));159160 161 162 mockWs!.simulateClose();163164 await expect(sessionPromise).rejects.toThrow(/closed|error/i);165166 vi.unstubAllGlobals();167 });168169 170 171 172 173 174 175 it("T-020c: WebSocket connect timeout fires → openSyncSession rejects with timeout error", async () => {176 let mockWs: MockWebSocketInstance;177 const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {178 mockWs = createMockWebSocket();179 180 return mockWs;181 });182 vi.stubGlobal("WebSocket", MockWebSocketCtor);183184 const peerKp = generateKeyPair();185 const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");186187 188 const sessionPromise = openSyncSession(189 { ip: "127.0.0.1", port: 9999 },190 TEST_IDENTITY,191 peerHex,192 { connectTimeoutMs: 10 },193 );194195 await expect(sessionPromise).rejects.toThrow(/timed out|timeout/i);196197 vi.unstubAllGlobals();198 }, 15_000);199200 201 202 203 204 205 206 it("T-020d: oversized frame received → session rejects (MV-3 frame-size guard)", async () => {207 let mockWs: MockWebSocketInstance;208 const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {209 mockWs = createMockWebSocket();210 return mockWs;211 });212 vi.stubGlobal("WebSocket", MockWebSocketCtor);213214 const peerKp = generateKeyPair();215 const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");216217 const sessionPromise = openSyncSession(218 { ip: "127.0.0.1", port: 9999 },219 TEST_IDENTITY,220 peerHex,221 );222223 await new Promise((r) => setTimeout(r, 0));224 mockWs!.simulateOpen();225 await new Promise((r) => setTimeout(r, 0));226227 228 const bigFrame = new ArrayBuffer(SYNC_LIMITS.MAX_FRAME_BYTES + 1);229 mockWs!.simulateMessage(bigFrame);230231 await expect(sessionPromise).rejects.toThrow(/closed|error/i);232233 vi.unstubAllGlobals();234 });235});