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});