import { describe, expect, it, vi } from "vitest"; import { applySyncEntriesMessage, buildSyncEntriesMessage, buildLocalSyncSnapshot, cancelSyncIfVaultLocked, checkSyncPreflight, dispatchSyncFrame, planManifestSync, } from "./sync-coordinator"; import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire"; import type { VaultEntry } from "../shared/types"; function entry(overrides: Partial = {}): VaultEntry { return { id: "entry-1", type: "login", title: "GitHub", username: "june", password: "secret", url: "https://github.com", createdAt: 1_000, modifiedAt: 1_000, ...overrides, }; } describe("sync coordinator", () => { it("blocks sync for free users before inspecting the vault", async () => { const getEntries = vi.fn(() => [entry()]); await expect( checkSyncPreflight({ isProUser: async () => false, isUnlocked: () => true, }) ).resolves.toEqual({ ok: false, error: "pro_required" }); expect(getEntries).not.toHaveBeenCalled(); }); it("blocks sync when the vault is locked", async () => { await expect( checkSyncPreflight({ isProUser: async () => true, isUnlocked: () => false, }) ).resolves.toEqual({ ok: false, error: "vault_locked" }); }); it("builds a local sync snapshot only after preflight passes", async () => { const snapshot = await buildLocalSyncSnapshot({ isProUser: async () => true, isUnlocked: () => true, getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })], }); expect(snapshot.entries).toHaveLength(2); expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]); }); it("plans manifest sync without sending entry payloads", async () => { const plan = await planManifestSync( [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }], { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })], } ); expect(plan.diff).toEqual({ requestFromRemote: ["remote-only"], offerToRemote: ["local-only"], conflicts: [], }); }); it("builds encrypted entry messages for requested ids", async () => { const rawEntryKey = new Uint8Array(32).fill(5); const message = await buildSyncEntriesMessage(["b"], rawEntryKey, { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [ entry({ id: "a" }), entry({ id: "b", passkey: { rpId: "github.com", rpName: "GitHub", credentialId: "credential", publicKeyCose: "public", privateKeyJwk: { kty: "EC", d: "secret" }, userHandle: "user", userName: "june", userDisplayName: "June", algorithm: -7, signCount: 1, aaguid: "aaguid", backupEligible: true, backupState: true, createdAt: 1_000, }, }), ], }); expect(message.type).toBe("SYNC_ENTRIES"); expect(message.entries).toHaveLength(1); expect(message.entries[0]).toMatchObject({ id: "b" }); expect(message.entries[0]?.ciphertext).not.toContain("secret"); }); it("decrypts and applies incoming entry messages through vault merge", async () => { const rawEntryKey = new Uint8Array(32).fill(6); const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })], }); const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 })); await expect( applySyncEntriesMessage(message, rawEntryKey, { isProUser: async () => true, isUnlocked: () => true, restoreEntries, }) ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 }); expect(restoreEntries).toHaveBeenCalledWith( [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })], "merge" ); }); it("sends a stable sync error when a peer asks while the vault is locked", async () => { const sendSyncData = vi.fn(); const cancelled = await cancelSyncIfVaultLocked("conn-1", { isUnlocked: () => false, sendSyncData, }); expect(cancelled).toBe(true); expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String)); expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({ type: "SYNC_ERROR", code: "vault_locked", message: "Vault is locked; unlock Peach before syncing.", }); }); it("does not cancel an unlocked vault sync", async () => { const sendSyncData = vi.fn(); expect( await cancelSyncIfVaultLocked("conn-1", { isUnlocked: () => true, sendSyncData, }) ).toBe(false); expect(sendSyncData).not.toHaveBeenCalled(); }); describe("dispatchSyncFrame", () => { const passDeps = (entries: VaultEntry[]) => ({ isProUser: async () => true, isUnlocked: () => true, getEntries: () => entries, restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })), }); it("answers SYNC_HELLO with a sorted SYNC_MANIFEST", async () => { const deps = passDeps([entry({ id: "b" }), entry({ id: "a" })]); const result = await dispatchSyncFrame( encodeSyncMessage({ type: "SYNC_HELLO", deviceId: "device", clockMs: 1, vaultVersion: 1, }), new Uint8Array(32).fill(1), deps ); expect(result.outbound).toHaveLength(1); const decoded = decodeSyncMessage(result.outbound[0]!); expect(decoded.type).toBe("SYNC_MANIFEST"); if (decoded.type === "SYNC_MANIFEST") { expect(decoded.entries.map((entry) => entry.id)).toEqual(["a", "b"]); } }); it("requests missing ids and offers local-only ids on SYNC_MANIFEST", async () => { const deps = passDeps([entry({ id: "local", modifiedAt: 2_000 })]); const result = await dispatchSyncFrame( encodeSyncMessage({ type: "SYNC_MANIFEST", entries: [{ id: "remote", modifiedAt: 1_500, hash: "remote" }], }), new Uint8Array(32).fill(2), deps ); const messages = result.outbound.map((frame) => decodeSyncMessage(frame)); expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({ type: "SYNC_REQUEST", ids: ["remote"], }); const entriesMessage = messages.find((m) => m.type === "SYNC_ENTRIES"); expect(entriesMessage?.type).toBe("SYNC_ENTRIES"); if (entriesMessage?.type === "SYNC_ENTRIES") { expect(entriesMessage.entries.map((e) => e.id)).toEqual(["local"]); } }); it("returns SYNC_DONE when manifests already agree", async () => { const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]); const localManifest = encodeSyncMessage({ type: "SYNC_MANIFEST", entries: [ { id: "shared", modifiedAt: 1_000, hash: ( await (await import("../shared/sync-protocol")).buildSyncManifest( deps.getEntries() ) )[0]!.hash, }, ], }); const result = await dispatchSyncFrame( localManifest, new Uint8Array(32).fill(3), deps ); const messages = result.outbound.map((frame) => decodeSyncMessage(frame)); expect(messages).toHaveLength(1); expect(messages[0]!.type).toBe("SYNC_DONE"); }); it("encrypts only the requested ids on SYNC_REQUEST", async () => { const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]); const result = await dispatchSyncFrame( encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }), new Uint8Array(32).fill(4), deps ); expect(result.outbound).toHaveLength(1); const decoded = decodeSyncMessage(result.outbound[0]!); expect(decoded.type).toBe("SYNC_ENTRIES"); if (decoded.type === "SYNC_ENTRIES") { expect(decoded.entries.map((e) => e.id)).toEqual(["b"]); } }); it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => { const rawEntryKey = new Uint8Array(32).fill(9); const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })], }); const deps = passDeps([]); const result = await dispatchSyncFrame( encodeSyncMessage(offered), rawEntryKey, deps ); expect(deps.restoreEntries).toHaveBeenCalledWith( [expect.objectContaining({ id: "remote", title: "Remote" })], "merge" ); const decoded = decodeSyncMessage(result.outbound[0]!); expect(decoded.type).toBe("SYNC_DONE"); }); it("returns SYNC_ERROR + done for malformed inbound frames", async () => { const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [], restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })), }); expect(result.done).toBe(true); expect(result.error?.code).toBe("invalid_message"); expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR"); }); it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => { const result = await dispatchSyncFrame( encodeSyncMessage({ type: "SYNC_HELLO", deviceId: "device", clockMs: 0, vaultVersion: 0, }), new Uint8Array(32), { isProUser: async () => true, isUnlocked: () => false, getEntries: () => [], restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })), } ); expect(result.done).toBe(true); expect(result.error?.code).toBe("vault_locked"); const decoded = decodeSyncMessage(result.outbound[0]!); expect(decoded.type).toBe("SYNC_ERROR"); if (decoded.type === "SYNC_ERROR") { expect(decoded.code).toBe("vault_locked"); } }); it("treats SYNC_DONE from peer as session end with no outbound frames", async () => { const result = await dispatchSyncFrame( encodeSyncMessage({ type: "SYNC_DONE" }), new Uint8Array(32).fill(7), passDeps([]) ); expect(result.outbound).toEqual([]); expect(result.done).toBe(true); }); it("treats SYNC_ERROR from peer as session end with no outbound frames", async () => { const result = await dispatchSyncFrame( encodeSyncMessage({ type: "SYNC_ERROR", code: "peer_failed", message: "Peer reported error.", }), new Uint8Array(32).fill(8), passDeps([]) ); expect(result.outbound).toEqual([]); expect(result.done).toBe(true); expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." }); }); }); });