1import { describe, expect, it, vi } from "vitest";23import {4 applySyncEntriesMessage,5 buildSyncEntriesMessage,6 buildLocalSyncSnapshot,7 cancelSyncIfVaultLocked,8 checkSyncPreflight,9 dispatchSyncFrame,10 planManifestSync,11 runSyncSession,12 type SyncSessionTransport,13} from "./sync-coordinator";14import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";15import type { VaultEntry } from "../shared/types";1617function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {18 return {19 id: "entry-1",20 type: "login",21 title: "GitHub",22 username: "june",23 password: "secret",24 url: "https://github.com",25 createdAt: 1_000,26 modifiedAt: 1_000,27 ...overrides,28 };29}3031describe("sync coordinator", () => {32 it("blocks sync for free users before inspecting the vault", async () => {33 const getEntries = vi.fn(() => [entry()]);3435 await expect(36 checkSyncPreflight({37 isProUser: async () => false,38 isUnlocked: () => true,39 })40 ).resolves.toEqual({ ok: false, error: "pro_required" });4142 expect(getEntries).not.toHaveBeenCalled();43 });4445 it("blocks sync when the vault is locked", async () => {46 await expect(47 checkSyncPreflight({48 isProUser: async () => true,49 isUnlocked: () => false,50 })51 ).resolves.toEqual({ ok: false, error: "vault_locked" });52 });5354 it("builds a local sync snapshot only after preflight passes", async () => {55 const snapshot = await buildLocalSyncSnapshot({56 isProUser: async () => true,57 isUnlocked: () => true,58 getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })],59 });6061 expect(snapshot.entries).toHaveLength(2);62 expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]);63 });6465 it("plans manifest sync without sending entry payloads", async () => {66 const plan = await planManifestSync(67 [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }],68 {69 isProUser: async () => true,70 isUnlocked: () => true,71 getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })],72 }73 );7475 expect(plan.diff).toEqual({76 requestFromRemote: ["remote-only"],77 offerToRemote: ["local-only"],78 conflicts: [],79 });80 });8182 it("builds encrypted entry messages for requested ids", async () => {83 const rawEntryKey = new Uint8Array(32).fill(5);84 const message = await buildSyncEntriesMessage(["b"], rawEntryKey, {85 isProUser: async () => true,86 isUnlocked: () => true,87 getEntries: () => [88 entry({ id: "a" }),89 entry({90 id: "b",91 passkey: {92 rpId: "github.com",93 rpName: "GitHub",94 credentialId: "credential",95 publicKeyCose: "public",96 privateKeyJwk: { kty: "EC", d: "secret" },97 userHandle: "user",98 userName: "june",99 userDisplayName: "June",100 algorithm: -7,101 signCount: 1,102 aaguid: "aaguid",103 backupEligible: true,104 backupState: true,105 createdAt: 1_000,106 },107 }),108 ],109 });110111 expect(message.type).toBe("SYNC_ENTRIES");112 expect(message.entries).toHaveLength(1);113 expect(message.entries[0]).toMatchObject({ id: "b" });114 expect(message.entries[0]?.ciphertext).not.toContain("secret");115 });116117 it("decrypts and applies incoming entry messages through vault merge", async () => {118 const rawEntryKey = new Uint8Array(32).fill(6);119 const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, {120 isProUser: async () => true,121 isUnlocked: () => true,122 getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })],123 });124 const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));125126 await expect(127 applySyncEntriesMessage(message, rawEntryKey, {128 isProUser: async () => true,129 isUnlocked: () => true,130 restoreEntries,131 })132 ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 });133134 expect(restoreEntries).toHaveBeenCalledWith(135 [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })],136 "merge"137 );138 });139140 it("sends a stable sync error when a peer asks while the vault is locked", async () => {141 const sendSyncData = vi.fn();142143 const cancelled = await cancelSyncIfVaultLocked("conn-1", {144 isUnlocked: () => false,145 sendSyncData,146 });147148 expect(cancelled).toBe(true);149 expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String));150 expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({151 type: "SYNC_ERROR",152 code: "vault_locked",153 message: "Vault is locked; unlock Peach before syncing.",154 });155 });156157 it("does not cancel an unlocked vault sync", async () => {158 const sendSyncData = vi.fn();159160 expect(161 await cancelSyncIfVaultLocked("conn-1", {162 isUnlocked: () => true,163 sendSyncData,164 })165 ).toBe(false);166 expect(sendSyncData).not.toHaveBeenCalled();167 });168169 describe("dispatchSyncFrame", () => {170 const passDeps = (entries: VaultEntry[]) => ({171 isProUser: async () => true,172 isUnlocked: () => true,173 getEntries: () => entries,174 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),175 });176177 it("answers SYNC_HELLO with a sorted SYNC_MANIFEST", async () => {178 const deps = passDeps([entry({ id: "b" }), entry({ id: "a" })]);179 const result = await dispatchSyncFrame(180 encodeSyncMessage({181 type: "SYNC_HELLO",182 deviceId: "device",183 clockMs: 1,184 vaultVersion: 1,185 }),186 new Uint8Array(32).fill(1),187 deps188 );189 expect(result.outbound).toHaveLength(1);190 const decoded = decodeSyncMessage(result.outbound[0]!);191 expect(decoded.type).toBe("SYNC_MANIFEST");192 if (decoded.type === "SYNC_MANIFEST") {193 expect(decoded.entries.map((entry) => entry.id)).toEqual(["a", "b"]);194 }195 });196197 it("requests missing ids and offers local-only ids on SYNC_MANIFEST", async () => {198 const deps = passDeps([entry({ id: "local", modifiedAt: 2_000 })]);199 const result = await dispatchSyncFrame(200 encodeSyncMessage({201 type: "SYNC_MANIFEST",202 entries: [{ id: "remote", modifiedAt: 1_500, hash: "remote" }],203 }),204 new Uint8Array(32).fill(2),205 deps206 );207 const messages = result.outbound.map((frame) => decodeSyncMessage(frame));208 expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({209 type: "SYNC_REQUEST",210 ids: ["remote"],211 });212 const entriesMessage = messages.find((m) => m.type === "SYNC_ENTRIES");213 expect(entriesMessage?.type).toBe("SYNC_ENTRIES");214 if (entriesMessage?.type === "SYNC_ENTRIES") {215 expect(entriesMessage.entries.map((e) => e.id)).toEqual(["local"]);216 }217 });218219 it("returns SYNC_DONE when manifests already agree", async () => {220 const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);221 const localManifest = encodeSyncMessage({222 type: "SYNC_MANIFEST",223 entries: [224 {225 id: "shared",226 modifiedAt: 1_000,227 hash: (228 await (await import("../shared/sync-protocol")).buildSyncManifest(229 deps.getEntries()230 )231 )[0]!.hash,232 },233 ],234 });235 const result = await dispatchSyncFrame(236 localManifest,237 new Uint8Array(32).fill(3),238 deps239 );240 const messages = result.outbound.map((frame) => decodeSyncMessage(frame));241 expect(messages).toHaveLength(1);242 expect(messages[0]!.type).toBe("SYNC_DONE");243 });244245 it("encrypts only the requested ids on SYNC_REQUEST", async () => {246 const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);247 const result = await dispatchSyncFrame(248 encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),249 new Uint8Array(32).fill(4),250 deps251 );252 expect(result.outbound).toHaveLength(1);253 const decoded = decodeSyncMessage(result.outbound[0]!);254 expect(decoded.type).toBe("SYNC_ENTRIES");255 if (decoded.type === "SYNC_ENTRIES") {256 expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);257 }258 });259260 it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {261 const rawEntryKey = new Uint8Array(32).fill(9);262 const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {263 isProUser: async () => true,264 isUnlocked: () => true,265 getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],266 });267 const deps = passDeps([]);268 const result = await dispatchSyncFrame(269 encodeSyncMessage(offered),270 rawEntryKey,271 deps272 );273 expect(deps.restoreEntries).toHaveBeenCalledWith(274 [expect.objectContaining({ id: "remote", title: "Remote" })],275 "merge"276 );277 const decoded = decodeSyncMessage(result.outbound[0]!);278 expect(decoded.type).toBe("SYNC_DONE");279 });280281 it("returns SYNC_ERROR + done for malformed inbound frames", async () => {282 const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {283 isProUser: async () => true,284 isUnlocked: () => true,285 getEntries: () => [],286 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),287 });288 expect(result.done).toBe(true);289 expect(result.error?.code).toBe("invalid_message");290 expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");291 });292293 it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {294 const result = await dispatchSyncFrame(295 encodeSyncMessage({296 type: "SYNC_HELLO",297 deviceId: "device",298 clockMs: 0,299 vaultVersion: 0,300 }),301 new Uint8Array(32),302 {303 isProUser: async () => true,304 isUnlocked: () => false,305 getEntries: () => [],306 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),307 }308 );309 expect(result.done).toBe(true);310 expect(result.error?.code).toBe("vault_locked");311 const decoded = decodeSyncMessage(result.outbound[0]!);312 expect(decoded.type).toBe("SYNC_ERROR");313 if (decoded.type === "SYNC_ERROR") {314 expect(decoded.code).toBe("vault_locked");315 }316 });317318 it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {319 const result = await dispatchSyncFrame(320 encodeSyncMessage({ type: "SYNC_DONE" }),321 new Uint8Array(32).fill(7),322 passDeps([])323 );324 expect(result.outbound).toEqual([]);325 expect(result.done).toBe(true);326 });327328 it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {329 const result = await dispatchSyncFrame(330 encodeSyncMessage({331 type: "SYNC_ERROR",332 code: "peer_failed",333 message: "Peer reported error.",334 }),335 new Uint8Array(32).fill(8),336 passDeps([])337 );338 expect(result.outbound).toEqual([]);339 expect(result.done).toBe(true);340 expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });341 });342 });343344 describe("runSyncSession", () => {345 interface TransportHarness {346 transport: SyncSessionTransport;347 sent: string[];348 emitFrame: (frame: string) => Promise<void>;349 emitError: (message: string) => Promise<void>;350 emitClose: () => Promise<void>;351 isClosed: () => boolean;352 ready: () => Promise<void>;353 }354355 function makeTransport(rawKey: Uint8Array): TransportHarness {356 const sent: string[] = [];357 const frameQueue: string[] = [];358 const errorQueue: string[] = [];359 let pendingClose = false;360 let frameHandler: ((frame: string) => void) | null = null;361 let errorHandler: ((message: string) => void) | null = null;362 let closeHandler: (() => void) | null = null;363 let resolveReady: (() => void) | null = null;364 const ready = new Promise<void>((resolve) => {365 resolveReady = resolve;366 });367 let closed = false;368369 const checkReady = (): void => {370 if (frameHandler && errorHandler && closeHandler && resolveReady) {371 resolveReady();372 resolveReady = null;373 for (const queued of frameQueue.splice(0)) frameHandler(queued);374 for (const queued of errorQueue.splice(0)) errorHandler(queued);375 if (pendingClose && closeHandler) {376 pendingClose = false;377 closeHandler();378 }379 }380 };381382 const transport: SyncSessionTransport = {383 send: (frame) => {384 sent.push(frame);385 },386 onFrame: (handler) => {387 frameHandler = handler;388 checkReady();389 return () => {390 if (frameHandler === handler) frameHandler = null;391 };392 },393 onError: (handler) => {394 errorHandler = handler;395 checkReady();396 return () => {397 if (errorHandler === handler) errorHandler = null;398 };399 },400 onClose: (handler) => {401 closeHandler = handler;402 checkReady();403 return () => {404 if (closeHandler === handler) closeHandler = null;405 };406 },407 close: () => {408 closed = true;409 },410 getSessionKey: async () => rawKey,411 };412413 const flush = async (): Promise<void> => {414 for (let i = 0; i < 50; i += 1) {415 await new Promise<void>((resolve) => setTimeout(resolve, 0));416 }417 };418419 return {420 transport,421 sent,422 emitFrame: async (frame) => {423 if (frameHandler) frameHandler(frame);424 else frameQueue.push(frame);425 await flush();426 },427 emitError: async (message) => {428 if (errorHandler) errorHandler(message);429 else errorQueue.push(message);430 await flush();431 },432 emitClose: async () => {433 if (closeHandler) closeHandler();434 else pendingClose = true;435 await flush();436 },437 isClosed: () => closed,438 ready: () => ready,439 };440 }441442 it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {443 const harness = makeTransport(new Uint8Array(32).fill(11));444445 const sessionPromise = runSyncSession({446 role: "initiator",447 deviceId: "device-a",448 vaultVersion: 1,449 transport: harness.transport,450 deps: {451 isProUser: async () => true,452 isUnlocked: () => true,453 getEntries: () => [],454 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),455 },456 });457458 await harness.ready();459 expect(harness.sent).toHaveLength(1);460 expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");461462 await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));463464 const result = await sessionPromise;465 expect(result.status).toBe("completed");466 expect(harness.isClosed()).toBe(true);467 });468469 it("does not send SYNC_HELLO when responder", async () => {470 const harness = makeTransport(new Uint8Array(32));471 const sessionPromise = runSyncSession({472 role: "responder",473 deviceId: "device-b",474 vaultVersion: 1,475 transport: harness.transport,476 deps: {477 isProUser: async () => true,478 isUnlocked: () => true,479 getEntries: () => [],480 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),481 },482 });483 await harness.ready();484 expect(harness.sent).toEqual([]);485486 await harness.emitClose();487 const result = await sessionPromise;488 expect(result.status).toBe("closed");489 });490491 it("aborts immediately with SYNC_ERROR when preflight fails", async () => {492 const harness = makeTransport(new Uint8Array(32));493 const result = await runSyncSession({494 role: "initiator",495 deviceId: "device",496 vaultVersion: 1,497 transport: harness.transport,498 deps: {499 isProUser: async () => false,500 isUnlocked: () => true,501 getEntries: () => [],502 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),503 },504 });505506 expect(result.status).toBe("errored");507 expect(result.error?.code).toBe("pro_required");508 expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");509 expect(harness.isClosed()).toBe(true);510 });511512 it("propagates transport errors as a session result", async () => {513 const harness = makeTransport(new Uint8Array(32));514 const sessionPromise = runSyncSession({515 role: "responder",516 deviceId: "device",517 vaultVersion: 1,518 transport: harness.transport,519 deps: {520 isProUser: async () => true,521 isUnlocked: () => true,522 getEntries: () => [],523 restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),524 },525 });526 await harness.ready();527 await harness.emitError("native host disconnected");528 const result = await sessionPromise;529 expect(result.status).toBe("errored");530 expect(result.error).toEqual({531 code: "transport_error",532 message: "native host disconnected",533 });534 });535536 it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {537 const rawKey = new Uint8Array(32).fill(12);538 const harness = makeTransport(rawKey);539 const restoreEntries = vi540 .fn()541 .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });542543 const sessionPromise = runSyncSession({544 role: "responder",545 deviceId: "device",546 vaultVersion: 1,547 transport: harness.transport,548 deps: {549 isProUser: async () => true,550 isUnlocked: () => true,551 getEntries: () => [],552 restoreEntries,553 },554 });555 await harness.ready();556557 const incoming = await buildSyncEntriesMessage(["a"], rawKey, {558 isProUser: async () => true,559 isUnlocked: () => true,560 getEntries: () => [entry({ id: "a", title: "First" })],561 });562 await harness.emitFrame(encodeSyncMessage(incoming));563 564 await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));565566 const result = await sessionPromise;567 expect(result.status).toBe("completed");568 expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });569 });570 });571});