sync-coordinator.test.ts

18.5 KB · raw

1import { describe, expect, it, vi } from "vitest";23import {4  applySyncEntriesMessage,5  buildSyncEntriesMessage,6  buildLocalSyncSnapshot,7  cancelSyncIfVaultLocked,8  checkSyncPreflight,9  dispatchSyncFrame,10  planManifestSync,11  runSyncSession,12  type SyncSessionTransport,13} from "./sync-coordinator";14import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";15import type { VaultEntry } from "../shared/types";1617function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {18  return {19    id: "entry-1",20    type: "login",21    title: "GitHub",22    username: "june",23    password: "secret",24    url: "https://github.com",25    createdAt: 1_000,26    modifiedAt: 1_000,27    ...overrides,28  };29}3031describe("sync coordinator", () => {32  it("blocks sync for free users before inspecting the vault", async () => {33    const getEntries = vi.fn(() => [entry()]);3435    await expect(36      checkSyncPreflight({37        isProUser: async () => false,38        isUnlocked: () => true,39      })40    ).resolves.toEqual({ ok: false, error: "pro_required" });4142    expect(getEntries).not.toHaveBeenCalled();43  });4445  it("blocks sync when the vault is locked", async () => {46    await expect(47      checkSyncPreflight({48        isProUser: async () => true,49        isUnlocked: () => false,50      })51    ).resolves.toEqual({ ok: false, error: "vault_locked" });52  });5354  it("builds a local sync snapshot only after preflight passes", async () => {55    const snapshot = await buildLocalSyncSnapshot({56      isProUser: async () => true,57      isUnlocked: () => true,58      getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })],59    });6061    expect(snapshot.entries).toHaveLength(2);62    expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]);63  });6465  it("plans manifest sync without sending entry payloads", async () => {66    const plan = await planManifestSync(67      [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }],68      {69        isProUser: async () => true,70        isUnlocked: () => true,71        getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })],72      }73    );7475    expect(plan.diff).toEqual({76      requestFromRemote: ["remote-only"],77      offerToRemote: ["local-only"],78      conflicts: [],79    });80  });8182  it("builds encrypted entry messages for requested ids", async () => {83    const rawEntryKey = new Uint8Array(32).fill(5);84    const message = await buildSyncEntriesMessage(["b"], rawEntryKey, {85      isProUser: async () => true,86      isUnlocked: () => true,87      getEntries: () => [88        entry({ id: "a" }),89        entry({90          id: "b",91          passkey: {92            rpId: "github.com",93            rpName: "GitHub",94            credentialId: "credential",95            publicKeyCose: "public",96            privateKeyJwk: { kty: "EC", d: "secret" },97            userHandle: "user",98            userName: "june",99            userDisplayName: "June",100            algorithm: -7,101            signCount: 1,102            aaguid: "aaguid",103            backupEligible: true,104            backupState: true,105            createdAt: 1_000,106          },107        }),108      ],109    });110111    expect(message.type).toBe("SYNC_ENTRIES");112    expect(message.entries).toHaveLength(1);113    expect(message.entries[0]).toMatchObject({ id: "b" });114    expect(message.entries[0]?.ciphertext).not.toContain("secret");115  });116117  it("decrypts and applies incoming entry messages through vault merge", async () => {118    const rawEntryKey = new Uint8Array(32).fill(6);119    const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, {120      isProUser: async () => true,121      isUnlocked: () => true,122      getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })],123    });124    const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));125126    await expect(127      applySyncEntriesMessage(message, rawEntryKey, {128        isProUser: async () => true,129        isUnlocked: () => true,130        restoreEntries,131      })132    ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 });133134    expect(restoreEntries).toHaveBeenCalledWith(135      [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })],136      "merge"137    );138  });139140  it("sends a stable sync error when a peer asks while the vault is locked", async () => {141    const sendSyncData = vi.fn();142143    const cancelled = await cancelSyncIfVaultLocked("conn-1", {144      isUnlocked: () => false,145      sendSyncData,146    });147148    expect(cancelled).toBe(true);149    expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String));150    expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({151      type: "SYNC_ERROR",152      code: "vault_locked",153      message: "Vault is locked; unlock Peach before syncing.",154    });155  });156157  it("does not cancel an unlocked vault sync", async () => {158    const sendSyncData = vi.fn();159160    expect(161      await cancelSyncIfVaultLocked("conn-1", {162        isUnlocked: () => true,163        sendSyncData,164      })165    ).toBe(false);166    expect(sendSyncData).not.toHaveBeenCalled();167  });168169  describe("dispatchSyncFrame", () => {170    const passDeps = (entries: VaultEntry[]) => ({171      isProUser: async () => true,172      isUnlocked: () => true,173      getEntries: () => entries,174      restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),175    });176177    it("answers SYNC_HELLO with a sorted SYNC_MANIFEST", async () => {178      const deps = passDeps([entry({ id: "b" }), entry({ id: "a" })]);179      const result = await dispatchSyncFrame(180        encodeSyncMessage({181          type: "SYNC_HELLO",182          deviceId: "device",183          clockMs: 1,184          vaultVersion: 1,185        }),186        new Uint8Array(32).fill(1),187        deps188      );189      expect(result.outbound).toHaveLength(1);190      const decoded = decodeSyncMessage(result.outbound[0]!);191      expect(decoded.type).toBe("SYNC_MANIFEST");192      if (decoded.type === "SYNC_MANIFEST") {193        expect(decoded.entries.map((entry) => entry.id)).toEqual(["a", "b"]);194      }195    });196197    it("requests missing ids and offers local-only ids on SYNC_MANIFEST", async () => {198      const deps = passDeps([entry({ id: "local", modifiedAt: 2_000 })]);199      const result = await dispatchSyncFrame(200        encodeSyncMessage({201          type: "SYNC_MANIFEST",202          entries: [{ id: "remote", modifiedAt: 1_500, hash: "remote" }],203        }),204        new Uint8Array(32).fill(2),205        deps206      );207      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));208      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({209        type: "SYNC_REQUEST",210        ids: ["remote"],211      });212      const entriesMessage = messages.find((m) => m.type === "SYNC_ENTRIES");213      expect(entriesMessage?.type).toBe("SYNC_ENTRIES");214      if (entriesMessage?.type === "SYNC_ENTRIES") {215        expect(entriesMessage.entries.map((e) => e.id)).toEqual(["local"]);216      }217    });218219    it("returns SYNC_DONE when manifests already agree", async () => {220      const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);221      const localManifest = encodeSyncMessage({222        type: "SYNC_MANIFEST",223        entries: [224          {225            id: "shared",226            modifiedAt: 1_000,227            hash: (228              await (await import("../shared/sync-protocol")).buildSyncManifest(229                deps.getEntries()230              )231            )[0]!.hash,232          },233        ],234      });235      const result = await dispatchSyncFrame(236        localManifest,237        new Uint8Array(32).fill(3),238        deps239      );240      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));241      expect(messages).toHaveLength(1);242      expect(messages[0]!.type).toBe("SYNC_DONE");243    });244245    it("encrypts only the requested ids on SYNC_REQUEST", async () => {246      const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);247      const result = await dispatchSyncFrame(248        encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),249        new Uint8Array(32).fill(4),250        deps251      );252      expect(result.outbound).toHaveLength(1);253      const decoded = decodeSyncMessage(result.outbound[0]!);254      expect(decoded.type).toBe("SYNC_ENTRIES");255      if (decoded.type === "SYNC_ENTRIES") {256        expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);257      }258    });259260    it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {261      const rawEntryKey = new Uint8Array(32).fill(9);262      const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {263        isProUser: async () => true,264        isUnlocked: () => true,265        getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],266      });267      const deps = passDeps([]);268      const result = await dispatchSyncFrame(269        encodeSyncMessage(offered),270        rawEntryKey,271        deps272      );273      expect(deps.restoreEntries).toHaveBeenCalledWith(274        [expect.objectContaining({ id: "remote", title: "Remote" })],275        "merge"276      );277      const decoded = decodeSyncMessage(result.outbound[0]!);278      expect(decoded.type).toBe("SYNC_DONE");279    });280281    it("returns SYNC_ERROR + done for malformed inbound frames", async () => {282      const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {283        isProUser: async () => true,284        isUnlocked: () => true,285        getEntries: () => [],286        restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),287      });288      expect(result.done).toBe(true);289      expect(result.error?.code).toBe("invalid_message");290      expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");291    });292293    it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {294      const result = await dispatchSyncFrame(295        encodeSyncMessage({296          type: "SYNC_HELLO",297          deviceId: "device",298          clockMs: 0,299          vaultVersion: 0,300        }),301        new Uint8Array(32),302        {303          isProUser: async () => true,304          isUnlocked: () => false,305          getEntries: () => [],306          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),307        }308      );309      expect(result.done).toBe(true);310      expect(result.error?.code).toBe("vault_locked");311      const decoded = decodeSyncMessage(result.outbound[0]!);312      expect(decoded.type).toBe("SYNC_ERROR");313      if (decoded.type === "SYNC_ERROR") {314        expect(decoded.code).toBe("vault_locked");315      }316    });317318    it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {319      const result = await dispatchSyncFrame(320        encodeSyncMessage({ type: "SYNC_DONE" }),321        new Uint8Array(32).fill(7),322        passDeps([])323      );324      expect(result.outbound).toEqual([]);325      expect(result.done).toBe(true);326    });327328    it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {329      const result = await dispatchSyncFrame(330        encodeSyncMessage({331          type: "SYNC_ERROR",332          code: "peer_failed",333          message: "Peer reported error.",334        }),335        new Uint8Array(32).fill(8),336        passDeps([])337      );338      expect(result.outbound).toEqual([]);339      expect(result.done).toBe(true);340      expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });341    });342  });343344  describe("runSyncSession", () => {345    interface TransportHarness {346      transport: SyncSessionTransport;347      sent: string[];348      emitFrame: (frame: string) => Promise<void>;349      emitError: (message: string) => Promise<void>;350      emitClose: () => Promise<void>;351      isClosed: () => boolean;352      ready: () => Promise<void>;353    }354355    function makeTransport(rawKey: Uint8Array): TransportHarness {356      const sent: string[] = [];357      const frameQueue: string[] = [];358      const errorQueue: string[] = [];359      let pendingClose = false;360      let frameHandler: ((frame: string) => void) | null = null;361      let errorHandler: ((message: string) => void) | null = null;362      let closeHandler: (() => void) | null = null;363      let resolveReady: (() => void) | null = null;364      const ready = new Promise<void>((resolve) => {365        resolveReady = resolve;366      });367      let closed = false;368369      const checkReady = (): void => {370        if (frameHandler && errorHandler && closeHandler && resolveReady) {371          resolveReady();372          resolveReady = null;373          for (const queued of frameQueue.splice(0)) frameHandler(queued);374          for (const queued of errorQueue.splice(0)) errorHandler(queued);375          if (pendingClose && closeHandler) {376            pendingClose = false;377            closeHandler();378          }379        }380      };381382      const transport: SyncSessionTransport = {383        send: (frame) => {384          sent.push(frame);385        },386        onFrame: (handler) => {387          frameHandler = handler;388          checkReady();389          return () => {390            if (frameHandler === handler) frameHandler = null;391          };392        },393        onError: (handler) => {394          errorHandler = handler;395          checkReady();396          return () => {397            if (errorHandler === handler) errorHandler = null;398          };399        },400        onClose: (handler) => {401          closeHandler = handler;402          checkReady();403          return () => {404            if (closeHandler === handler) closeHandler = null;405          };406        },407        close: () => {408          closed = true;409        },410        getSessionKey: async () => rawKey,411      };412413      const flush = async (): Promise<void> => {414        for (let i = 0; i < 50; i += 1) {415          await new Promise<void>((resolve) => setTimeout(resolve, 0));416        }417      };418419      return {420        transport,421        sent,422        emitFrame: async (frame) => {423          if (frameHandler) frameHandler(frame);424          else frameQueue.push(frame);425          await flush();426        },427        emitError: async (message) => {428          if (errorHandler) errorHandler(message);429          else errorQueue.push(message);430          await flush();431        },432        emitClose: async () => {433          if (closeHandler) closeHandler();434          else pendingClose = true;435          await flush();436        },437        isClosed: () => closed,438        ready: () => ready,439      };440    }441442    it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {443      const harness = makeTransport(new Uint8Array(32).fill(11));444445      const sessionPromise = runSyncSession({446        role: "initiator",447        deviceId: "device-a",448        vaultVersion: 1,449        transport: harness.transport,450        deps: {451          isProUser: async () => true,452          isUnlocked: () => true,453          getEntries: () => [],454          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),455        },456      });457458      await harness.ready();459      expect(harness.sent).toHaveLength(1);460      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");461462      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));463464      const result = await sessionPromise;465      expect(result.status).toBe("completed");466      expect(harness.isClosed()).toBe(true);467    });468469    it("does not send SYNC_HELLO when responder", async () => {470      const harness = makeTransport(new Uint8Array(32));471      const sessionPromise = runSyncSession({472        role: "responder",473        deviceId: "device-b",474        vaultVersion: 1,475        transport: harness.transport,476        deps: {477          isProUser: async () => true,478          isUnlocked: () => true,479          getEntries: () => [],480          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),481        },482      });483      await harness.ready();484      expect(harness.sent).toEqual([]);485486      await harness.emitClose();487      const result = await sessionPromise;488      expect(result.status).toBe("closed");489    });490491    it("aborts immediately with SYNC_ERROR when preflight fails", async () => {492      const harness = makeTransport(new Uint8Array(32));493      const result = await runSyncSession({494        role: "initiator",495        deviceId: "device",496        vaultVersion: 1,497        transport: harness.transport,498        deps: {499          isProUser: async () => false,500          isUnlocked: () => true,501          getEntries: () => [],502          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),503        },504      });505506      expect(result.status).toBe("errored");507      expect(result.error?.code).toBe("pro_required");508      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");509      expect(harness.isClosed()).toBe(true);510    });511512    it("propagates transport errors as a session result", async () => {513      const harness = makeTransport(new Uint8Array(32));514      const sessionPromise = runSyncSession({515        role: "responder",516        deviceId: "device",517        vaultVersion: 1,518        transport: harness.transport,519        deps: {520          isProUser: async () => true,521          isUnlocked: () => true,522          getEntries: () => [],523          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),524        },525      });526      await harness.ready();527      await harness.emitError("native host disconnected");528      const result = await sessionPromise;529      expect(result.status).toBe("errored");530      expect(result.error).toEqual({531        code: "transport_error",532        message: "native host disconnected",533      });534    });535536    it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {537      const rawKey = new Uint8Array(32).fill(12);538      const harness = makeTransport(rawKey);539      const restoreEntries = vi540        .fn()541        .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });542543      const sessionPromise = runSyncSession({544        role: "responder",545        deviceId: "device",546        vaultVersion: 1,547        transport: harness.transport,548        deps: {549          isProUser: async () => true,550          isUnlocked: () => true,551          getEntries: () => [],552          restoreEntries,553        },554      });555      await harness.ready();556557      const incoming = await buildSyncEntriesMessage(["a"], rawKey, {558        isProUser: async () => true,559        isUnlocked: () => true,560        getEntries: () => [entry({ id: "a", title: "First" })],561      });562      await harness.emitFrame(encodeSyncMessage(incoming));563      // Peer signals end of its side; session can resolve.564      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));565566      const result = await sessionPromise;567      expect(result.status).toBe("completed");568      expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });569    });570  });571});