sync-coordinator.test.ts

24.1 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("resolves equal-timestamp conflicts deterministically: local wins → offer", async () => {222      const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];223      const restoreEntries = vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 }));224      const localManifest = (225        await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)226      )[0]!;227      // Pick a remote hash that lex-compares larger so local wins.228      const remoteHash =229        localManifest.hash < "f".repeat(64) ? "f".repeat(64) : "0".repeat(64);230231      const result = await dispatchSyncFrame(232        encodeSyncMessage({233          type: "SYNC_MANIFEST",234          entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],235        }),236        new Uint8Array(32).fill(20),237        {238          isProUser: async () => true,239          isUnlocked: () => true,240          getEntries: () => localEntries,241          restoreEntries,242        }243      );244245      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));246      // Local wins → offer "shared", no request, no archive.247      expect(restoreEntries).not.toHaveBeenCalled();248      const offer = messages.find((m) => m.type === "SYNC_ENTRIES");249      expect(offer?.type).toBe("SYNC_ENTRIES");250      if (offer?.type === "SYNC_ENTRIES") {251        expect(offer.entries.map((e) => e.id)).toEqual(["shared"]);252      }253      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toBeUndefined();254    });255256    it("resolves equal-timestamp conflicts deterministically: remote wins → request + archive", async () => {257      const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];258      const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));259      const localManifest = (260        await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)261      )[0]!;262      // Pick a remote hash that lex-compares smaller so remote wins.263      const remoteHash =264        localManifest.hash > "0".repeat(64) ? "0".repeat(64) : "f".repeat(64);265266      const result = await dispatchSyncFrame(267        encodeSyncMessage({268          type: "SYNC_MANIFEST",269          entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],270        }),271        new Uint8Array(32).fill(21),272        {273          isProUser: async () => true,274          isUnlocked: () => true,275          getEntries: () => localEntries,276          restoreEntries,277        }278      );279280      // Remote wins → request "shared" from peer, archive our local copy under281      // a derived id, no offer.282      expect(restoreEntries).toHaveBeenCalledTimes(1);283      const archived = (restoreEntries.mock.calls[0] as unknown as [VaultEntry[]])[0];284      expect(archived).toHaveLength(1);285      expect(archived[0]!.id).toBe(`shared-conflict-${localManifest.hash.slice(0, 8)}`);286      expect(archived[0]!.title).toBe("LocalEdit (conflicted copy)");287288      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));289      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({290        type: "SYNC_REQUEST",291        ids: ["shared"],292      });293      expect(messages.find((m) => m.type === "SYNC_ENTRIES")).toBeUndefined();294      expect(result.merge).toEqual({ added: 1, updated: 0, skipped: 0 });295    });296297    it("returns SYNC_DONE when manifests already agree", async () => {298      const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);299      const localManifest = encodeSyncMessage({300        type: "SYNC_MANIFEST",301        entries: [302          {303            id: "shared",304            modifiedAt: 1_000,305            hash: (306              await (await import("../shared/sync-protocol")).buildSyncManifest(307                deps.getEntries()308              )309            )[0]!.hash,310          },311        ],312      });313      const result = await dispatchSyncFrame(314        localManifest,315        new Uint8Array(32).fill(3),316        deps317      );318      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));319      expect(messages).toHaveLength(1);320      expect(messages[0]!.type).toBe("SYNC_DONE");321    });322323    it("encrypts only the requested ids on SYNC_REQUEST", async () => {324      const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);325      const result = await dispatchSyncFrame(326        encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),327        new Uint8Array(32).fill(4),328        deps329      );330      expect(result.outbound).toHaveLength(1);331      const decoded = decodeSyncMessage(result.outbound[0]!);332      expect(decoded.type).toBe("SYNC_ENTRIES");333      if (decoded.type === "SYNC_ENTRIES") {334        expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);335      }336    });337338    it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {339      const rawEntryKey = new Uint8Array(32).fill(9);340      const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {341        isProUser: async () => true,342        isUnlocked: () => true,343        getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],344      });345      const deps = passDeps([]);346      const result = await dispatchSyncFrame(347        encodeSyncMessage(offered),348        rawEntryKey,349        deps350      );351      expect(deps.restoreEntries).toHaveBeenCalledWith(352        [expect.objectContaining({ id: "remote", title: "Remote" })],353        "merge"354      );355      const decoded = decodeSyncMessage(result.outbound[0]!);356      expect(decoded.type).toBe("SYNC_DONE");357    });358359    it("returns SYNC_ERROR + done for malformed inbound frames", async () => {360      const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {361        isProUser: async () => true,362        isUnlocked: () => true,363        getEntries: () => [],364        restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),365      });366      expect(result.done).toBe(true);367      expect(result.error?.code).toBe("invalid_message");368      expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");369    });370371    it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {372      const result = await dispatchSyncFrame(373        encodeSyncMessage({374          type: "SYNC_HELLO",375          deviceId: "device",376          clockMs: 0,377          vaultVersion: 0,378        }),379        new Uint8Array(32),380        {381          isProUser: async () => true,382          isUnlocked: () => false,383          getEntries: () => [],384          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),385        }386      );387      expect(result.done).toBe(true);388      expect(result.error?.code).toBe("vault_locked");389      const decoded = decodeSyncMessage(result.outbound[0]!);390      expect(decoded.type).toBe("SYNC_ERROR");391      if (decoded.type === "SYNC_ERROR") {392        expect(decoded.code).toBe("vault_locked");393      }394    });395396    it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {397      const result = await dispatchSyncFrame(398        encodeSyncMessage({ type: "SYNC_DONE" }),399        new Uint8Array(32).fill(7),400        passDeps([])401      );402      expect(result.outbound).toEqual([]);403      expect(result.done).toBe(true);404    });405406    it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {407      const result = await dispatchSyncFrame(408        encodeSyncMessage({409          type: "SYNC_ERROR",410          code: "peer_failed",411          message: "Peer reported error.",412        }),413        new Uint8Array(32).fill(8),414        passDeps([])415      );416      expect(result.outbound).toEqual([]);417      expect(result.done).toBe(true);418      expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });419    });420  });421422  describe("runSyncSession", () => {423    interface TransportHarness {424      transport: SyncSessionTransport;425      sent: string[];426      emitFrame: (frame: string) => Promise<void>;427      emitError: (message: string) => Promise<void>;428      emitClose: () => Promise<void>;429      isClosed: () => boolean;430      ready: () => Promise<void>;431    }432433    function makeTransport(rawKey: Uint8Array): TransportHarness {434      const sent: string[] = [];435      const frameQueue: string[] = [];436      const errorQueue: string[] = [];437      let pendingClose = false;438      let frameHandler: ((frame: string) => void) | null = null;439      let errorHandler: ((message: string) => void) | null = null;440      let closeHandler: (() => void) | null = null;441      let resolveReady: (() => void) | null = null;442      const ready = new Promise<void>((resolve) => {443        resolveReady = resolve;444      });445      let closed = false;446447      const checkReady = (): void => {448        if (frameHandler && errorHandler && closeHandler && resolveReady) {449          resolveReady();450          resolveReady = null;451          for (const queued of frameQueue.splice(0)) frameHandler(queued);452          for (const queued of errorQueue.splice(0)) errorHandler(queued);453          if (pendingClose && closeHandler) {454            pendingClose = false;455            closeHandler();456          }457        }458      };459460      const transport: SyncSessionTransport = {461        send: (frame) => {462          sent.push(frame);463        },464        onFrame: (handler) => {465          frameHandler = handler;466          checkReady();467          return () => {468            if (frameHandler === handler) frameHandler = null;469          };470        },471        onError: (handler) => {472          errorHandler = handler;473          checkReady();474          return () => {475            if (errorHandler === handler) errorHandler = null;476          };477        },478        onClose: (handler) => {479          closeHandler = handler;480          checkReady();481          return () => {482            if (closeHandler === handler) closeHandler = null;483          };484        },485        close: () => {486          closed = true;487        },488        getSessionKey: async () => rawKey,489      };490491      const flush = async (): Promise<void> => {492        for (let i = 0; i < 50; i += 1) {493          await new Promise<void>((resolve) => setTimeout(resolve, 0));494        }495      };496497      return {498        transport,499        sent,500        emitFrame: async (frame) => {501          if (frameHandler) frameHandler(frame);502          else frameQueue.push(frame);503          await flush();504        },505        emitError: async (message) => {506          if (errorHandler) errorHandler(message);507          else errorQueue.push(message);508          await flush();509        },510        emitClose: async () => {511          if (closeHandler) closeHandler();512          else pendingClose = true;513          await flush();514        },515        isClosed: () => closed,516        ready: () => ready,517      };518    }519520    it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {521      const harness = makeTransport(new Uint8Array(32).fill(11));522523      const sessionPromise = runSyncSession({524        role: "initiator",525        deviceId: "device-a",526        vaultVersion: 1,527        transport: harness.transport,528        deps: {529          isProUser: async () => true,530          isUnlocked: () => true,531          getEntries: () => [],532          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),533        },534      });535536      await harness.ready();537      expect(harness.sent).toHaveLength(1);538      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");539540      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));541542      const result = await sessionPromise;543      expect(result.status).toBe("completed");544      expect(harness.isClosed()).toBe(true);545    });546547    it("does not send SYNC_HELLO when responder", async () => {548      const harness = makeTransport(new Uint8Array(32));549      const sessionPromise = runSyncSession({550        role: "responder",551        deviceId: "device-b",552        vaultVersion: 1,553        transport: harness.transport,554        deps: {555          isProUser: async () => true,556          isUnlocked: () => true,557          getEntries: () => [],558          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),559        },560      });561      await harness.ready();562      expect(harness.sent).toEqual([]);563564      await harness.emitClose();565      const result = await sessionPromise;566      expect(result.status).toBe("closed");567    });568569    it("aborts immediately with SYNC_ERROR when preflight fails", async () => {570      const harness = makeTransport(new Uint8Array(32));571      const result = await runSyncSession({572        role: "initiator",573        deviceId: "device",574        vaultVersion: 1,575        transport: harness.transport,576        deps: {577          isProUser: async () => false,578          isUnlocked: () => true,579          getEntries: () => [],580          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),581        },582      });583584      expect(result.status).toBe("errored");585      expect(result.error?.code).toBe("pro_required");586      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");587      expect(harness.isClosed()).toBe(true);588    });589590    it("propagates transport errors as a session result", async () => {591      const harness = makeTransport(new Uint8Array(32));592      const sessionPromise = runSyncSession({593        role: "responder",594        deviceId: "device",595        vaultVersion: 1,596        transport: harness.transport,597        deps: {598          isProUser: async () => true,599          isUnlocked: () => true,600          getEntries: () => [],601          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),602        },603      });604      await harness.ready();605      await harness.emitError("native host disconnected");606      const result = await sessionPromise;607      expect(result.status).toBe("errored");608      expect(result.error).toEqual({609        code: "transport_error",610        message: "native host disconnected",611      });612    });613614    it("createNativeSyncTransport routes only matching frames and errors", async () => {615      const sendSyncData = vi.fn();616      const disconnectFromPeer = vi.fn();617      const handlers: {618        frame: ((id: string, data: string) => void) | null;619        error: ((msg: string, id?: string) => void) | null;620        disconnect: ((id?: string) => void) | null;621      } = { frame: null, error: null, disconnect: null };622623      const bindings: NativeSyncTransportBindings = {624        sendSyncData,625        disconnectFromPeer,626        onSyncMessage: (h) => {627          handlers.frame = h;628          return () => {629            if (handlers.frame === h) handlers.frame = null;630          };631        },632        onError: (h) => {633          handlers.error = h;634          return () => {635            if (handlers.error === h) handlers.error = null;636          };637        },638        onDisconnected: (h) => {639          handlers.disconnect = h;640          return () => {641            if (handlers.disconnect === h) handlers.disconnect = null;642          };643        },644        getNativeSessionKey: async () => new Uint8Array(32).fill(7),645      };646647      const transport = await createNativeSyncTransport("target", bindings);648649      const frames: string[] = [];650      const errors: string[] = [];651      let closes = 0;652      transport.onFrame((frame) => frames.push(frame));653      transport.onError((message) => errors.push(message));654      transport.onClose(() => {655        closes += 1;656      });657658      handlers.frame?.("other", "ignored");659      handlers.frame?.("target", "kept");660      expect(frames).toEqual(["kept"]);661662      handlers.error?.("scoped error", "other");663      handlers.error?.("scoped error", "target");664      handlers.error?.("global error", undefined);665      expect(errors).toEqual(["scoped error", "global error"]);666667      handlers.disconnect?.("other");668      handlers.disconnect?.("target");669      handlers.disconnect?.(undefined);670      expect(closes).toBe(2);671672      transport.send("frame");673      expect(sendSyncData).toHaveBeenCalledWith("target", "frame");674675      transport.close();676      expect(disconnectFromPeer).toHaveBeenCalledWith("target");677678      const key = await transport.getSessionKey();679      expect(key).toEqual(new Uint8Array(32).fill(7));680    });681682    it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {683      const rawKey = new Uint8Array(32).fill(12);684      const harness = makeTransport(rawKey);685      const restoreEntries = vi686        .fn()687        .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });688689      const sessionPromise = runSyncSession({690        role: "responder",691        deviceId: "device",692        vaultVersion: 1,693        transport: harness.transport,694        deps: {695          isProUser: async () => true,696          isUnlocked: () => true,697          getEntries: () => [],698          restoreEntries,699        },700      });701      await harness.ready();702703      const incoming = await buildSyncEntriesMessage(["a"], rawKey, {704        isProUser: async () => true,705        isUnlocked: () => true,706        getEntries: () => [entry({ id: "a", title: "First" })],707      });708      await harness.emitFrame(encodeSyncMessage(incoming));709      // Peer signals end of its side; session can resolve.710      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));711712      const result = await sessionPromise;713      expect(result.status).toBe("completed");714      expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });715    });716  });717});