1import { describe, expect, it, vi } from "vitest";2import { SYNC_LIMITS } from "../shared/sync-limits";3import {4 runSyncInitiator,5 runSyncResponder,6 type SyncVaultAdapter,7} from "./sync-coordinator";8import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";9import type { SyncSession } from "../shared/sync-transport";10import { buildSignedDeviceManifest } from "../shared/device-manifest";11import { deriveSyncSigningKeypair } from "../shared/sync-identity";1213function inMemoryPair(): [SyncSession, FakePeer] {14 const a: Uint8Array[] = [];15 const b: Uint8Array[] = [];16 const aR: Array<(v: Uint8Array) => void> = [];17 const bR: Array<(v: Uint8Array) => void> = [];18 const aRej: Array<(e: Error) => void> = [];19 let closed = false;2021 const ourSession: SyncSession = {22 send(bytes) {23 const r = bR.shift();24 if (r) r(bytes); else b.push(bytes);25 },26 receive(): Promise<Uint8Array> {27 const v = a.shift();28 if (v) return Promise.resolve(v);29 if (closed) return Promise.reject(new Error("WebSocket closed"));30 return new Promise<Uint8Array>((res, rej) => { aR.push(res); aRej.push(rej); });31 },32 async close() { },33 remoteStaticPublicKey: new Uint8Array(32),34 };3536 const peer: FakePeer = {37 async send(msg: SyncMessage) {38 const bytes = new TextEncoder().encode(JSON.stringify(msg));39 const r = aR.shift();40 if (r) r(bytes); else a.push(bytes);41 },42 async receive(): Promise<SyncMessage> {43 const v = b.shift();44 const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));45 return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;46 },47 4849505152 async closeInitiatorReceive() {53 if (closed) return;54 closed = true;55 while (aRej.length > 0) aRej.shift()!(new Error("WebSocket closed"));56 },57 };58 return [ourSession, peer];59}6061function symmetricSessionPair(): [SyncSession, SyncSession] {62 const queues: [Uint8Array[], Uint8Array[]] = [[], []];63 const readers: [64 Array<(value: Uint8Array) => void>,65 Array<(value: Uint8Array) => void>,66 ] = [[], []];67 const make = (side: 0 | 1): SyncSession => ({68 send(bytes) {69 const other = side === 0 ? 1 : 0;70 const reader = readers[other].shift();71 if (reader) reader(bytes);72 else queues[other].push(bytes);73 },74 receive() {75 const queued = queues[side].shift();76 if (queued) return Promise.resolve(queued);77 return new Promise<Uint8Array>((resolve) => readers[side].push(resolve));78 },79 async close() {},80 remoteStaticPublicKey: new Uint8Array(32).fill(side === 0 ? 0x22 : 0x11),81 });82 return [make(0), make(1)];83}8485interface FakePeer {86 send(msg: SyncMessage): Promise<void>;87 receive(): Promise<SyncMessage>;88 89 closeInitiatorReceive(): Promise<void>;90}9192function makeVaultAdapter(opts: {93 vaultId?: string;94 deviceId?: string;95 localVersion?: number;96 entries?: Record<string, number>;97 tombstones?: Record<string, number>;98 ciphertext?: Record<string, string>;99}): SyncVaultAdapter & {100 appliedEntries: Record<string, string>[];101 appliedTombstones: Record<string, number>[];102} {103 const appliedEntries: Record<string, string>[] = [];104 const appliedTombstones: Record<string, number>[] = [];105 return {106 async computeVaultId() { return opts.vaultId ?? "vault-abc"; },107 async getOurDeviceId() { return opts.deviceId ?? "device-init"; },108 109 110 async getLocalVersion() { return opts.localVersion ?? 5; },111 async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },112 async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },113 async getEncryptedEntries(ids) {114 const out: Record<string, string> = {};115 const cipher = opts.ciphertext ?? {};116 for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];117 return out;118 },119 async applyEntries(entries) { appliedEntries.push(entries); },120 async applyTombstones(t) { appliedTombstones.push(t); },121 appliedEntries,122 appliedTombstones,123 };124}125126127128129130131describe("runSyncInitiator — bounded SYNC_REQUEST (PB-26-111)", () => {132 it("rejects an oversized id list before any per-entry work", async () => {133 const [session, peer] = inMemoryPair();134 const vault = makeVaultAdapter({ vaultId: "vault-abc", entries: { "id-1": 1 } });135 let encryptedFor: string[] | null = null;136 const spied = Object.assign({}, vault, {137 async getEncryptedEntries(ids: string[]) {138 encryptedFor = ids;139 return {};140 },141 });142143 const initPromise = runSyncInitiator(session, spied);144 void (async () => {145 await peer.receive(); 146 await peer.send({147 type: "SYNC_HELLO",148 vaultId: "vault-abc",149 deviceId: "p",150 version: 0,151 deviceTime: Date.now(),152 protocolVersion: 2,153 });154 await peer.receive(); 155 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });156 await peer.receive(); 157 158 await peer.send({159 type: "SYNC_REQUEST",160 entryIds: Array.from(161 { length: SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT + 1 },162 (_, i) => `id-${i}`,163 ),164 });165 })();166167 const outcome = await initPromise;168 expect(outcome.status).toBe("errored");169 expect(outcome.error).toMatch(/entry count exceeds limit/);170 171 expect(encryptedFor).toBeNull();172 });173174 it("still serves a request at the limit", async () => {175 const [session, peer] = inMemoryPair();176 const ids = Array.from({ length: 3 }, (_, i) => `id-${i}`);177 const vault = makeVaultAdapter({178 vaultId: "vault-abc",179 entries: { "id-0": 1, "id-1": 1, "id-2": 1 },180 ciphertext: { "id-0": "AAAA", "id-1": "BBBB", "id-2": "CCCC" },181 });182183 const initPromise = runSyncInitiator(session, vault);184 void (async () => {185 await peer.receive(); 186 await peer.send({187 type: "SYNC_HELLO",188 vaultId: "vault-abc",189 deviceId: "p",190 version: 0,191 deviceTime: Date.now(),192 protocolVersion: 2,193 });194 await peer.receive(); 195 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });196 await peer.receive(); 197 await peer.send({ type: "SYNC_REQUEST", entryIds: ids });198 await peer.receive(); 199 await peer.receive(); 200 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });201 await peer.receive(); 202 await peer.send({ type: "SYNC_DONE" });203 })();204205 const outcome = await initPromise;206 expect(outcome.status).toBe("completed");207 expect(outcome.entriesSent).toBe(3);208 });209});210211describe("runSyncInitiator — SYNC_MANIFEST limits (PB-26-312)", () => {212 it("rejects oversized entry and tombstone manifests before diffing", async () => {213 const [session, peer] = inMemoryPair();214 const vault = makeVaultAdapter({ vaultId: "vault-abc" });215 const initPromise = runSyncInitiator(session, vault);216 void (async () => {217 await peer.receive();218 await peer.send({219 type: "SYNC_HELLO",220 vaultId: "vault-abc",221 deviceId: "p",222 version: 0,223 deviceTime: Date.now(),224 protocolVersion: 2,225 });226 await peer.receive();227 const entries = Object.fromEntries(228 Array.from({ length: SYNC_LIMITS.MAX_ENTRY_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),229 );230 await peer.send({ type: "SYNC_MANIFEST", entries, tombstones: {} });231 })();232 await expect(initPromise).resolves.toMatchObject({233 status: "errored",234 error: expect.stringMatching(/manifest entry count exceeds limit/),235 });236237 const [tombstoneSession, tombstonePeer] = inMemoryPair();238 const tombstonePromise = runSyncInitiator(tombstoneSession, vault);239 void (async () => {240 await tombstonePeer.receive();241 await tombstonePeer.send({242 type: "SYNC_HELLO",243 vaultId: "vault-abc",244 deviceId: "p",245 version: 0,246 deviceTime: Date.now(),247 protocolVersion: 2,248 });249 await tombstonePeer.receive();250 const tombstones = Object.fromEntries(251 Array.from({ length: SYNC_LIMITS.MAX_TOMBSTONE_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),252 );253 await tombstonePeer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones });254 })();255 await expect(tombstonePromise).resolves.toMatchObject({256 status: "errored",257 error: expect.stringMatching(/manifest tombstone count exceeds limit/),258 });259 });260});261262describe("runSyncInitiator — HELLO fields and version optimisation", () => {263 it("sends HELLO with version and deviceTime", async () => {264 const [session, peer] = inMemoryPair();265 const vault = makeVaultAdapter({ localVersion: 7 });266267 const initPromise = runSyncInitiator(session, vault);268269 const sentHello = await peer.receive();270 expect(sentHello.type).toBe("SYNC_HELLO");271 if (sentHello.type === "SYNC_HELLO") {272 expect(sentHello.version).toBe(7);273 expect(typeof sentHello.deviceTime).toBe("number");274 expect(sentHello.deviceTime).toBeGreaterThan(0);275 }276 277 await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now(), protocolVersion: 2 });278 await peer.receive(); 279 await initPromise;280 });281282 it("skips manifest exchange and returns completed when versions are equal", async () => {283 const [session, peer] = inMemoryPair();284 const vault = makeVaultAdapter({ localVersion: 10 });285286 const initPromise = runSyncInitiator(session, vault);287288 await peer.receive(); 289 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now(), protocolVersion: 2 });290291 292 const done = await peer.receive();293 expect(done.type).toBe("SYNC_DONE");294295 296 await peer.send({ type: "SYNC_DONE" });297298 const outcome = await initPromise;299 expect(outcome.status).toBe("completed");300 expect(outcome.entriesReceived).toBe(0);301 expect(outcome.entriesSent).toBe(0);302 });303304 it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {305 const [dialerSession, waiterSession] = symmetricSessionPair();306 const dialerManifest = {307 body: {308 manifestVersion: 3,309 issuerDeviceId: "dialer",310 issuedAt: 1,311 devices: [],312 revoked: [],313 },314 signatureHex: "11".repeat(64),315 } satisfies SignedDeviceManifest;316 const waiterManifest = {317 body: {318 manifestVersion: 3,319 issuerDeviceId: "waiter",320 issuedAt: 1,321 devices: [],322 revoked: [],323 },324 signatureHex: "22".repeat(64),325 } satisfies SignedDeviceManifest;326 const dialerApplied: SignedDeviceManifest[] = [];327 const waiterApplied: SignedDeviceManifest[] = [];328 const dialer: SyncVaultAdapter = {329 ...makeVaultAdapter({ deviceId: "dialer", localVersion: 7 }),330 async buildOutgoingDeviceManifest() { return dialerManifest; },331 async applyIncomingDeviceManifest(manifest) { dialerApplied.push(manifest); },332 };333 const waiter: SyncVaultAdapter = {334 ...makeVaultAdapter({ deviceId: "waiter", localVersion: 7 }),335 async buildOutgoingDeviceManifest() { return waiterManifest; },336 async applyIncomingDeviceManifest(manifest) { waiterApplied.push(manifest); },337 };338339 const [dialerOutcome, waiterOutcome] = await Promise.all([340 runSyncInitiator(dialerSession, dialer),341 runSyncResponder(waiterSession, waiter),342 ]);343344 expect(dialerOutcome.status).toBe("completed");345 expect(waiterOutcome.status).toBe("completed");346 expect(dialerApplied).toEqual([waiterManifest]);347 expect(waiterApplied).toEqual([dialerManifest]);348 });349350 it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {351 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);352 const [session, peer] = inMemoryPair();353 const vault = makeVaultAdapter({ localVersion: 5 });354355 const initPromise = runSyncInitiator(session, vault);356357 await peer.receive(); 358 359 await peer.send({360 type: "SYNC_HELLO",361 vaultId: "vault-abc",362 deviceId: "p",363 version: 3, 364 deviceTime: Date.now() - 200_000,365 protocolVersion: 2,366 });367 368 await new Promise((r) => setTimeout(r, 0));369 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));370371 372 await peer.receive(); 373 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });374 await peer.receive(); 375 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });376 await peer.receive(); 377 await peer.receive(); 378 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });379 await peer.receive(); 380 await peer.send({ type: "SYNC_DONE" });381382 const outcome = await initPromise;383 expect(outcome.status).toBe("completed");384 warnSpy.mockRestore();385 });386});387388describe("runSyncInitiator", () => {389 it("syncs two mesh peers directly with their introducer absent", async () => {390 const [dialerSession, waiterSession] = symmetricSessionPair();391 const dialer = makeVaultAdapter({392 deviceId: "laptop-a",393 localVersion: 10,394 entries: { a: 10 },395 ciphertext: { a: "cipher-a" },396 });397 const waiter = makeVaultAdapter({398 deviceId: "laptop-c",399 localVersion: 11,400 entries: { c: 11 },401 ciphertext: { c: "cipher-c" },402 });403404 const [dialerOutcome, waiterOutcome] = await Promise.all([405 runSyncInitiator(dialerSession, dialer),406 runSyncResponder(waiterSession, waiter),407 ]);408409 expect(dialerOutcome.status).toBe("completed");410 expect(waiterOutcome.status).toBe("completed");411 expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);412 expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);413 });414415 it("reports the privacy-safe protocol stage when a device stops answering", async () => {416 vi.useFakeTimers();417 try {418 const [session, peer] = inMemoryPair();419 const vault = makeVaultAdapter({});420 const initPromise = runSyncInitiator(session, vault);421422 const hello = await peer.receive();423 expect(hello.type).toBe("SYNC_HELLO");424425 await vi.advanceTimersByTimeAsync(30_000);426 await expect(initPromise).resolves.toMatchObject({427 status: "timeout",428 error: "sync_timeout:waiting_for_device_hello",429 });430 } finally {431 vi.useRealTimers();432 }433 });434435 it("completes a full sync with bi-directional updates", async () => {436 const [session, peer] = inMemoryPair();437 const vault = makeVaultAdapter({438 entries: { a: 100, b: 50 }, 439 tombstones: { x: 70 },440 ciphertext: { a: "ct-a", b: "ct-b" },441 });442443 const initPromise = runSyncInitiator(session, vault);444445 const peerProtocol = (async () => {446 const hello = await peer.receive();447 expect(hello.type).toBe("SYNC_HELLO");448 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now(), protocolVersion: 2 });449450 const ourManifest = await peer.receive();451 expect(ourManifest.type).toBe("SYNC_MANIFEST");452 await peer.send({453 type: "SYNC_MANIFEST",454 entries: { a: 80, b: 90, c: 200 }, 455 tombstones: { x: 60, y: 99 }, 456 });457458 const ourRequest = await peer.receive();459 expect(ourRequest.type).toBe("SYNC_REQUEST");460 461 if (ourRequest.type === "SYNC_REQUEST") {462 expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);463 }464 465 await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });466467 const ourEntries = await peer.receive();468 expect(ourEntries.type).toBe("SYNC_ENTRIES");469 if (ourEntries.type === "SYNC_ENTRIES") {470 expect(ourEntries.entries).toEqual({ a: "ct-a" });471 }472 await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });473474 const ourTombs = await peer.receive();475 expect(ourTombs.type).toBe("SYNC_TOMBSTONES");476 if (ourTombs.type === "SYNC_TOMBSTONES") {477 478 expect(ourTombs.tombstones).toEqual({ x: 70 });479 }480 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });481482 const ourDone = await peer.receive();483 expect(ourDone.type).toBe("SYNC_DONE");484 await peer.send({ type: "SYNC_DONE" });485 })();486487 const outcome = await initPromise;488 await peerProtocol;489490 expect(outcome.status).toBe("completed");491 expect(outcome.entriesSent).toBe(1);492 expect(outcome.entriesReceived).toBe(2);493 expect(outcome.tombstonesSent).toBe(1);494 expect(outcome.tombstonesReceived).toBe(1);495 expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);496 expect(vault.appliedTombstones).toEqual([{ y: 99 }]);497 });498499 it("aborts with vault_mismatch on a peer with a different vault_id", async () => {500 const [session, peer] = inMemoryPair();501 const vault = makeVaultAdapter({ vaultId: "vault-A" });502503 const initPromise = runSyncInitiator(session, vault);504 const peerProtocol = (async () => {505 const hello = await peer.receive();506 expect(hello.type).toBe("SYNC_HELLO");507 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now(), protocolVersion: 2 });508 509 const err = await peer.receive();510 expect(err.type).toBe("SYNC_ERROR");511 })();512513 const outcome = await initPromise;514 await peerProtocol;515 expect(outcome.status).toBe("vault_mismatch");516 });517518 it("rejects a stale peer with PEER_PROTOCOL_OUTDATED", async () => {519 const [session, peer] = inMemoryPair();520 const vault = makeVaultAdapter({});521522 const initPromise = runSyncInitiator(session, vault);523 const peerProtocol = (async () => {524 await peer.receive(); 525 await peer.send({526 type: "SYNC_HELLO",527 vaultId: "vault-abc",528 deviceId: "p",529 version: 3,530 deviceTime: Date.now(),531 });532 const err = await peer.receive();533 expect(err.type).toBe("SYNC_ERROR");534 })();535536 const outcome = await initPromise;537 await peerProtocol;538 expect(outcome.status).toBe("errored");539 expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");540 });541542 it("exchanges device manifests when both adapters opt in", async () => {543 const [session, peer] = inMemoryPair();544 const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");545 const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");546547 const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);548 const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);549 const applied: SignedDeviceManifest[] = [];550551 const base = makeVaultAdapter({});552 const vault: SyncVaultAdapter = {553 ...base,554 async buildOutgoingDeviceManifest() { return outgoing; },555 async applyIncomingDeviceManifest(m, authenticatedKey) {556 expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);557 applied.push(m);558 },559 };560561 const initPromise = runSyncInitiator(session, vault);562 const peerProtocol = (async () => {563 await peer.receive(); 564 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });565 await peer.receive(); 566 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });567 await peer.receive(); 568 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });569 await peer.receive(); 570 await peer.receive(); 571 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });572 const ourManifest = await peer.receive();573 expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");574 if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {575 expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");576 }577 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });578 await peer.receive(); 579 await peer.send({ type: "SYNC_DONE" });580 })();581582 const outcome = await initPromise;583 await peerProtocol;584 expect(outcome.status).toBe("completed");585 expect(applied).toHaveLength(1);586 expect(applied[0].body.issuerDeviceId).toBe("them");587 });588589 it("uses null to represent an absent device manifest", async () => {590 const [session, peer] = inMemoryPair();591 const applyIncoming = vi.fn();592 const vault: SyncVaultAdapter = {593 ...makeVaultAdapter({}),594 async buildOutgoingDeviceManifest() { return null; },595 applyIncomingDeviceManifest: applyIncoming,596 };597598 const initPromise = runSyncInitiator(session, vault);599 const peerProtocol = (async () => {600 await peer.receive();601 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });602 await peer.receive();603 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });604 await peer.receive();605 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });606 await peer.receive();607 await peer.receive();608 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });609 const manifest = await peer.receive();610 expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });611 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });612 await peer.receive();613 await peer.send({ type: "SYNC_DONE" });614 })();615616 const outcome = await initPromise;617 await peerProtocol;618 expect(outcome.status).toBe("completed");619 expect(applyIncoming).not.toHaveBeenCalled();620 });621622 it("returns errored when peer sends a wrong frame", async () => {623 const [session, peer] = inMemoryPair();624 const vault = makeVaultAdapter({ vaultId: "v" });625626 const initPromise = runSyncInitiator(session, vault);627 void (async () => {628 await peer.receive(); 629 await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });630 await peer.receive(); 631 await peer.send({ type: "SYNC_DONE" }); 632 })();633634 const outcome = await initPromise;635 expect(outcome.status).toBe("errored");636 expect(outcome.error).toMatch(/SYNC_MANIFEST/);637 });638639 it("reports completed when the peer closes the socket while we wait for its closing SYNC_DONE", async () => {640 641 642 643 644 645 646 const [session, peer] = inMemoryPair();647 const vault = makeVaultAdapter({ localVersion: 5 });648649 const initPromise = runSyncInitiator(session, vault);650 void (async () => {651 await peer.receive(); 652 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now(), protocolVersion: 2 });653 await peer.receive(); 654 655 await peer.closeInitiatorReceive();656 })();657658 const outcome = await initPromise;659 expect(outcome.status).toBe("completed");660 });661});