sync-coordinator.test.ts

11.2 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} from "./sync-coordinator";12import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";13import type { VaultEntry } from "../shared/types";1415function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {16  return {17    id: "entry-1",18    type: "login",19    title: "GitHub",20    username: "june",21    password: "secret",22    url: "https://github.com",23    createdAt: 1_000,24    modifiedAt: 1_000,25    ...overrides,26  };27}2829describe("sync coordinator", () => {30  it("blocks sync for free users before inspecting the vault", async () => {31    const getEntries = vi.fn(() => [entry()]);3233    await expect(34      checkSyncPreflight({35        isProUser: async () => false,36        isUnlocked: () => true,37      })38    ).resolves.toEqual({ ok: false, error: "pro_required" });3940    expect(getEntries).not.toHaveBeenCalled();41  });4243  it("blocks sync when the vault is locked", async () => {44    await expect(45      checkSyncPreflight({46        isProUser: async () => true,47        isUnlocked: () => false,48      })49    ).resolves.toEqual({ ok: false, error: "vault_locked" });50  });5152  it("builds a local sync snapshot only after preflight passes", async () => {53    const snapshot = await buildLocalSyncSnapshot({54      isProUser: async () => true,55      isUnlocked: () => true,56      getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })],57    });5859    expect(snapshot.entries).toHaveLength(2);60    expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]);61  });6263  it("plans manifest sync without sending entry payloads", async () => {64    const plan = await planManifestSync(65      [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }],66      {67        isProUser: async () => true,68        isUnlocked: () => true,69        getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })],70      }71    );7273    expect(plan.diff).toEqual({74      requestFromRemote: ["remote-only"],75      offerToRemote: ["local-only"],76      conflicts: [],77    });78  });7980  it("builds encrypted entry messages for requested ids", async () => {81    const rawEntryKey = new Uint8Array(32).fill(5);82    const message = await buildSyncEntriesMessage(["b"], rawEntryKey, {83      isProUser: async () => true,84      isUnlocked: () => true,85      getEntries: () => [86        entry({ id: "a" }),87        entry({88          id: "b",89          passkey: {90            rpId: "github.com",91            rpName: "GitHub",92            credentialId: "credential",93            publicKeyCose: "public",94            privateKeyJwk: { kty: "EC", d: "secret" },95            userHandle: "user",96            userName: "june",97            userDisplayName: "June",98            algorithm: -7,99            signCount: 1,100            aaguid: "aaguid",101            backupEligible: true,102            backupState: true,103            createdAt: 1_000,104          },105        }),106      ],107    });108109    expect(message.type).toBe("SYNC_ENTRIES");110    expect(message.entries).toHaveLength(1);111    expect(message.entries[0]).toMatchObject({ id: "b" });112    expect(message.entries[0]?.ciphertext).not.toContain("secret");113  });114115  it("decrypts and applies incoming entry messages through vault merge", async () => {116    const rawEntryKey = new Uint8Array(32).fill(6);117    const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, {118      isProUser: async () => true,119      isUnlocked: () => true,120      getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })],121    });122    const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));123124    await expect(125      applySyncEntriesMessage(message, rawEntryKey, {126        isProUser: async () => true,127        isUnlocked: () => true,128        restoreEntries,129      })130    ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 });131132    expect(restoreEntries).toHaveBeenCalledWith(133      [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })],134      "merge"135    );136  });137138  it("sends a stable sync error when a peer asks while the vault is locked", async () => {139    const sendSyncData = vi.fn();140141    const cancelled = await cancelSyncIfVaultLocked("conn-1", {142      isUnlocked: () => false,143      sendSyncData,144    });145146    expect(cancelled).toBe(true);147    expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String));148    expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({149      type: "SYNC_ERROR",150      code: "vault_locked",151      message: "Vault is locked; unlock Peach before syncing.",152    });153  });154155  it("does not cancel an unlocked vault sync", async () => {156    const sendSyncData = vi.fn();157158    expect(159      await cancelSyncIfVaultLocked("conn-1", {160        isUnlocked: () => true,161        sendSyncData,162      })163    ).toBe(false);164    expect(sendSyncData).not.toHaveBeenCalled();165  });166167  describe("dispatchSyncFrame", () => {168    const passDeps = (entries: VaultEntry[]) => ({169      isProUser: async () => true,170      isUnlocked: () => true,171      getEntries: () => entries,172      restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),173    });174175    it("answers SYNC_HELLO with a sorted SYNC_MANIFEST", async () => {176      const deps = passDeps([entry({ id: "b" }), entry({ id: "a" })]);177      const result = await dispatchSyncFrame(178        encodeSyncMessage({179          type: "SYNC_HELLO",180          deviceId: "device",181          clockMs: 1,182          vaultVersion: 1,183        }),184        new Uint8Array(32).fill(1),185        deps186      );187      expect(result.outbound).toHaveLength(1);188      const decoded = decodeSyncMessage(result.outbound[0]!);189      expect(decoded.type).toBe("SYNC_MANIFEST");190      if (decoded.type === "SYNC_MANIFEST") {191        expect(decoded.entries.map((entry) => entry.id)).toEqual(["a", "b"]);192      }193    });194195    it("requests missing ids and offers local-only ids on SYNC_MANIFEST", async () => {196      const deps = passDeps([entry({ id: "local", modifiedAt: 2_000 })]);197      const result = await dispatchSyncFrame(198        encodeSyncMessage({199          type: "SYNC_MANIFEST",200          entries: [{ id: "remote", modifiedAt: 1_500, hash: "remote" }],201        }),202        new Uint8Array(32).fill(2),203        deps204      );205      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));206      expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({207        type: "SYNC_REQUEST",208        ids: ["remote"],209      });210      const entriesMessage = messages.find((m) => m.type === "SYNC_ENTRIES");211      expect(entriesMessage?.type).toBe("SYNC_ENTRIES");212      if (entriesMessage?.type === "SYNC_ENTRIES") {213        expect(entriesMessage.entries.map((e) => e.id)).toEqual(["local"]);214      }215    });216217    it("returns SYNC_DONE when manifests already agree", async () => {218      const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);219      const localManifest = encodeSyncMessage({220        type: "SYNC_MANIFEST",221        entries: [222          {223            id: "shared",224            modifiedAt: 1_000,225            hash: (226              await (await import("../shared/sync-protocol")).buildSyncManifest(227                deps.getEntries()228              )229            )[0]!.hash,230          },231        ],232      });233      const result = await dispatchSyncFrame(234        localManifest,235        new Uint8Array(32).fill(3),236        deps237      );238      const messages = result.outbound.map((frame) => decodeSyncMessage(frame));239      expect(messages).toHaveLength(1);240      expect(messages[0]!.type).toBe("SYNC_DONE");241    });242243    it("encrypts only the requested ids on SYNC_REQUEST", async () => {244      const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);245      const result = await dispatchSyncFrame(246        encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),247        new Uint8Array(32).fill(4),248        deps249      );250      expect(result.outbound).toHaveLength(1);251      const decoded = decodeSyncMessage(result.outbound[0]!);252      expect(decoded.type).toBe("SYNC_ENTRIES");253      if (decoded.type === "SYNC_ENTRIES") {254        expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);255      }256    });257258    it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {259      const rawEntryKey = new Uint8Array(32).fill(9);260      const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {261        isProUser: async () => true,262        isUnlocked: () => true,263        getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],264      });265      const deps = passDeps([]);266      const result = await dispatchSyncFrame(267        encodeSyncMessage(offered),268        rawEntryKey,269        deps270      );271      expect(deps.restoreEntries).toHaveBeenCalledWith(272        [expect.objectContaining({ id: "remote", title: "Remote" })],273        "merge"274      );275      const decoded = decodeSyncMessage(result.outbound[0]!);276      expect(decoded.type).toBe("SYNC_DONE");277    });278279    it("returns SYNC_ERROR + done for malformed inbound frames", async () => {280      const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {281        isProUser: async () => true,282        isUnlocked: () => true,283        getEntries: () => [],284        restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),285      });286      expect(result.done).toBe(true);287      expect(result.error?.code).toBe("invalid_message");288      expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");289    });290291    it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {292      const result = await dispatchSyncFrame(293        encodeSyncMessage({294          type: "SYNC_HELLO",295          deviceId: "device",296          clockMs: 0,297          vaultVersion: 0,298        }),299        new Uint8Array(32),300        {301          isProUser: async () => true,302          isUnlocked: () => false,303          getEntries: () => [],304          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),305        }306      );307      expect(result.done).toBe(true);308      expect(result.error?.code).toBe("vault_locked");309      const decoded = decodeSyncMessage(result.outbound[0]!);310      expect(decoded.type).toBe("SYNC_ERROR");311      if (decoded.type === "SYNC_ERROR") {312        expect(decoded.code).toBe("vault_locked");313      }314    });315316    it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {317      const result = await dispatchSyncFrame(318        encodeSyncMessage({ type: "SYNC_DONE" }),319        new Uint8Array(32).fill(7),320        passDeps([])321      );322      expect(result.outbound).toEqual([]);323      expect(result.done).toBe(true);324    });325326    it("treats SYNC_ERROR from peer as session end with no outbound frames", async () => {327      const result = await dispatchSyncFrame(328        encodeSyncMessage({329          type: "SYNC_ERROR",330          code: "peer_failed",331          message: "Peer reported error.",332        }),333        new Uint8Array(32).fill(8),334        passDeps([])335      );336      expect(result.outbound).toEqual([]);337      expect(result.done).toBe(true);338      expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });339    });340  });341});