sync-coordinator.test.ts

9.9 KB · raw

1/**2 * Blob sync session tests.3 *4 * Covers the v2.14.x rewrite that replaced per-entry SYNC_HELLO/MANIFEST/5 * REQUEST/ENTRIES/DONE with a single bidirectional blob exchange. Last-6 * write-wins is enforced via `sent_at` versus `peach_last_sync_at`.7 */89import { 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      // no-op for the mock — finish() in the coordinator will already56      // have called us inside finish().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    // Pre-populate storage so takeVaultSnapshot has a slotA to snapshot.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    // History captures the old blob (JSON-stringified) before the sync write155    // overwrote slotA with the incoming string.156    expect(history?.snapshots[0]?.vault_a).toBe(priorJson);157    // After apply, slotA holds the incoming bytes.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    // Wait a microtask so the initiator's first send fires.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    // Settle the async work inside the frame handler.239    await new Promise((r) => setTimeout(r, 5));240241    // Responder should have sent ACK + its own SEND.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    // Now the initiator-side ACK arrives — session completes.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});