import { describe, expect, it, vi } from "vitest"; import { applySyncEntriesMessage, buildSyncEntriesMessage, buildLocalSyncSnapshot, cancelSyncIfVaultLocked, checkSyncPreflight, dispatchSyncFrame, planManifestSync, runSyncSession, type SyncSessionTransport, } 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." }); }); }); describe("runSyncSession", () => { interface TransportHarness { transport: SyncSessionTransport; sent: string[]; emitFrame: (frame: string) => Promise; emitError: (message: string) => Promise; emitClose: () => Promise; isClosed: () => boolean; ready: () => Promise; } function makeTransport(rawKey: Uint8Array): TransportHarness { const sent: string[] = []; const frameQueue: string[] = []; const errorQueue: string[] = []; let pendingClose = false; let frameHandler: ((frame: string) => void) | null = null; let errorHandler: ((message: string) => void) | null = null; let closeHandler: (() => void) | null = null; let resolveReady: (() => void) | null = null; const ready = new Promise((resolve) => { resolveReady = resolve; }); let closed = false; const checkReady = (): void => { if (frameHandler && errorHandler && closeHandler && resolveReady) { resolveReady(); resolveReady = null; for (const queued of frameQueue.splice(0)) frameHandler(queued); for (const queued of errorQueue.splice(0)) errorHandler(queued); if (pendingClose && closeHandler) { pendingClose = false; closeHandler(); } } }; const transport: SyncSessionTransport = { send: (frame) => { sent.push(frame); }, onFrame: (handler) => { frameHandler = handler; checkReady(); return () => { if (frameHandler === handler) frameHandler = null; }; }, onError: (handler) => { errorHandler = handler; checkReady(); return () => { if (errorHandler === handler) errorHandler = null; }; }, onClose: (handler) => { closeHandler = handler; checkReady(); return () => { if (closeHandler === handler) closeHandler = null; }; }, close: () => { closed = true; }, getSessionKey: async () => rawKey, }; const flush = async (): Promise => { for (let i = 0; i < 50; i += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } }; return { transport, sent, emitFrame: async (frame) => { if (frameHandler) frameHandler(frame); else frameQueue.push(frame); await flush(); }, emitError: async (message) => { if (errorHandler) errorHandler(message); else errorQueue.push(message); await flush(); }, emitClose: async () => { if (closeHandler) closeHandler(); else pendingClose = true; await flush(); }, isClosed: () => closed, ready: () => ready, }; } it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => { const harness = makeTransport(new Uint8Array(32).fill(11)); const sessionPromise = runSyncSession({ role: "initiator", deviceId: "device-a", vaultVersion: 1, transport: harness.transport, deps: { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [], restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })), }, }); await harness.ready(); expect(harness.sent).toHaveLength(1); expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO"); await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" })); const result = await sessionPromise; expect(result.status).toBe("completed"); expect(harness.isClosed()).toBe(true); }); it("does not send SYNC_HELLO when responder", async () => { const harness = makeTransport(new Uint8Array(32)); const sessionPromise = runSyncSession({ role: "responder", deviceId: "device-b", vaultVersion: 1, transport: harness.transport, deps: { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [], restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })), }, }); await harness.ready(); expect(harness.sent).toEqual([]); await harness.emitClose(); const result = await sessionPromise; expect(result.status).toBe("closed"); }); it("aborts immediately with SYNC_ERROR when preflight fails", async () => { const harness = makeTransport(new Uint8Array(32)); const result = await runSyncSession({ role: "initiator", deviceId: "device", vaultVersion: 1, transport: harness.transport, deps: { isProUser: async () => false, isUnlocked: () => true, getEntries: () => [], restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })), }, }); expect(result.status).toBe("errored"); expect(result.error?.code).toBe("pro_required"); expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR"); expect(harness.isClosed()).toBe(true); }); it("propagates transport errors as a session result", async () => { const harness = makeTransport(new Uint8Array(32)); const sessionPromise = runSyncSession({ role: "responder", deviceId: "device", vaultVersion: 1, transport: harness.transport, deps: { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [], restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })), }, }); await harness.ready(); await harness.emitError("native host disconnected"); const result = await sessionPromise; expect(result.status).toBe("errored"); expect(result.error).toEqual({ code: "transport_error", message: "native host disconnected", }); }); it("records merge totals from a SYNC_ENTRIES frame on the result", async () => { const rawKey = new Uint8Array(32).fill(12); const harness = makeTransport(rawKey); const restoreEntries = vi .fn() .mockResolvedValue({ added: 1, updated: 2, skipped: 3 }); const sessionPromise = runSyncSession({ role: "responder", deviceId: "device", vaultVersion: 1, transport: harness.transport, deps: { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [], restoreEntries, }, }); await harness.ready(); const incoming = await buildSyncEntriesMessage(["a"], rawKey, { isProUser: async () => true, isUnlocked: () => true, getEntries: () => [entry({ id: "a", title: "First" })], }); await harness.emitFrame(encodeSyncMessage(incoming)); // Peer signals end of its side; session can resolve. await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" })); const result = await sessionPromise; expect(result.status).toBe("completed"); expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 }); }); }); });