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 });153 await peer.receive(); 154 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });155 await peer.receive(); 156 157 await peer.send({158 type: "SYNC_REQUEST",159 entryIds: Array.from(160 { length: SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT + 1 },161 (_, i) => `id-${i}`,162 ),163 });164 })();165166 const outcome = await initPromise;167 expect(outcome.status).toBe("errored");168 expect(outcome.error).toMatch(/entry count exceeds limit/);169 170 expect(encryptedFor).toBeNull();171 });172173 it("still serves a request at the limit", async () => {174 const [session, peer] = inMemoryPair();175 const ids = Array.from({ length: 3 }, (_, i) => `id-${i}`);176 const vault = makeVaultAdapter({177 vaultId: "vault-abc",178 entries: { "id-0": 1, "id-1": 1, "id-2": 1 },179 ciphertext: { "id-0": "AAAA", "id-1": "BBBB", "id-2": "CCCC" },180 });181182 const initPromise = runSyncInitiator(session, vault);183 void (async () => {184 await peer.receive(); 185 await peer.send({186 type: "SYNC_HELLO",187 vaultId: "vault-abc",188 deviceId: "p",189 version: 0,190 deviceTime: Date.now(),191 });192 await peer.receive(); 193 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });194 await peer.receive(); 195 await peer.send({ type: "SYNC_REQUEST", entryIds: ids });196 await peer.receive(); 197 await peer.receive(); 198 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });199 await peer.receive(); 200 await peer.send({ type: "SYNC_DONE" });201 })();202203 const outcome = await initPromise;204 expect(outcome.status).toBe("completed");205 expect(outcome.entriesSent).toBe(3);206 });207});208209describe("runSyncInitiator — SYNC_MANIFEST limits (PB-26-312)", () => {210 it("rejects oversized entry and tombstone manifests before diffing", async () => {211 const [session, peer] = inMemoryPair();212 const vault = makeVaultAdapter({ vaultId: "vault-abc" });213 const initPromise = runSyncInitiator(session, vault);214 void (async () => {215 await peer.receive();216 await peer.send({217 type: "SYNC_HELLO",218 vaultId: "vault-abc",219 deviceId: "p",220 version: 0,221 deviceTime: Date.now(),222 });223 await peer.receive();224 const entries = Object.fromEntries(225 Array.from({ length: SYNC_LIMITS.MAX_ENTRY_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),226 );227 await peer.send({ type: "SYNC_MANIFEST", entries, tombstones: {} });228 })();229 await expect(initPromise).resolves.toMatchObject({230 status: "errored",231 error: expect.stringMatching(/manifest entry count exceeds limit/),232 });233234 const [tombstoneSession, tombstonePeer] = inMemoryPair();235 const tombstonePromise = runSyncInitiator(tombstoneSession, vault);236 void (async () => {237 await tombstonePeer.receive();238 await tombstonePeer.send({239 type: "SYNC_HELLO",240 vaultId: "vault-abc",241 deviceId: "p",242 version: 0,243 deviceTime: Date.now(),244 });245 await tombstonePeer.receive();246 const tombstones = Object.fromEntries(247 Array.from({ length: SYNC_LIMITS.MAX_TOMBSTONE_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),248 );249 await tombstonePeer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones });250 })();251 await expect(tombstonePromise).resolves.toMatchObject({252 status: "errored",253 error: expect.stringMatching(/manifest tombstone count exceeds limit/),254 });255 });256});257258describe("runSyncInitiator — HELLO fields and version optimisation", () => {259 it("sends HELLO with version and deviceTime", async () => {260 const [session, peer] = inMemoryPair();261 const vault = makeVaultAdapter({ localVersion: 7 });262263 const initPromise = runSyncInitiator(session, vault);264265 const sentHello = await peer.receive();266 expect(sentHello.type).toBe("SYNC_HELLO");267 if (sentHello.type === "SYNC_HELLO") {268 expect(sentHello.version).toBe(7);269 expect(typeof sentHello.deviceTime).toBe("number");270 expect(sentHello.deviceTime).toBeGreaterThan(0);271 }272 273 await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now() });274 await peer.receive(); 275 await initPromise;276 });277278 it("skips manifest exchange and returns completed when versions are equal", async () => {279 const [session, peer] = inMemoryPair();280 const vault = makeVaultAdapter({ localVersion: 10 });281282 const initPromise = runSyncInitiator(session, vault);283284 await peer.receive(); 285 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now() });286287 288 const done = await peer.receive();289 expect(done.type).toBe("SYNC_DONE");290291 292 await peer.send({ type: "SYNC_DONE" });293294 const outcome = await initPromise;295 expect(outcome.status).toBe("completed");296 expect(outcome.entriesReceived).toBe(0);297 expect(outcome.entriesSent).toBe(0);298 });299300 it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {301 const [dialerSession, waiterSession] = symmetricSessionPair();302 const dialerManifest = {303 body: {304 manifestVersion: 3,305 issuerDeviceId: "dialer",306 issuedAt: 1,307 devices: [],308 revoked: [],309 },310 signatureHex: "11".repeat(64),311 } satisfies SignedDeviceManifest;312 const waiterManifest = {313 body: {314 manifestVersion: 3,315 issuerDeviceId: "waiter",316 issuedAt: 1,317 devices: [],318 revoked: [],319 },320 signatureHex: "22".repeat(64),321 } satisfies SignedDeviceManifest;322 const dialerApplied: SignedDeviceManifest[] = [];323 const waiterApplied: SignedDeviceManifest[] = [];324 const dialer: SyncVaultAdapter = {325 ...makeVaultAdapter({ deviceId: "dialer", localVersion: 7 }),326 async buildOutgoingDeviceManifest() { return dialerManifest; },327 async applyIncomingDeviceManifest(manifest) { dialerApplied.push(manifest); },328 };329 const waiter: SyncVaultAdapter = {330 ...makeVaultAdapter({ deviceId: "waiter", localVersion: 7 }),331 async buildOutgoingDeviceManifest() { return waiterManifest; },332 async applyIncomingDeviceManifest(manifest) { waiterApplied.push(manifest); },333 };334335 const [dialerOutcome, waiterOutcome] = await Promise.all([336 runSyncInitiator(dialerSession, dialer),337 runSyncResponder(waiterSession, waiter),338 ]);339340 expect(dialerOutcome.status).toBe("completed");341 expect(waiterOutcome.status).toBe("completed");342 expect(dialerApplied).toEqual([waiterManifest]);343 expect(waiterApplied).toEqual([dialerManifest]);344 });345346 it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {347 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);348 const [session, peer] = inMemoryPair();349 const vault = makeVaultAdapter({ localVersion: 5 });350351 const initPromise = runSyncInitiator(session, vault);352353 await peer.receive(); 354 355 await peer.send({356 type: "SYNC_HELLO",357 vaultId: "vault-abc",358 deviceId: "p",359 version: 3, 360 deviceTime: Date.now() - 200_000,361 });362 363 await new Promise((r) => setTimeout(r, 0));364 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));365366 367 await peer.receive(); 368 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });369 await peer.receive(); 370 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });371 await peer.receive(); 372 await peer.receive(); 373 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });374 await peer.receive(); 375 await peer.send({ type: "SYNC_DONE" });376377 const outcome = await initPromise;378 expect(outcome.status).toBe("completed");379 warnSpy.mockRestore();380 });381});382383describe("runSyncInitiator", () => {384 it("syncs two mesh peers directly with their introducer absent", async () => {385 const [dialerSession, waiterSession] = symmetricSessionPair();386 const dialer = makeVaultAdapter({387 deviceId: "laptop-a",388 localVersion: 10,389 entries: { a: 10 },390 ciphertext: { a: "cipher-a" },391 });392 const waiter = makeVaultAdapter({393 deviceId: "laptop-c",394 localVersion: 11,395 entries: { c: 11 },396 ciphertext: { c: "cipher-c" },397 });398399 const [dialerOutcome, waiterOutcome] = await Promise.all([400 runSyncInitiator(dialerSession, dialer),401 runSyncResponder(waiterSession, waiter),402 ]);403404 expect(dialerOutcome.status).toBe("completed");405 expect(waiterOutcome.status).toBe("completed");406 expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);407 expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);408 });409410 it("reports the privacy-safe protocol stage when a device stops answering", async () => {411 vi.useFakeTimers();412 try {413 const [session, peer] = inMemoryPair();414 const vault = makeVaultAdapter({});415 const initPromise = runSyncInitiator(session, vault);416417 const hello = await peer.receive();418 expect(hello.type).toBe("SYNC_HELLO");419420 await vi.advanceTimersByTimeAsync(30_000);421 await expect(initPromise).resolves.toMatchObject({422 status: "timeout",423 error: "sync_timeout:waiting_for_device_hello",424 });425 } finally {426 vi.useRealTimers();427 }428 });429430 it("completes a full sync with bi-directional updates", async () => {431 const [session, peer] = inMemoryPair();432 const vault = makeVaultAdapter({433 entries: { a: 100, b: 50 }, 434 tombstones: { x: 70 },435 ciphertext: { a: "ct-a", b: "ct-b" },436 });437438 const initPromise = runSyncInitiator(session, vault);439440 const peerProtocol = (async () => {441 const hello = await peer.receive();442 expect(hello.type).toBe("SYNC_HELLO");443 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now() });444445 const ourManifest = await peer.receive();446 expect(ourManifest.type).toBe("SYNC_MANIFEST");447 await peer.send({448 type: "SYNC_MANIFEST",449 entries: { a: 80, b: 90, c: 200 }, 450 tombstones: { x: 60, y: 99 }, 451 });452453 const ourRequest = await peer.receive();454 expect(ourRequest.type).toBe("SYNC_REQUEST");455 456 if (ourRequest.type === "SYNC_REQUEST") {457 expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);458 }459 460 await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });461462 const ourEntries = await peer.receive();463 expect(ourEntries.type).toBe("SYNC_ENTRIES");464 if (ourEntries.type === "SYNC_ENTRIES") {465 expect(ourEntries.entries).toEqual({ a: "ct-a" });466 }467 await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });468469 const ourTombs = await peer.receive();470 expect(ourTombs.type).toBe("SYNC_TOMBSTONES");471 if (ourTombs.type === "SYNC_TOMBSTONES") {472 473 expect(ourTombs.tombstones).toEqual({ x: 70 });474 }475 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });476477 const ourDone = await peer.receive();478 expect(ourDone.type).toBe("SYNC_DONE");479 await peer.send({ type: "SYNC_DONE" });480 })();481482 const outcome = await initPromise;483 await peerProtocol;484485 expect(outcome.status).toBe("completed");486 expect(outcome.entriesSent).toBe(1);487 expect(outcome.entriesReceived).toBe(2);488 expect(outcome.tombstonesSent).toBe(1);489 expect(outcome.tombstonesReceived).toBe(1);490 expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);491 expect(vault.appliedTombstones).toEqual([{ y: 99 }]);492 });493494 it("aborts with vault_mismatch on a peer with a different vault_id", async () => {495 const [session, peer] = inMemoryPair();496 const vault = makeVaultAdapter({ vaultId: "vault-A" });497498 const initPromise = runSyncInitiator(session, vault);499 const peerProtocol = (async () => {500 const hello = await peer.receive();501 expect(hello.type).toBe("SYNC_HELLO");502 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now() });503 504 const err = await peer.receive();505 expect(err.type).toBe("SYNC_ERROR");506 })();507508 const outcome = await initPromise;509 await peerProtocol;510 expect(outcome.status).toBe("vault_mismatch");511 });512513 it("exchanges device manifests when both adapters opt in", async () => {514 const [session, peer] = inMemoryPair();515 const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");516 const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");517518 const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);519 const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);520 const applied: SignedDeviceManifest[] = [];521522 const base = makeVaultAdapter({});523 const vault: SyncVaultAdapter = {524 ...base,525 async buildOutgoingDeviceManifest() { return outgoing; },526 async applyIncomingDeviceManifest(m, authenticatedKey) {527 expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);528 applied.push(m);529 },530 };531532 const initPromise = runSyncInitiator(session, vault);533 const peerProtocol = (async () => {534 await peer.receive(); 535 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });536 await peer.receive(); 537 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });538 await peer.receive(); 539 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });540 await peer.receive(); 541 await peer.receive(); 542 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });543 const ourManifest = await peer.receive();544 expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");545 if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {546 expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");547 }548 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });549 await peer.receive(); 550 await peer.send({ type: "SYNC_DONE" });551 })();552553 const outcome = await initPromise;554 await peerProtocol;555 expect(outcome.status).toBe("completed");556 expect(applied).toHaveLength(1);557 expect(applied[0].body.issuerDeviceId).toBe("them");558 });559560 it("uses null to represent an absent device manifest", async () => {561 const [session, peer] = inMemoryPair();562 const applyIncoming = vi.fn();563 const vault: SyncVaultAdapter = {564 ...makeVaultAdapter({}),565 async buildOutgoingDeviceManifest() { return null; },566 applyIncomingDeviceManifest: applyIncoming,567 };568569 const initPromise = runSyncInitiator(session, vault);570 const peerProtocol = (async () => {571 await peer.receive();572 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });573 await peer.receive();574 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });575 await peer.receive();576 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });577 await peer.receive();578 await peer.receive();579 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });580 const manifest = await peer.receive();581 expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });582 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });583 await peer.receive();584 await peer.send({ type: "SYNC_DONE" });585 })();586587 const outcome = await initPromise;588 await peerProtocol;589 expect(outcome.status).toBe("completed");590 expect(applyIncoming).not.toHaveBeenCalled();591 });592593 it("returns errored when peer sends a wrong frame", async () => {594 const [session, peer] = inMemoryPair();595 const vault = makeVaultAdapter({ vaultId: "v" });596597 const initPromise = runSyncInitiator(session, vault);598 void (async () => {599 await peer.receive(); 600 await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });601 await peer.receive(); 602 await peer.send({ type: "SYNC_DONE" }); 603 })();604605 const outcome = await initPromise;606 expect(outcome.status).toBe("errored");607 expect(outcome.error).toMatch(/SYNC_MANIFEST/);608 });609610 it("reports completed when the peer closes the socket while we wait for its closing SYNC_DONE", async () => {611 612 613 614 615 616 617 const [session, peer] = inMemoryPair();618 const vault = makeVaultAdapter({ localVersion: 5 });619620 const initPromise = runSyncInitiator(session, vault);621 void (async () => {622 await peer.receive(); 623 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now() });624 await peer.receive(); 625 626 await peer.closeInitiatorReceive();627 })();628629 const outcome = await initPromise;630 expect(outcome.status).toBe("completed");631 });632});