1import { describe, expect, it, vi } from "vitest";23import {4 applySyncEntriesMessage,5 buildSyncEntriesMessage,6 buildLocalSyncSnapshot,7 cancelSyncIfVaultLocked,8 checkSyncPreflight,9 createNativeSyncTransport,10 dispatchSyncFrame,11 planManifestSync,12 runSyncSession,13 type NativeSyncTransportBindings,14 type SyncSessionTransport,15} from "./sync-coordinator";16import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";17import type { VaultEntry } from "../shared/types";1819function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {20 return {21 id: "entry-1",22 type: "login",23 title: "GitHub",24 username: "june",25 password: "secret",26 url: "https://github.com",27 createdAt: 1_000,28 modifiedAt: 1_000,29 ...overrides,30 };31}3233describe("sync coordinator", () => {34 it("blocks sync for free users before inspecting the vault", async () => {35 const getEntries = vi.fn(() => [entry()]);3637 await expect(38 checkSyncPreflight({39 isProUser: async () => false,40 isUnlocked: () => true,41 })42 ).resolves.toEqual({ ok: false, error: "pro_required" });4344 expect(getEntries).not.toHaveBeenCalled();45 });4647 it("blocks sync when the vault is locked", async () => {48 await expect(49 checkSyncPreflight({50 isProUser: async () => true,51 isUnlocked: () => false,52 })53 ).resolves.toEqual({ ok: false, error: "vault_locked" });54 });5556 it("builds a local sync snapshot only after preflight passes", async () => {57 const snapshot = await buildLocalSyncSnapshot({58 isProUser: async () => true,59 isUnlocked: () => true,60 getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })],61 });6263 expect(snapshot.entries).toHaveLength(2);64 expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]);65 });6667 it("plans manifest sync without sending entry payloads", async () => {68 const plan = await planManifestSync(69 [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }],70 {71 isProUser: async () => true,72 isUnlocked: () => true,73 getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })],74 }75 );7677 expect(plan.diff).toEqual({78 requestFromRemote: ["remote-only"],79 offerToRemote: ["local-only"],80 conflicts: [],81 });82 });8384 it("builds encrypted entry messages for requested ids", async () => {85 const rawEntryKey = new Uint8Array(32).fill(5);86 const message = await buildSyncEntriesMessage(["b"], rawEntryKey, {87 isProUser: async () => true,88 isUnlocked: () => true,89 getEntries: () => [90 entry({ id: "a" }),91 entry({92 id: "b",93 passkey: {94 rpId: "github.com",95 rpName: "GitHub",96 credentialId: "credential",97 publicKeyCose: "public",98 privateKeyJwk: { kty: "EC", d: "secret" },99 userHandle: "user",100 userName: "june",101 userDisplayName: "June",102 algorithm: -7,103 signCount: 1,104 aaguid: "aaguid",105 backupEligible: true,106 backupState: true,107 createdAt: 1_000,108 },109 }),110 ],111 });112113 expect(message.type).toBe("SYNC_ENTRIES");114 expect(message.entries).toHaveLength(1);115 expect(message.entries[0]).toMatchObject({ id: "b" });116 expect(message.entries[0]?.ciphertext).not.toContain("secret");117 });118119 it("decrypts and applies incoming entry messages through vault merge", async () => {120 const rawEntryKey = new Uint8Array(32).fill(6);121 const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, {122 isProUser: async () => true,123 isUnlocked: () => true,124 getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })],125 });126 const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));127128 await expect(129 applySyncEntriesMessage(message, rawEntryKey, {130 isProUser: async () => true,131 isUnlocked: () => true,132 restoreEntries,133 })134 ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 });135136 expect(restoreEntries).toHaveBeenCalledWith(137 [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })],138 "merge"139 );140 });141142 it("sends a stable sync error when a peer asks while the vault is locked", async () => {143 const sendSyncData = vi.fn();144145 const cancelled = await cancelSyncIfVaultLocked("conn-1", {146 isUnlocked: () => false,147 sendSyncData,148 });149150 expect(cancelled).toBe(true);151 expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String));152 expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({153 type: "SYNC_ERROR",154 code: "vault_locked",155 message: "Vault is locked; unlock Peach before syncing.",156 });157 });158159 it("does not cancel an unlocked vault sync", async () => {160 const sendSyncData = vi.fn();161162 expect(163 await cancelSyncIfVaultLocked("conn-1", {164 isUnlocked: () => true,165 sendSyncData,166 })167 ).toBe(false);168 expect(sendSyncData).not.toHaveBeenCalled();169 });170171 describe("dispatchSyncFrame", () => {172 const passDeps = (entries: VaultEntry[]) => ({173 isProUser: async () => true,174 isUnlocked: () => true,175 getEntries: () => entries,176 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),177 });178179 it("answers SYNC_HELLO with a sorted SYNC_MANIFEST", async () => {180 const deps = passDeps([entry({ id: "b" }), entry({ id: "a" })]);181 const result = await dispatchSyncFrame(182 encodeSyncMessage({183 type: "SYNC_HELLO",184 deviceId: "device",185 clockMs: 1,186 vaultVersion: 1,187 }),188 new Uint8Array(32).fill(1),189 deps190 );191 expect(result.outbound).toHaveLength(1);192 const decoded = decodeSyncMessage(result.outbound[0]!);193 expect(decoded.type).toBe("SYNC_MANIFEST");194 if (decoded.type === "SYNC_MANIFEST") {195 expect(decoded.entries.map((entry) => entry.id)).toEqual(["a", "b"]);196 }197 });198199 it("requests missing ids and offers local-only ids on SYNC_MANIFEST", async () => {200 const deps = passDeps([entry({ id: "local", modifiedAt: 2_000 })]);201 const result = await dispatchSyncFrame(202 encodeSyncMessage({203 type: "SYNC_MANIFEST",204 entries: [{ id: "remote", modifiedAt: 1_500, hash: "remote" }],205 }),206 new Uint8Array(32).fill(2),207 deps208 );209 const messages = result.outbound.map((frame) => decodeSyncMessage(frame));210 expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({211 type: "SYNC_REQUEST",212 ids: ["remote"],213 });214 const entriesMessage = messages.find((m) => m.type === "SYNC_ENTRIES");215 expect(entriesMessage?.type).toBe("SYNC_ENTRIES");216 if (entriesMessage?.type === "SYNC_ENTRIES") {217 expect(entriesMessage.entries.map((e) => e.id)).toEqual(["local"]);218 }219 });220221 it("resolves equal-timestamp conflicts deterministically: local wins → offer", async () => {222 const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];223 const restoreEntries = vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 }));224 const localManifest = (225 await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)226 )[0]!;227 228 const remoteHash =229 localManifest.hash < "f".repeat(64) ? "f".repeat(64) : "0".repeat(64);230231 const result = await dispatchSyncFrame(232 encodeSyncMessage({233 type: "SYNC_MANIFEST",234 entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],235 }),236 new Uint8Array(32).fill(20),237 {238 isProUser: async () => true,239 isUnlocked: () => true,240 getEntries: () => localEntries,241 restoreEntries,242 }243 );244245 const messages = result.outbound.map((frame) => decodeSyncMessage(frame));246 247 expect(restoreEntries).not.toHaveBeenCalled();248 const offer = messages.find((m) => m.type === "SYNC_ENTRIES");249 expect(offer?.type).toBe("SYNC_ENTRIES");250 if (offer?.type === "SYNC_ENTRIES") {251 expect(offer.entries.map((e) => e.id)).toEqual(["shared"]);252 }253 expect(messages.find((m) => m.type === "SYNC_REQUEST")).toBeUndefined();254 });255256 it("resolves equal-timestamp conflicts deterministically: remote wins → request + archive", async () => {257 const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];258 const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));259 const localManifest = (260 await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)261 )[0]!;262 263 const remoteHash =264 localManifest.hash > "0".repeat(64) ? "0".repeat(64) : "f".repeat(64);265266 const result = await dispatchSyncFrame(267 encodeSyncMessage({268 type: "SYNC_MANIFEST",269 entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],270 }),271 new Uint8Array(32).fill(21),272 {273 isProUser: async () => true,274 isUnlocked: () => true,275 getEntries: () => localEntries,276 restoreEntries,277 }278 );279280 281 282 expect(restoreEntries).toHaveBeenCalledTimes(1);283 const archived = (restoreEntries.mock.calls[0] as unknown as [VaultEntry[]])[0];284 expect(archived).toHaveLength(1);285 expect(archived[0]!.id).toBe(`shared-conflict-${localManifest.hash.slice(0, 8)}`);286 expect(archived[0]!.title).toBe("LocalEdit (conflicted copy)");287288 const messages = result.outbound.map((frame) => decodeSyncMessage(frame));289 expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({290 type: "SYNC_REQUEST",291 ids: ["shared"],292 });293 expect(messages.find((m) => m.type === "SYNC_ENTRIES")).toBeUndefined();294 expect(result.merge).toEqual({ added: 1, updated: 0, skipped: 0 });295 });296297 it("returns SYNC_DONE when manifests already agree", async () => {298 const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);299 const localManifest = encodeSyncMessage({300 type: "SYNC_MANIFEST",301 entries: [302 {303 id: "shared",304 modifiedAt: 1_000,305 hash: (306 await (await import("../shared/sync-protocol")).buildSyncManifest(307 deps.getEntries()308 )309 )[0]!.hash,310 },311 ],312 });313 const result = await dispatchSyncFrame(314 localManifest,315 new Uint8Array(32).fill(3),316 deps317 );318 const messages = result.outbound.map((frame) => decodeSyncMessage(frame));319 expect(messages).toHaveLength(1);320 expect(messages[0]!.type).toBe("SYNC_DONE");321 });322323 it("encrypts only the requested ids on SYNC_REQUEST", async () => {324 const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);325 const result = await dispatchSyncFrame(326 encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),327 new Uint8Array(32).fill(4),328 deps329 );330 expect(result.outbound).toHaveLength(1);331 const decoded = decodeSyncMessage(result.outbound[0]!);332 expect(decoded.type).toBe("SYNC_ENTRIES");333 if (decoded.type === "SYNC_ENTRIES") {334 expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);335 }336 });337338 it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {339 const rawEntryKey = new Uint8Array(32).fill(9);340 const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {341 isProUser: async () => true,342 isUnlocked: () => true,343 getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],344 });345 const deps = passDeps([]);346 const result = await dispatchSyncFrame(347 encodeSyncMessage(offered),348 rawEntryKey,349 deps350 );351 expect(deps.restoreEntries).toHaveBeenCalledWith(352 [expect.objectContaining({ id: "remote", title: "Remote" })],353 "merge"354 );355 const decoded = decodeSyncMessage(result.outbound[0]!);356 expect(decoded.type).toBe("SYNC_DONE");357 });358359 it("returns SYNC_ERROR + done for malformed inbound frames", async () => {360 const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {361 isProUser: async () => true,362 isUnlocked: () => true,363 getEntries: () => [],364 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),365 });366 expect(result.done).toBe(true);367 expect(result.error?.code).toBe("invalid_message");368 expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");369 });370371 it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {372 const result = await dispatchSyncFrame(373 encodeSyncMessage({374 type: "SYNC_HELLO",375 deviceId: "device",376 clockMs: 0,377 vaultVersion: 0,378 }),379 new Uint8Array(32),380 {381 isProUser: async () => true,382 isUnlocked: () => false,383 getEntries: () => [],384 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),385 }386 );387 expect(result.done).toBe(true);388 expect(result.error?.code).toBe("vault_locked");389 const decoded = decodeSyncMessage(result.outbound[0]!);390 expect(decoded.type).toBe("SYNC_ERROR");391 if (decoded.type === "SYNC_ERROR") {392 expect(decoded.code).toBe("vault_locked");393 }394 });395396 it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {397 const result = await dispatchSyncFrame(398 encodeSyncMessage({ type: "SYNC_DONE" }),399 new Uint8Array(32).fill(7),400 passDeps([])401 );402 expect(result.outbound).toEqual([]);403 expect(result.done).toBe(true);404 });405406 it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {407 const result = await dispatchSyncFrame(408 encodeSyncMessage({409 type: "SYNC_ERROR",410 code: "peer_failed",411 message: "Peer reported error.",412 }),413 new Uint8Array(32).fill(8),414 passDeps([])415 );416 expect(result.outbound).toEqual([]);417 expect(result.done).toBe(true);418 expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });419 });420 });421422 describe("runSyncSession", () => {423 interface TransportHarness {424 transport: SyncSessionTransport;425 sent: string[];426 emitFrame: (frame: string) => Promise<void>;427 emitError: (message: string) => Promise<void>;428 emitClose: () => Promise<void>;429 isClosed: () => boolean;430 ready: () => Promise<void>;431 }432433 function makeTransport(rawKey: Uint8Array): TransportHarness {434 const sent: string[] = [];435 const frameQueue: string[] = [];436 const errorQueue: string[] = [];437 let pendingClose = false;438 let frameHandler: ((frame: string) => void) | null = null;439 let errorHandler: ((message: string) => void) | null = null;440 let closeHandler: (() => void) | null = null;441 let resolveReady: (() => void) | null = null;442 const ready = new Promise<void>((resolve) => {443 resolveReady = resolve;444 });445 let closed = false;446447 const checkReady = (): void => {448 if (frameHandler && errorHandler && closeHandler && resolveReady) {449 resolveReady();450 resolveReady = null;451 for (const queued of frameQueue.splice(0)) frameHandler(queued);452 for (const queued of errorQueue.splice(0)) errorHandler(queued);453 if (pendingClose && closeHandler) {454 pendingClose = false;455 closeHandler();456 }457 }458 };459460 const transport: SyncSessionTransport = {461 send: (frame) => {462 sent.push(frame);463 },464 onFrame: (handler) => {465 frameHandler = handler;466 checkReady();467 return () => {468 if (frameHandler === handler) frameHandler = null;469 };470 },471 onError: (handler) => {472 errorHandler = handler;473 checkReady();474 return () => {475 if (errorHandler === handler) errorHandler = null;476 };477 },478 onClose: (handler) => {479 closeHandler = handler;480 checkReady();481 return () => {482 if (closeHandler === handler) closeHandler = null;483 };484 },485 close: () => {486 closed = true;487 },488 getSessionKey: async () => rawKey,489 };490491 const flush = async (): Promise<void> => {492 for (let i = 0; i < 50; i += 1) {493 await new Promise<void>((resolve) => setTimeout(resolve, 0));494 }495 };496497 return {498 transport,499 sent,500 emitFrame: async (frame) => {501 if (frameHandler) frameHandler(frame);502 else frameQueue.push(frame);503 await flush();504 },505 emitError: async (message) => {506 if (errorHandler) errorHandler(message);507 else errorQueue.push(message);508 await flush();509 },510 emitClose: async () => {511 if (closeHandler) closeHandler();512 else pendingClose = true;513 await flush();514 },515 isClosed: () => closed,516 ready: () => ready,517 };518 }519520 it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {521 const harness = makeTransport(new Uint8Array(32).fill(11));522523 const sessionPromise = runSyncSession({524 role: "initiator",525 deviceId: "device-a",526 vaultVersion: 1,527 transport: harness.transport,528 deps: {529 isProUser: async () => true,530 isUnlocked: () => true,531 getEntries: () => [],532 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),533 },534 });535536 await harness.ready();537 expect(harness.sent).toHaveLength(1);538 expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");539540 await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));541542 const result = await sessionPromise;543 expect(result.status).toBe("completed");544 expect(harness.isClosed()).toBe(true);545 });546547 it("does not send SYNC_HELLO when responder", async () => {548 const harness = makeTransport(new Uint8Array(32));549 const sessionPromise = runSyncSession({550 role: "responder",551 deviceId: "device-b",552 vaultVersion: 1,553 transport: harness.transport,554 deps: {555 isProUser: async () => true,556 isUnlocked: () => true,557 getEntries: () => [],558 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),559 },560 });561 await harness.ready();562 expect(harness.sent).toEqual([]);563564 await harness.emitClose();565 const result = await sessionPromise;566 expect(result.status).toBe("closed");567 });568569 it("aborts immediately with SYNC_ERROR when preflight fails", async () => {570 const harness = makeTransport(new Uint8Array(32));571 const result = await runSyncSession({572 role: "initiator",573 deviceId: "device",574 vaultVersion: 1,575 transport: harness.transport,576 deps: {577 isProUser: async () => false,578 isUnlocked: () => true,579 getEntries: () => [],580 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),581 },582 });583584 expect(result.status).toBe("errored");585 expect(result.error?.code).toBe("pro_required");586 expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");587 expect(harness.isClosed()).toBe(true);588 });589590 it("propagates transport errors as a session result", async () => {591 const harness = makeTransport(new Uint8Array(32));592 const sessionPromise = runSyncSession({593 role: "responder",594 deviceId: "device",595 vaultVersion: 1,596 transport: harness.transport,597 deps: {598 isProUser: async () => true,599 isUnlocked: () => true,600 getEntries: () => [],601 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),602 },603 });604 await harness.ready();605 await harness.emitError("native host disconnected");606 const result = await sessionPromise;607 expect(result.status).toBe("errored");608 expect(result.error).toEqual({609 code: "transport_error",610 message: "native host disconnected",611 });612 });613614 it("createNativeSyncTransport routes only matching frames and errors", async () => {615 const sendSyncData = vi.fn();616 const disconnectFromPeer = vi.fn();617 const handlers: {618 frame: ((id: string, data: string) => void) | null;619 error: ((msg: string, id?: string) => void) | null;620 disconnect: ((id?: string) => void) | null;621 } = { frame: null, error: null, disconnect: null };622623 const bindings: NativeSyncTransportBindings = {624 sendSyncData,625 disconnectFromPeer,626 onSyncMessage: (h) => {627 handlers.frame = h;628 return () => {629 if (handlers.frame === h) handlers.frame = null;630 };631 },632 onError: (h) => {633 handlers.error = h;634 return () => {635 if (handlers.error === h) handlers.error = null;636 };637 },638 onDisconnected: (h) => {639 handlers.disconnect = h;640 return () => {641 if (handlers.disconnect === h) handlers.disconnect = null;642 };643 },644 getNativeSessionKey: async () => new Uint8Array(32).fill(7),645 };646647 const transport = await createNativeSyncTransport("target", bindings);648649 const frames: string[] = [];650 const errors: string[] = [];651 let closes = 0;652 transport.onFrame((frame) => frames.push(frame));653 transport.onError((message) => errors.push(message));654 transport.onClose(() => {655 closes += 1;656 });657658 handlers.frame?.("other", "ignored");659 handlers.frame?.("target", "kept");660 expect(frames).toEqual(["kept"]);661662 handlers.error?.("scoped error", "other");663 handlers.error?.("scoped error", "target");664 handlers.error?.("global error", undefined);665 expect(errors).toEqual(["scoped error", "global error"]);666667 handlers.disconnect?.("other");668 handlers.disconnect?.("target");669 handlers.disconnect?.(undefined);670 expect(closes).toBe(2);671672 transport.send("frame");673 expect(sendSyncData).toHaveBeenCalledWith("target", "frame");674675 transport.close();676 expect(disconnectFromPeer).toHaveBeenCalledWith("target");677678 const key = await transport.getSessionKey();679 expect(key).toEqual(new Uint8Array(32).fill(7));680 });681682 it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {683 const rawKey = new Uint8Array(32).fill(12);684 const harness = makeTransport(rawKey);685 const restoreEntries = vi686 .fn()687 .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });688689 const sessionPromise = runSyncSession({690 role: "responder",691 deviceId: "device",692 vaultVersion: 1,693 transport: harness.transport,694 deps: {695 isProUser: async () => true,696 isUnlocked: () => true,697 getEntries: () => [],698 restoreEntries,699 },700 });701 await harness.ready();702703 const incoming = await buildSyncEntriesMessage(["a"], rawKey, {704 isProUser: async () => true,705 isUnlocked: () => true,706 getEntries: () => [entry({ id: "a", title: "First" })],707 });708 await harness.emitFrame(encodeSyncMessage(incoming));709 710 await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));711712 const result = await sessionPromise;713 expect(result.status).toBe("completed");714 expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });715 });716 });717});