/** * Blob sync session tests. * * Covers the v2.14.x rewrite that replaced per-entry SYNC_HELLO/MANIFEST/ * REQUEST/ENTRIES/DONE with a single bidirectional blob exchange. Last- * write-wins is enforced via `sent_at` versus `peach_last_sync_at`. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { encodeSyncMessage, decodeSyncMessage } from "../shared/sync-wire"; import type { SyncSessionTransport } from "./sync-coordinator"; type StorageShape = Record; interface MockTransport extends SyncSessionTransport { sentFrames: string[]; pushIncoming: (frame: string) => void; pushClose: () => void; pushError: (msg: string) => void; } function makeMockTransport(): MockTransport { const sentFrames: string[] = []; const frameHandlers: Array<(frame: string) => void> = []; const errorHandlers: Array<(msg: string) => void> = []; const closeHandlers: Array<() => void> = []; return { sentFrames, send: (frame: string) => { sentFrames.push(frame); }, onFrame: (handler) => { frameHandlers.push(handler); return () => { const i = frameHandlers.indexOf(handler); if (i >= 0) frameHandlers.splice(i, 1); }; }, onError: (handler) => { errorHandlers.push(handler); return () => { const i = errorHandlers.indexOf(handler); if (i >= 0) errorHandlers.splice(i, 1); }; }, onClose: (handler) => { closeHandlers.push(handler); return () => { const i = closeHandlers.indexOf(handler); if (i >= 0) closeHandlers.splice(i, 1); }; }, close: () => { // no-op for the mock — finish() in the coordinator will already // have called us inside finish(). }, pushIncoming: (frame) => { for (const h of frameHandlers) h(frame); }, pushClose: () => { for (const h of closeHandlers) h(); }, pushError: (msg) => { for (const h of errorHandlers) h(msg); }, }; } describe("runBlobSyncSession + applyIncomingVault", () => { let storage: StorageShape; beforeEach(() => { vi.resetModules(); storage = {}; const chromeMock = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...storage }; if (typeof key === "string") return { [key]: storage[key] }; return key.reduce>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; }); it("applyIncomingVault writes new blobs when sent_at beats lastSyncAt", async () => { const { applyIncomingVault } = await import("./sync-coordinator"); storage.peach_last_sync_at = "100"; storage.peach_vault_a = "old-a"; const verdict = await applyIncomingVault("new-a", "new-b", 200); expect(verdict).toBe("applied"); expect(storage.peach_vault_a).toBe("new-a"); expect(storage.peach_vault_b).toBe("new-b"); expect(storage.peach_last_sync_at).toBe("200"); }); it("applyIncomingVault ignores blobs whose sent_at is older", async () => { const { applyIncomingVault } = await import("./sync-coordinator"); storage.peach_last_sync_at = "500"; storage.peach_vault_a = "current-a"; const verdict = await applyIncomingVault("stale-a", "stale-b", 100); expect(verdict).toBe("ignored"); expect(storage.peach_vault_a).toBe("current-a"); expect(storage.peach_last_sync_at).toBe("500"); }); it("applyIncomingVault snapshots BEFORE writing with reason 'sync_received'", async () => { // Pre-populate storage so takeVaultSnapshot has a slotA to snapshot. const priorSlot = { version: 1, salt: "00".repeat(16), nonce: "00".repeat(12), ciphertext: "00".repeat(32), tag: "00".repeat(16), }; storage.peach_vault_a = priorSlot; storage.peach_vault_b = priorSlot; const priorJson = JSON.stringify(priorSlot); const { applyIncomingVault } = await import("./sync-coordinator"); await applyIncomingVault("incoming-a", "incoming-b", Date.now() + 1000); const history = storage.peach_vault_history as | { snapshots: Array<{ reason: string; vault_a: string }> } | undefined; expect(history?.snapshots[0]?.reason).toBe("sync_received"); // History captures the old blob (JSON-stringified) before the sync write // overwrote slotA with the incoming string. expect(history?.snapshots[0]?.vault_a).toBe(priorJson); // After apply, slotA holds the incoming bytes. expect(storage.peach_vault_a).toBe("incoming-a"); }); it("initiator: pro_required when isPro returns false", async () => { const { runBlobSyncSession } = await import("./sync-coordinator"); const transport = makeMockTransport(); const result = await runBlobSyncSession("initiator", transport, { isPro: async () => false, isUnlocked: () => true, getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }), }); expect(result.status).toBe("errored"); expect(result.error).toBe("pro_required"); expect(transport.sentFrames).toHaveLength(1); const decoded = decodeSyncMessage(transport.sentFrames[0]!); expect(decoded.type).toBe("SYNC_ERROR"); if (decoded.type === "SYNC_ERROR") { expect(decoded.reason).toBe("pro_required"); } }); it("initiator: vault_locked when isUnlocked returns false", async () => { const { runBlobSyncSession } = await import("./sync-coordinator"); const transport = makeMockTransport(); const result = await runBlobSyncSession("initiator", transport, { isPro: async () => true, isUnlocked: () => false, getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }), }); expect(result.status).toBe("errored"); expect(result.error).toBe("vault_locked"); }); it("initiator sends SYNC_VAULT_SEND first and completes on peer ACK", async () => { const { runBlobSyncSession } = await import("./sync-coordinator"); const transport = makeMockTransport(); const sessionPromise = runBlobSyncSession("initiator", transport, { isPro: async () => true, isUnlocked: () => true, getVaultBlobs: async () => ({ vault_a: "blob-a", vault_b: "blob-b" }), }); // Wait a microtask so the initiator's first send fires. await Promise.resolve(); await Promise.resolve(); expect(transport.sentFrames).toHaveLength(1); const sent = decodeSyncMessage(transport.sentFrames[0]!); expect(sent.type).toBe("SYNC_VAULT_SEND"); transport.pushIncoming( encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now() }), ); const result = await sessionPromise; expect(result.status).toBe("completed"); }); it("responder applies incoming vault if newer and sends ACK + its own SYNC_VAULT_SEND", async () => { const { runBlobSyncSession } = await import("./sync-coordinator"); const transport = makeMockTransport(); const sessionPromise = runBlobSyncSession("responder", transport, { isPro: async () => true, isUnlocked: () => true, getVaultBlobs: async () => ({ vault_a: "responder-a", vault_b: "responder-b" }), }); await Promise.resolve(); transport.pushIncoming( encodeSyncMessage({ type: "SYNC_VAULT_SEND", vault_a: "incoming-a", vault_b: "incoming-b", sent_at: Date.now() + 1000, }), ); // Settle the async work inside the frame handler. await new Promise((r) => setTimeout(r, 5)); // Responder should have sent ACK + its own SEND. expect(transport.sentFrames).toHaveLength(2); const ack = decodeSyncMessage(transport.sentFrames[0]!); const send = decodeSyncMessage(transport.sentFrames[1]!); expect(ack.type).toBe("SYNC_VAULT_ACK"); expect(send.type).toBe("SYNC_VAULT_SEND"); // Now the initiator-side ACK arrives — session completes. transport.pushIncoming( encodeSyncMessage({ type: "SYNC_VAULT_ACK", received_at: Date.now() }), ); const result = await sessionPromise; expect(result.status).toBe("completed"); expect(storage.peach_vault_a).toBe("incoming-a"); }); it("SYNC_ERROR from peer propagates as session error", async () => { const { runBlobSyncSession } = await import("./sync-coordinator"); const transport = makeMockTransport(); const sessionPromise = runBlobSyncSession("initiator", transport, { isPro: async () => true, isUnlocked: () => true, getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }), }); await Promise.resolve(); transport.pushIncoming( encodeSyncMessage({ type: "SYNC_ERROR", reason: "vault_locked" }), ); const result = await sessionPromise; expect(result.status).toBe("errored"); expect(result.error).toBe("vault_locked"); }); it("invalid frame triggers SYNC_ERROR + invalid_frame", async () => { const { runBlobSyncSession } = await import("./sync-coordinator"); const transport = makeMockTransport(); const sessionPromise = runBlobSyncSession("responder", transport, { isPro: async () => true, isUnlocked: () => true, getVaultBlobs: async () => ({ vault_a: "a", vault_b: "b" }), }); await Promise.resolve(); transport.pushIncoming("@@@invalid-base64@@@"); const result = await sessionPromise; expect(result.status).toBe("errored"); expect(result.error).toBe("invalid_frame"); }); });