sync-coordinator.test.ts

20.8 KB · raw

1import { describe, expect, it, vi } from "vitest";23import {4  applySyncEntriesMessage,5  buildSyncEntriesMessage,6  buildLocalSyncSnapshot,7  cancelSyncIfVaultLocked,8  checkSyncPreflight,9  createNativeSyncTransport,10  dispatchSyncFrame,11  planManifestSync,12  runSyncSession,13  type NativeSyncTransportBindings,14  type SyncSessionTransport,15} from "./sync-coordinator";16import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";17import type { VaultEntry } from "../shared/types";1819function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {20  return {21    id: "entry-1",22    type: "login",23    title: "GitHub",24    username: "june",25    password: "secret",26    url: "https://github.com",27    createdAt: 1_000,28    modifiedAt: 1_000,29    ...overrides,30  };31}3233describe("sync coordinator", () => {34  it("blocks sync for free users before inspecting the vault", async () => {35    const getEntries = vi.fn(() => [entry()]);3637    await expect(38      checkSyncPreflight({39        isProUser: async () => false,40        isUnlocked: () => true,41      })42    ).resolves.toEqual({ ok: false, error: "pro_required" });4344    expect(getEntries).not.toHaveBeenCalled();45  });4647  it("blocks sync when the vault is locked", async () => {48    await expect(49      checkSyncPreflight({50        isProUser: async () => true,51        isUnlocked: () => false,52      })53    ).resolves.toEqual({ ok: false, error: "vault_locked" });54  });5556  it("builds a local sync snapshot only after preflight passes", async () => {57    const snapshot = await buildLocalSyncSnapshot({58      isProUser: async () => true,59      isUnlocked: () => true,60      getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })],61    });6263    expect(snapshot.entries).toHaveLength(2);64    expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]);65  });6667  it("plans manifest sync without sending entry payloads", async () => {68    const plan = await planManifestSync(69      [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }],70      {71        isProUser: async () => true,72        isUnlocked: () => true,73        getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })],74      }75    );7677    expect(plan.diff).toEqual({78      requestFromRemote: ["remote-only"],79      offerToRemote: ["local-only"],80      conflicts: [],81    });82  });8384  it("builds encrypted entry messages for requested ids", async () => {85    const rawEntryKey = new Uint8Array(32).fill(5);86    const message = await buildSyncEntriesMessage(["b"], rawEntryKey, {87      isProUser: async () => true,88      isUnlocked: () => true,89      getEntries: () => [90        entry({ id: "a" }),91        entry({92          id: "b",93          passkey: {94            rpId: "github.com",95            rpName: "GitHub",96            credentialId: "credential",97            publicKeyCose: "public",98            privateKeyJwk: { kty: "EC", d: "secret" },99            userHandle: "user",100            userName: "june",101            userDisplayName: "June",102            algorithm: -7,103            signCount: 1,104            aaguid: "aaguid",105            backupEligible: true,106            backupState: true,107            createdAt: 1_000,108          },109        }),110      ],111    });112113    expect(message.type).toBe("SYNC_ENTRIES");114    expect(message.entries).toHaveLength(1);115    expect(message.entries[0]).toMatchObject({ id: "b" });116    expect(message.entries[0]?.ciphertext).not.toContain("secret");117  });118119  it("decrypts and applies incoming entry messages through vault merge", async () => {120    const rawEntryKey = new Uint8Array(32).fill(6);121    const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, {122      isProUser: async () => true,123      isUnlocked: () => true,124      getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })],125    });126    const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));127128    await expect(129      applySyncEntriesMessage(message, rawEntryKey, {130        isProUser: async () => true,131        isUnlocked: () => true,132        restoreEntries,133      })134    ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 });135136    expect(restoreEntries).toHaveBeenCalledWith(137      [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })],138      "merge"139    );140  });141142  it("sends a stable sync error when a peer asks while the vault is locked", async () => {143    const sendSyncData = vi.fn();144145    const cancelled = await cancelSyncIfVaultLocked("conn-1", {146      isUnlocked: () => false,147      sendSyncData,148    });149150    expect(cancelled).toBe(true);151    expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String));152    expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({153      type: "SYNC_ERROR",154      code: "vault_locked",155      message: "Vault is locked; unlock Peach before syncing.",156    });157  });158159  it("does not cancel an unlocked vault sync", async () => {160    const sendSyncData = vi.fn();161162    expect(163      await cancelSyncIfVaultLocked("conn-1", {164        isUnlocked: () => true,165        sendSyncData,166      })167    ).toBe(false);168    expect(sendSyncData).not.toHaveBeenCalled();169  });170171  describe("dispatchSyncFrame", () => {172    const passDeps = (entries: VaultEntry[]) => ({173      isProUser: async () => true,174      isUnlocked: () => true,175      getEntries: () => entries,176      restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),177    });178179    it("answers SYNC_HELLO with a sorted SYNC_MANIFEST", async () => {180      const deps = passDeps([entry({ id: "b" }), entry({ id: "a" })]);181      const result = await dispatchSyncFrame(182        encodeSyncMessage({183          type: "SYNC_HELLO",184          deviceId: "device",185          clockMs: 1,186          vaultVersion: 1,187        }),188        new Uint8Array(32).fill(1),189        deps190      );191      expect(result.outbound).toHaveLength(1);192      const decoded = decodeSyncMessage(result.outbound[0]!);193      expect(decoded.type).toBe("SYNC_MANIFEST");194      if (decoded.type === "SYNC_MANIFEST") {195        expect(decoded.entries.map((entry) => entry.id)).toEqual(["a", "b"]);196      }197    });198199    it("requests missing ids and offers local-only ids on SYNC_MANIFEST", async () => {200      const deps = passDeps([entry({ id: "local", modifiedAt: 2_000 })]);201      const result = await dispatchSyncFrame(202        encodeSyncMessage({203          type: "SYNC_MANIFEST",204          entries: [{ id: "remote", modifiedAt: 1_500, hash: "remote" }],205        }),206        new Uint8Array(32).fill(2),207        deps208      );209      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));210      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({211        type: "SYNC_REQUEST",212        ids: ["remote"],213      });214      const entriesMessage = messages.find((m) => m.type === "SYNC_ENTRIES");215      expect(entriesMessage?.type).toBe("SYNC_ENTRIES");216      if (entriesMessage?.type === "SYNC_ENTRIES") {217        expect(entriesMessage.entries.map((e) => e.id)).toEqual(["local"]);218      }219    });220221    it("returns SYNC_DONE when manifests already agree", async () => {222      const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);223      const localManifest = encodeSyncMessage({224        type: "SYNC_MANIFEST",225        entries: [226          {227            id: "shared",228            modifiedAt: 1_000,229            hash: (230              await (await import("../shared/sync-protocol")).buildSyncManifest(231                deps.getEntries()232              )233            )[0]!.hash,234          },235        ],236      });237      const result = await dispatchSyncFrame(238        localManifest,239        new Uint8Array(32).fill(3),240        deps241      );242      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));243      expect(messages).toHaveLength(1);244      expect(messages[0]!.type).toBe("SYNC_DONE");245    });246247    it("encrypts only the requested ids on SYNC_REQUEST", async () => {248      const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);249      const result = await dispatchSyncFrame(250        encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),251        new Uint8Array(32).fill(4),252        deps253      );254      expect(result.outbound).toHaveLength(1);255      const decoded = decodeSyncMessage(result.outbound[0]!);256      expect(decoded.type).toBe("SYNC_ENTRIES");257      if (decoded.type === "SYNC_ENTRIES") {258        expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);259      }260    });261262    it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {263      const rawEntryKey = new Uint8Array(32).fill(9);264      const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {265        isProUser: async () => true,266        isUnlocked: () => true,267        getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],268      });269      const deps = passDeps([]);270      const result = await dispatchSyncFrame(271        encodeSyncMessage(offered),272        rawEntryKey,273        deps274      );275      expect(deps.restoreEntries).toHaveBeenCalledWith(276        [expect.objectContaining({ id: "remote", title: "Remote" })],277        "merge"278      );279      const decoded = decodeSyncMessage(result.outbound[0]!);280      expect(decoded.type).toBe("SYNC_DONE");281    });282283    it("returns SYNC_ERROR + done for malformed inbound frames", async () => {284      const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {285        isProUser: async () => true,286        isUnlocked: () => true,287        getEntries: () => [],288        restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),289      });290      expect(result.done).toBe(true);291      expect(result.error?.code).toBe("invalid_message");292      expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");293    });294295    it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {296      const result = await dispatchSyncFrame(297        encodeSyncMessage({298          type: "SYNC_HELLO",299          deviceId: "device",300          clockMs: 0,301          vaultVersion: 0,302        }),303        new Uint8Array(32),304        {305          isProUser: async () => true,306          isUnlocked: () => false,307          getEntries: () => [],308          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),309        }310      );311      expect(result.done).toBe(true);312      expect(result.error?.code).toBe("vault_locked");313      const decoded = decodeSyncMessage(result.outbound[0]!);314      expect(decoded.type).toBe("SYNC_ERROR");315      if (decoded.type === "SYNC_ERROR") {316        expect(decoded.code).toBe("vault_locked");317      }318    });319320    it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {321      const result = await dispatchSyncFrame(322        encodeSyncMessage({ type: "SYNC_DONE" }),323        new Uint8Array(32).fill(7),324        passDeps([])325      );326      expect(result.outbound).toEqual([]);327      expect(result.done).toBe(true);328    });329330    it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {331      const result = await dispatchSyncFrame(332        encodeSyncMessage({333          type: "SYNC_ERROR",334          code: "peer_failed",335          message: "Peer reported error.",336        }),337        new Uint8Array(32).fill(8),338        passDeps([])339      );340      expect(result.outbound).toEqual([]);341      expect(result.done).toBe(true);342      expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });343    });344  });345346  describe("runSyncSession", () => {347    interface TransportHarness {348      transport: SyncSessionTransport;349      sent: string[];350      emitFrame: (frame: string) => Promise<void>;351      emitError: (message: string) => Promise<void>;352      emitClose: () => Promise<void>;353      isClosed: () => boolean;354      ready: () => Promise<void>;355    }356357    function makeTransport(rawKey: Uint8Array): TransportHarness {358      const sent: string[] = [];359      const frameQueue: string[] = [];360      const errorQueue: string[] = [];361      let pendingClose = false;362      let frameHandler: ((frame: string) => void) | null = null;363      let errorHandler: ((message: string) => void) | null = null;364      let closeHandler: (() => void) | null = null;365      let resolveReady: (() => void) | null = null;366      const ready = new Promise<void>((resolve) => {367        resolveReady = resolve;368      });369      let closed = false;370371      const checkReady = (): void => {372        if (frameHandler && errorHandler && closeHandler && resolveReady) {373          resolveReady();374          resolveReady = null;375          for (const queued of frameQueue.splice(0)) frameHandler(queued);376          for (const queued of errorQueue.splice(0)) errorHandler(queued);377          if (pendingClose && closeHandler) {378            pendingClose = false;379            closeHandler();380          }381        }382      };383384      const transport: SyncSessionTransport = {385        send: (frame) => {386          sent.push(frame);387        },388        onFrame: (handler) => {389          frameHandler = handler;390          checkReady();391          return () => {392            if (frameHandler === handler) frameHandler = null;393          };394        },395        onError: (handler) => {396          errorHandler = handler;397          checkReady();398          return () => {399            if (errorHandler === handler) errorHandler = null;400          };401        },402        onClose: (handler) => {403          closeHandler = handler;404          checkReady();405          return () => {406            if (closeHandler === handler) closeHandler = null;407          };408        },409        close: () => {410          closed = true;411        },412        getSessionKey: async () => rawKey,413      };414415      const flush = async (): Promise<void> => {416        for (let i = 0; i < 50; i += 1) {417          await new Promise<void>((resolve) => setTimeout(resolve, 0));418        }419      };420421      return {422        transport,423        sent,424        emitFrame: async (frame) => {425          if (frameHandler) frameHandler(frame);426          else frameQueue.push(frame);427          await flush();428        },429        emitError: async (message) => {430          if (errorHandler) errorHandler(message);431          else errorQueue.push(message);432          await flush();433        },434        emitClose: async () => {435          if (closeHandler) closeHandler();436          else pendingClose = true;437          await flush();438        },439        isClosed: () => closed,440        ready: () => ready,441      };442    }443444    it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {445      const harness = makeTransport(new Uint8Array(32).fill(11));446447      const sessionPromise = runSyncSession({448        role: "initiator",449        deviceId: "device-a",450        vaultVersion: 1,451        transport: harness.transport,452        deps: {453          isProUser: async () => true,454          isUnlocked: () => true,455          getEntries: () => [],456          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),457        },458      });459460      await harness.ready();461      expect(harness.sent).toHaveLength(1);462      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");463464      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));465466      const result = await sessionPromise;467      expect(result.status).toBe("completed");468      expect(harness.isClosed()).toBe(true);469    });470471    it("does not send SYNC_HELLO when responder", async () => {472      const harness = makeTransport(new Uint8Array(32));473      const sessionPromise = runSyncSession({474        role: "responder",475        deviceId: "device-b",476        vaultVersion: 1,477        transport: harness.transport,478        deps: {479          isProUser: async () => true,480          isUnlocked: () => true,481          getEntries: () => [],482          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),483        },484      });485      await harness.ready();486      expect(harness.sent).toEqual([]);487488      await harness.emitClose();489      const result = await sessionPromise;490      expect(result.status).toBe("closed");491    });492493    it("aborts immediately with SYNC_ERROR when preflight fails", async () => {494      const harness = makeTransport(new Uint8Array(32));495      const result = await runSyncSession({496        role: "initiator",497        deviceId: "device",498        vaultVersion: 1,499        transport: harness.transport,500        deps: {501          isProUser: async () => false,502          isUnlocked: () => true,503          getEntries: () => [],504          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),505        },506      });507508      expect(result.status).toBe("errored");509      expect(result.error?.code).toBe("pro_required");510      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");511      expect(harness.isClosed()).toBe(true);512    });513514    it("propagates transport errors as a session result", async () => {515      const harness = makeTransport(new Uint8Array(32));516      const sessionPromise = runSyncSession({517        role: "responder",518        deviceId: "device",519        vaultVersion: 1,520        transport: harness.transport,521        deps: {522          isProUser: async () => true,523          isUnlocked: () => true,524          getEntries: () => [],525          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),526        },527      });528      await harness.ready();529      await harness.emitError("native host disconnected");530      const result = await sessionPromise;531      expect(result.status).toBe("errored");532      expect(result.error).toEqual({533        code: "transport_error",534        message: "native host disconnected",535      });536    });537538    it("createNativeSyncTransport routes only matching frames and errors", async () => {539      const sendSyncData = vi.fn();540      const disconnectFromPeer = vi.fn();541      const handlers: {542        frame: ((id: string, data: string) => void) | null;543        error: ((msg: string, id?: string) => void) | null;544        disconnect: ((id?: string) => void) | null;545      } = { frame: null, error: null, disconnect: null };546547      const bindings: NativeSyncTransportBindings = {548        sendSyncData,549        disconnectFromPeer,550        onSyncMessage: (h) => {551          handlers.frame = h;552          return () => {553            if (handlers.frame === h) handlers.frame = null;554          };555        },556        onError: (h) => {557          handlers.error = h;558          return () => {559            if (handlers.error === h) handlers.error = null;560          };561        },562        onDisconnected: (h) => {563          handlers.disconnect = h;564          return () => {565            if (handlers.disconnect === h) handlers.disconnect = null;566          };567        },568        getNativeSessionKey: async () => new Uint8Array(32).fill(7),569      };570571      const transport = await createNativeSyncTransport("target", bindings);572573      const frames: string[] = [];574      const errors: string[] = [];575      let closes = 0;576      transport.onFrame((frame) => frames.push(frame));577      transport.onError((message) => errors.push(message));578      transport.onClose(() => {579        closes += 1;580      });581582      handlers.frame?.("other", "ignored");583      handlers.frame?.("target", "kept");584      expect(frames).toEqual(["kept"]);585586      handlers.error?.("scoped error", "other");587      handlers.error?.("scoped error", "target");588      handlers.error?.("global error", undefined);589      expect(errors).toEqual(["scoped error", "global error"]);590591      handlers.disconnect?.("other");592      handlers.disconnect?.("target");593      handlers.disconnect?.(undefined);594      expect(closes).toBe(2);595596      transport.send("frame");597      expect(sendSyncData).toHaveBeenCalledWith("target", "frame");598599      transport.close();600      expect(disconnectFromPeer).toHaveBeenCalledWith("target");601602      const key = await transport.getSessionKey();603      expect(key).toEqual(new Uint8Array(32).fill(7));604    });605606    it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {607      const rawKey = new Uint8Array(32).fill(12);608      const harness = makeTransport(rawKey);609      const restoreEntries = vi610        .fn()611        .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });612613      const sessionPromise = runSyncSession({614        role: "responder",615        deviceId: "device",616        vaultVersion: 1,617        transport: harness.transport,618        deps: {619          isProUser: async () => true,620          isUnlocked: () => true,621          getEntries: () => [],622          restoreEntries,623        },624      });625      await harness.ready();626627      const incoming = await buildSyncEntriesMessage(["a"], rawKey, {628        isProUser: async () => true,629        isUnlocked: () => true,630        getEntries: () => [entry({ id: "a", title: "First" })],631      });632      await harness.emitFrame(encodeSyncMessage(incoming));633      // Peer signals end of its side; session can resolve.634      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));635636      const result = await sessionPromise;637      expect(result.status).toBe("completed");638      expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });639    });640  });641});