123456789import { beforeEach, describe, expect, it, vi } from "vitest";10import { encodeSyncMessage, decodeSyncMessage } from "../shared/sync-wire";11import type { SyncSessionTransport } from "./sync-coordinator";1213type StorageShape = Record<string, unknown>;1415interface MockTransport extends SyncSessionTransport {16 sentFrames: string[];17 pushIncoming: (frame: string) => void;18 pushClose: () => void;19 pushError: (msg: string) => void;20}2122function makeMockTransport(): MockTransport {23 const sentFrames: string[] = [];24 const frameHandlers: Array<(frame: string) => void> = [];25 const errorHandlers: Array<(msg: string) => void> = [];26 const closeHandlers: Array<() => void> = [];2728 return {29 sentFrames,30 send: (frame: string) => {31 sentFrames.push(frame);32 },33 onFrame: (handler) => {34 frameHandlers.push(handler);35 return () => {36 const i = frameHandlers.indexOf(handler);37 if (i >= 0) frameHandlers.splice(i, 1);38 };39 },40 onError: (handler) => {41 errorHandlers.push(handler);42 return () => {43 const i = errorHandlers.indexOf(handler);44 if (i >= 0) errorHandlers.splice(i, 1);45 };46 },47 onClose: (handler) => {48 closeHandlers.push(handler);49 return () => {50 const i = closeHandlers.indexOf(handler);51 if (i >= 0) closeHandlers.splice(i, 1);52 };53 },54 close: () => {55 56 57 },58 pushIncoming: (frame) => {59 for (const h of frameHandlers) h(frame);60 },61 pushClose: () => {62 for (const h of closeHandlers) h();63 },64 pushError: (msg) => {65 for (const h of errorHandlers) h(msg);66 },67 };68}6970describe("runBlobSyncSession + applyIncomingVault", () => {71 let storage: StorageShape;7273 beforeEach(() => {74 vi.resetModules();75 storage = {};7677 const chromeMock = {78 storage: {79 local: {80 get: vi.fn(async (key?: string | string[]) => {81 if (!key) return { ...storage };82 if (typeof key === "string") return { [key]: storage[key] };83 return key.reduce<Record<string, unknown>>((acc, k) => {84 acc[k] = storage[k];85 return acc;86 }, {});87 }),88 set: vi.fn(async (next: Record<string, unknown>) => {89 Object.assign(storage, next);90 }),91 remove: vi.fn(async (keys: string | string[]) => {92 for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];93 }),94 },95 },96 alarms: {97 clear: vi.fn(),98 create: vi.fn(),99 onAlarm: { addListener: vi.fn() },100 },101 idle: {102 setDetectionInterval: vi.fn(),103 onStateChanged: { addListener: vi.fn() },104 },105 } as unknown as typeof chrome;106 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;107 });108109 it("applyIncomingVault writes new blobs when sent_at beats lastSyncAt", async () => {110 const { applyIncomingVault } = await import("./sync-coordinator");111 storage.peach_last_sync_at = "100";112 storage.peach_vault_a = "old-a";113114 const verdict = await applyIncomingVault("new-a", "new-b", 200);115116 expect(verdict).toBe("applied");117 expect(storage.peach_vault_a).toBe("new-a");118 expect(storage.peach_vault_b).toBe("new-b");119 expect(storage.peach_last_sync_at).toBe("200");120 });121122 it("applyIncomingVault ignores blobs whose sent_at is older", async () => {123 const { applyIncomingVault } = await import("./sync-coordinator");124 storage.peach_last_sync_at = "500";125 storage.peach_vault_a = "current-a";126127 const verdict = await applyIncomingVault("stale-a", "stale-b", 100);128129 expect(verdict).toBe("ignored");130 expect(storage.peach_vault_a).toBe("current-a");131 expect(storage.peach_last_sync_at).toBe("500");132 });133134 it("applyIncomingVault snapshots BEFORE writing with reason 'sync_received'", async () => {135 136 const priorSlot = {137 version: 1,138 salt: "00".repeat(16),139 nonce: "00".repeat(12),140 ciphertext: "00".repeat(32),141 tag: "00".repeat(16),142 };143 storage.peach_vault_a = priorSlot;144 storage.peach_vault_b = priorSlot;145 const priorJson = JSON.stringify(priorSlot);146147 const { applyIncomingVault } = await import("./sync-coordinator");148 await applyIncomingVault("incoming-a", "incoming-b", Date.now() + 1000);149150 const history = storage.peach_vault_history as151 | { snapshots: Array<{ reason: string; vault_a: string }> }152 | undefined;153 expect(history?.snapshots[0]?.reason).toBe("sync_received");154 155 156 expect(history?.snapshots[0]?.vault_a).toBe(priorJson);157 158 expect(storage.peach_vault_a).toBe("incoming-a");159 });160161 it("initiator: pro_required when isPro returns false", async () => {162 const { runBlobSyncSession } = await import("./sync-coordinator");163 const transport = makeMockTransport();164 const result = await runBlobSyncSession("initiator", transport, {165 isPro: async () => false,166 isUnlocked: () => true,167 getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),168 });169170 expect(result.status).toBe("errored");171 expect(result.error).toBe("pro_required");172 expect(transport.sentFrames).toHaveLength(1);173 const decoded = decodeSyncMessage(transport.sentFrames[0]!);174 expect(decoded.type).toBe("SYNC_ERROR");175 if (decoded.type === "SYNC_ERROR") {176 expect(decoded.reason).toBe("pro_required");177 }178 });179180 it("initiator: vault_locked when isUnlocked returns false", async () => {181 const { runBlobSyncSession } = await import("./sync-coordinator");182 const transport = makeMockTransport();183 const result = await runBlobSyncSession("initiator", transport, {184 isPro: async () => true,185 isUnlocked: () => false,186 getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),187 });188189 expect(result.status).toBe("errored");190 expect(result.error).toBe("vault_locked");191 });192193 it("initiator sends SYNC_VAULT_SEND first and completes on peer ACK", async () => {194 const { runBlobSyncSession } = await import("./sync-coordinator");195 const transport = makeMockTransport();196 const sessionPromise = runBlobSyncSession("initiator", transport, {197 isPro: async () => true,198 isUnlocked: () => true,199 getVaultBlobs: async () => ({ vault_a: "blob-a", vault_b: "blob-b" }),200 });201202 203 await Promise.resolve();204 await Promise.resolve();205206 expect(transport.sentFrames).toHaveLength(1);207 const sent = decodeSyncMessage(transport.sentFrames[0]!);208 expect(sent.type).toBe("SYNC_VAULT_SEND");209210 transport.pushIncoming(211 encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now() }),212 );213214 const result = await sessionPromise;215 expect(result.status).toBe("completed");216 });217218 it("responder applies incoming vault if newer and sends ACK + its own SYNC_VAULT_SEND", async () => {219 const { runBlobSyncSession } = await import("./sync-coordinator");220 const transport = makeMockTransport();221222 const sessionPromise = runBlobSyncSession("responder", transport, {223 isPro: async () => true,224 isUnlocked: () => true,225 getVaultBlobs: async () => ({ vault_a: "responder-a", vault_b: "responder-b" }),226 });227228 await Promise.resolve();229 transport.pushIncoming(230 encodeSyncMessage({231 type: "SYNC_VAULT_SEND",232 vault_a: "incoming-a",233 vault_b: "incoming-b",234 sent_at: Date.now() + 1000,235 }),236 );237238 239 await new Promise((r) => setTimeout(r, 5));240241 242 expect(transport.sentFrames).toHaveLength(2);243 const ack = decodeSyncMessage(transport.sentFrames[0]!);244 const send = decodeSyncMessage(transport.sentFrames[1]!);245 expect(ack.type).toBe("SYNC_VAULT_ACK");246 expect(send.type).toBe("SYNC_VAULT_SEND");247248 249 transport.pushIncoming(250 encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now() }),251 );252253 const result = await sessionPromise;254 expect(result.status).toBe("completed");255 expect(storage.peach_vault_a).toBe("incoming-a");256 });257258 it("SYNC_ERROR from peer propagates as session error", async () => {259 const { runBlobSyncSession } = await import("./sync-coordinator");260 const transport = makeMockTransport();261 const sessionPromise = runBlobSyncSession("initiator", transport, {262 isPro: async () => true,263 isUnlocked: () => true,264 getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),265 });266267 await Promise.resolve();268 transport.pushIncoming(269 encodeSyncMessage({ type: "SYNC_ERROR", reason: "vault_locked" }),270 );271272 const result = await sessionPromise;273 expect(result.status).toBe("errored");274 expect(result.error).toBe("vault_locked");275 });276277 it("invalid frame triggers SYNC_ERROR + invalid_frame", async () => {278 const { runBlobSyncSession } = await import("./sync-coordinator");279 const transport = makeMockTransport();280 const sessionPromise = runBlobSyncSession("responder", transport, {281 isPro: async () => true,282 isUnlocked: () => true,283 getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }),284 });285286 await Promise.resolve();287 transport.pushIncoming("@@@invalid-base64@@@");288289 const result = await sessionPromise;290 expect(result.status).toBe("errored");291 expect(result.error).toBe("invalid_frame");292 });293});