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