sync-coordinator.test.ts

4.9 KB · raw

1import { describe, expect, it, vi } from "vitest";23import {4  applySyncEntriesMessage,5  buildSyncEntriesMessage,6  buildLocalSyncSnapshot,7  cancelSyncIfVaultLocked,8  checkSyncPreflight,9  planManifestSync,10} from "./sync-coordinator";11import { decodeSyncMessage } from "../shared/sync-wire";12import type { VaultEntry } from "../shared/types";1314function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {15  return {16    id: "entry-1",17    type: "login",18    title: "GitHub",19    username: "june",20    password: "secret",21    url: "https://github.com",22    createdAt: 1_000,23    modifiedAt: 1_000,24    ...overrides,25  };26}2728describe("sync coordinator", () => {29  it("blocks sync for free users before inspecting the vault", async () => {30    const getEntries = vi.fn(() => [entry()]);3132    await expect(33      checkSyncPreflight({34        isProUser: async () => false,35        isUnlocked: () => true,36      })37    ).resolves.toEqual({ ok: false, error: "pro_required" });3839    expect(getEntries).not.toHaveBeenCalled();40  });4142  it("blocks sync when the vault is locked", async () => {43    await expect(44      checkSyncPreflight({45        isProUser: async () => true,46        isUnlocked: () => false,47      })48    ).resolves.toEqual({ ok: false, error: "vault_locked" });49  });5051  it("builds a local sync snapshot only after preflight passes", async () => {52    const snapshot = await buildLocalSyncSnapshot({53      isProUser: async () => true,54      isUnlocked: () => true,55      getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })],56    });5758    expect(snapshot.entries).toHaveLength(2);59    expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]);60  });6162  it("plans manifest sync without sending entry payloads", async () => {63    const plan = await planManifestSync(64      [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }],65      {66        isProUser: async () => true,67        isUnlocked: () => true,68        getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })],69      }70    );7172    expect(plan.diff).toEqual({73      requestFromRemote: ["remote-only"],74      offerToRemote: ["local-only"],75      conflicts: [],76    });77  });7879  it("builds encrypted entry messages for requested ids", async () => {80    const rawEntryKey = new Uint8Array(32).fill(5);81    const message = await buildSyncEntriesMessage(["b"], rawEntryKey, {82      isProUser: async () => true,83      isUnlocked: () => true,84      getEntries: () => [85        entry({ id: "a" }),86        entry({87          id: "b",88          passkey: {89            rpId: "github.com",90            rpName: "GitHub",91            credentialId: "credential",92            publicKeyCose: "public",93            privateKeyJwk: { kty: "EC", d: "secret" },94            userHandle: "user",95            userName: "june",96            userDisplayName: "June",97            algorithm: -7,98            signCount: 1,99            aaguid: "aaguid",100            backupEligible: true,101            backupState: true,102            createdAt: 1_000,103          },104        }),105      ],106    });107108    expect(message.type).toBe("SYNC_ENTRIES");109    expect(message.entries).toHaveLength(1);110    expect(message.entries[0]).toMatchObject({ id: "b" });111    expect(message.entries[0]?.ciphertext).not.toContain("secret");112  });113114  it("decrypts and applies incoming entry messages through vault merge", async () => {115    const rawEntryKey = new Uint8Array(32).fill(6);116    const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, {117      isProUser: async () => true,118      isUnlocked: () => true,119      getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })],120    });121    const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));122123    await expect(124      applySyncEntriesMessage(message, rawEntryKey, {125        isProUser: async () => true,126        isUnlocked: () => true,127        restoreEntries,128      })129    ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 });130131    expect(restoreEntries).toHaveBeenCalledWith(132      [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })],133      "merge"134    );135  });136137  it("sends a stable sync error when a peer asks while the vault is locked", async () => {138    const sendSyncData = vi.fn();139140    const cancelled = await cancelSyncIfVaultLocked("conn-1", {141      isUnlocked: () => false,142      sendSyncData,143    });144145    expect(cancelled).toBe(true);146    expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String));147    expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({148      type: "SYNC_ERROR",149      code: "vault_locked",150      message: "Vault is locked; unlock Peach before syncing.",151    });152  });153154  it("does not cancel an unlocked vault sync", async () => {155    const sendSyncData = vi.fn();156157    expect(158      await cancelSyncIfVaultLocked("conn-1", {159        isUnlocked: () => true,160        sendSyncData,161      })162    ).toBe(false);163    expect(sendSyncData).not.toHaveBeenCalled();164  });165});