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("returns SYNC_DONE when manifests already agree", async () => {222 const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);223 const localManifest = encodeSyncMessage({224 type: "SYNC_MANIFEST",225 entries: [226 {227 id: "shared",228 modifiedAt: 1_000,229 hash: (230 await (await import("../shared/sync-protocol")).buildSyncManifest(231 deps.getEntries()232 )233 )[0]!.hash,234 },235 ],236 });237 const result = await dispatchSyncFrame(238 localManifest,239 new Uint8Array(32).fill(3),240 deps241 );242 const messages = result.outbound.map((frame) => decodeSyncMessage(frame));243 expect(messages).toHaveLength(1);244 expect(messages[0]!.type).toBe("SYNC_DONE");245 });246247 it("encrypts only the requested ids on SYNC_REQUEST", async () => {248 const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);249 const result = await dispatchSyncFrame(250 encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),251 new Uint8Array(32).fill(4),252 deps253 );254 expect(result.outbound).toHaveLength(1);255 const decoded = decodeSyncMessage(result.outbound[0]!);256 expect(decoded.type).toBe("SYNC_ENTRIES");257 if (decoded.type === "SYNC_ENTRIES") {258 expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);259 }260 });261262 it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {263 const rawEntryKey = new Uint8Array(32).fill(9);264 const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {265 isProUser: async () => true,266 isUnlocked: () => true,267 getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],268 });269 const deps = passDeps([]);270 const result = await dispatchSyncFrame(271 encodeSyncMessage(offered),272 rawEntryKey,273 deps274 );275 expect(deps.restoreEntries).toHaveBeenCalledWith(276 [expect.objectContaining({ id: "remote", title: "Remote" })],277 "merge"278 );279 const decoded = decodeSyncMessage(result.outbound[0]!);280 expect(decoded.type).toBe("SYNC_DONE");281 });282283 it("returns SYNC_ERROR + done for malformed inbound frames", async () => {284 const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {285 isProUser: async () => true,286 isUnlocked: () => true,287 getEntries: () => [],288 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),289 });290 expect(result.done).toBe(true);291 expect(result.error?.code).toBe("invalid_message");292 expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");293 });294295 it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {296 const result = await dispatchSyncFrame(297 encodeSyncMessage({298 type: "SYNC_HELLO",299 deviceId: "device",300 clockMs: 0,301 vaultVersion: 0,302 }),303 new Uint8Array(32),304 {305 isProUser: async () => true,306 isUnlocked: () => false,307 getEntries: () => [],308 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),309 }310 );311 expect(result.done).toBe(true);312 expect(result.error?.code).toBe("vault_locked");313 const decoded = decodeSyncMessage(result.outbound[0]!);314 expect(decoded.type).toBe("SYNC_ERROR");315 if (decoded.type === "SYNC_ERROR") {316 expect(decoded.code).toBe("vault_locked");317 }318 });319320 it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {321 const result = await dispatchSyncFrame(322 encodeSyncMessage({ type: "SYNC_DONE" }),323 new Uint8Array(32).fill(7),324 passDeps([])325 );326 expect(result.outbound).toEqual([]);327 expect(result.done).toBe(true);328 });329330 it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {331 const result = await dispatchSyncFrame(332 encodeSyncMessage({333 type: "SYNC_ERROR",334 code: "peer_failed",335 message: "Peer reported error.",336 }),337 new Uint8Array(32).fill(8),338 passDeps([])339 );340 expect(result.outbound).toEqual([]);341 expect(result.done).toBe(true);342 expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });343 });344 });345346 describe("runSyncSession", () => {347 interface TransportHarness {348 transport: SyncSessionTransport;349 sent: string[];350 emitFrame: (frame: string) => Promise<void>;351 emitError: (message: string) => Promise<void>;352 emitClose: () => Promise<void>;353 isClosed: () => boolean;354 ready: () => Promise<void>;355 }356357 function makeTransport(rawKey: Uint8Array): TransportHarness {358 const sent: string[] = [];359 const frameQueue: string[] = [];360 const errorQueue: string[] = [];361 let pendingClose = false;362 let frameHandler: ((frame: string) => void) | null = null;363 let errorHandler: ((message: string) => void) | null = null;364 let closeHandler: (() => void) | null = null;365 let resolveReady: (() => void) | null = null;366 const ready = new Promise<void>((resolve) => {367 resolveReady = resolve;368 });369 let closed = false;370371 const checkReady = (): void => {372 if (frameHandler && errorHandler && closeHandler && resolveReady) {373 resolveReady();374 resolveReady = null;375 for (const queued of frameQueue.splice(0)) frameHandler(queued);376 for (const queued of errorQueue.splice(0)) errorHandler(queued);377 if (pendingClose && closeHandler) {378 pendingClose = false;379 closeHandler();380 }381 }382 };383384 const transport: SyncSessionTransport = {385 send: (frame) => {386 sent.push(frame);387 },388 onFrame: (handler) => {389 frameHandler = handler;390 checkReady();391 return () => {392 if (frameHandler === handler) frameHandler = null;393 };394 },395 onError: (handler) => {396 errorHandler = handler;397 checkReady();398 return () => {399 if (errorHandler === handler) errorHandler = null;400 };401 },402 onClose: (handler) => {403 closeHandler = handler;404 checkReady();405 return () => {406 if (closeHandler === handler) closeHandler = null;407 };408 },409 close: () => {410 closed = true;411 },412 getSessionKey: async () => rawKey,413 };414415 const flush = async (): Promise<void> => {416 for (let i = 0; i < 50; i += 1) {417 await new Promise<void>((resolve) => setTimeout(resolve, 0));418 }419 };420421 return {422 transport,423 sent,424 emitFrame: async (frame) => {425 if (frameHandler) frameHandler(frame);426 else frameQueue.push(frame);427 await flush();428 },429 emitError: async (message) => {430 if (errorHandler) errorHandler(message);431 else errorQueue.push(message);432 await flush();433 },434 emitClose: async () => {435 if (closeHandler) closeHandler();436 else pendingClose = true;437 await flush();438 },439 isClosed: () => closed,440 ready: () => ready,441 };442 }443444 it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {445 const harness = makeTransport(new Uint8Array(32).fill(11));446447 const sessionPromise = runSyncSession({448 role: "initiator",449 deviceId: "device-a",450 vaultVersion: 1,451 transport: harness.transport,452 deps: {453 isProUser: async () => true,454 isUnlocked: () => true,455 getEntries: () => [],456 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),457 },458 });459460 await harness.ready();461 expect(harness.sent).toHaveLength(1);462 expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");463464 await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));465466 const result = await sessionPromise;467 expect(result.status).toBe("completed");468 expect(harness.isClosed()).toBe(true);469 });470471 it("does not send SYNC_HELLO when responder", async () => {472 const harness = makeTransport(new Uint8Array(32));473 const sessionPromise = runSyncSession({474 role: "responder",475 deviceId: "device-b",476 vaultVersion: 1,477 transport: harness.transport,478 deps: {479 isProUser: async () => true,480 isUnlocked: () => true,481 getEntries: () => [],482 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),483 },484 });485 await harness.ready();486 expect(harness.sent).toEqual([]);487488 await harness.emitClose();489 const result = await sessionPromise;490 expect(result.status).toBe("closed");491 });492493 it("aborts immediately with SYNC_ERROR when preflight fails", async () => {494 const harness = makeTransport(new Uint8Array(32));495 const result = await runSyncSession({496 role: "initiator",497 deviceId: "device",498 vaultVersion: 1,499 transport: harness.transport,500 deps: {501 isProUser: async () => false,502 isUnlocked: () => true,503 getEntries: () => [],504 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),505 },506 });507508 expect(result.status).toBe("errored");509 expect(result.error?.code).toBe("pro_required");510 expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");511 expect(harness.isClosed()).toBe(true);512 });513514 it("propagates transport errors as a session result", async () => {515 const harness = makeTransport(new Uint8Array(32));516 const sessionPromise = runSyncSession({517 role: "responder",518 deviceId: "device",519 vaultVersion: 1,520 transport: harness.transport,521 deps: {522 isProUser: async () => true,523 isUnlocked: () => true,524 getEntries: () => [],525 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),526 },527 });528 await harness.ready();529 await harness.emitError("native host disconnected");530 const result = await sessionPromise;531 expect(result.status).toBe("errored");532 expect(result.error).toEqual({533 code: "transport_error",534 message: "native host disconnected",535 });536 });537538 it("createNativeSyncTransport routes only matching frames and errors", async () => {539 const sendSyncData = vi.fn();540 const disconnectFromPeer = vi.fn();541 const handlers: {542 frame: ((id: string, data: string) => void) | null;543 error: ((msg: string, id?: string) => void) | null;544 disconnect: ((id?: string) => void) | null;545 } = { frame: null, error: null, disconnect: null };546547 const bindings: NativeSyncTransportBindings = {548 sendSyncData,549 disconnectFromPeer,550 onSyncMessage: (h) => {551 handlers.frame = h;552 return () => {553 if (handlers.frame === h) handlers.frame = null;554 };555 },556 onError: (h) => {557 handlers.error = h;558 return () => {559 if (handlers.error === h) handlers.error = null;560 };561 },562 onDisconnected: (h) => {563 handlers.disconnect = h;564 return () => {565 if (handlers.disconnect === h) handlers.disconnect = null;566 };567 },568 getNativeSessionKey: async () => new Uint8Array(32).fill(7),569 };570571 const transport = await createNativeSyncTransport("target", bindings);572573 const frames: string[] = [];574 const errors: string[] = [];575 let closes = 0;576 transport.onFrame((frame) => frames.push(frame));577 transport.onError((message) => errors.push(message));578 transport.onClose(() => {579 closes += 1;580 });581582 handlers.frame?.("other", "ignored");583 handlers.frame?.("target", "kept");584 expect(frames).toEqual(["kept"]);585586 handlers.error?.("scoped error", "other");587 handlers.error?.("scoped error", "target");588 handlers.error?.("global error", undefined);589 expect(errors).toEqual(["scoped error", "global error"]);590591 handlers.disconnect?.("other");592 handlers.disconnect?.("target");593 handlers.disconnect?.(undefined);594 expect(closes).toBe(2);595596 transport.send("frame");597 expect(sendSyncData).toHaveBeenCalledWith("target", "frame");598599 transport.close();600 expect(disconnectFromPeer).toHaveBeenCalledWith("target");601602 const key = await transport.getSessionKey();603 expect(key).toEqual(new Uint8Array(32).fill(7));604 });605606 it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {607 const rawKey = new Uint8Array(32).fill(12);608 const harness = makeTransport(rawKey);609 const restoreEntries = vi610 .fn()611 .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });612613 const sessionPromise = runSyncSession({614 role: "responder",615 deviceId: "device",616 vaultVersion: 1,617 transport: harness.transport,618 deps: {619 isProUser: async () => true,620 isUnlocked: () => true,621 getEntries: () => [],622 restoreEntries,623 },624 });625 await harness.ready();626627 const incoming = await buildSyncEntriesMessage(["a"], rawKey, {628 isProUser: async () => true,629 isUnlocked: () => true,630 getEntries: () => [entry({ id: "a", title: "First" })],631 });632 await harness.emitFrame(encodeSyncMessage(incoming));633 634 await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));635636 const result = await sessionPromise;637 expect(result.status).toBe("completed");638 expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });639 });640 });641});