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 — HELLO fields and version optimisation", () => {210 it("sends HELLO with version and deviceTime", async () => {211 const [session, peer] = inMemoryPair();212 const vault = makeVaultAdapter({ localVersion: 7 });213214 const initPromise = runSyncInitiator(session, vault);215216 const sentHello = await peer.receive();217 expect(sentHello.type).toBe("SYNC_HELLO");218 if (sentHello.type === "SYNC_HELLO") {219 expect(sentHello.version).toBe(7);220 expect(typeof sentHello.deviceTime).toBe("number");221 expect(sentHello.deviceTime).toBeGreaterThan(0);222 }223 224 await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now() });225 await peer.receive(); 226 await initPromise;227 });228229 it("skips manifest exchange and returns completed when versions are equal", async () => {230 const [session, peer] = inMemoryPair();231 const vault = makeVaultAdapter({ localVersion: 10 });232233 const initPromise = runSyncInitiator(session, vault);234235 await peer.receive(); 236 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now() });237238 239 const done = await peer.receive();240 expect(done.type).toBe("SYNC_DONE");241242 243 await peer.send({ type: "SYNC_DONE" });244245 const outcome = await initPromise;246 expect(outcome.status).toBe("completed");247 expect(outcome.entriesReceived).toBe(0);248 expect(outcome.entriesSent).toBe(0);249 });250251 it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {252 const [dialerSession, waiterSession] = symmetricSessionPair();253 const dialerManifest = {254 body: {255 manifestVersion: 3,256 issuerDeviceId: "dialer",257 issuedAt: 1,258 devices: [],259 revoked: [],260 },261 signatureHex: "11".repeat(64),262 } satisfies SignedDeviceManifest;263 const waiterManifest = {264 body: {265 manifestVersion: 3,266 issuerDeviceId: "waiter",267 issuedAt: 1,268 devices: [],269 revoked: [],270 },271 signatureHex: "22".repeat(64),272 } satisfies SignedDeviceManifest;273 const dialerApplied: SignedDeviceManifest[] = [];274 const waiterApplied: SignedDeviceManifest[] = [];275 const dialer: SyncVaultAdapter = {276 ...makeVaultAdapter({ deviceId: "dialer", localVersion: 7 }),277 async buildOutgoingDeviceManifest() { return dialerManifest; },278 async applyIncomingDeviceManifest(manifest) { dialerApplied.push(manifest); },279 };280 const waiter: SyncVaultAdapter = {281 ...makeVaultAdapter({ deviceId: "waiter", localVersion: 7 }),282 async buildOutgoingDeviceManifest() { return waiterManifest; },283 async applyIncomingDeviceManifest(manifest) { waiterApplied.push(manifest); },284 };285286 const [dialerOutcome, waiterOutcome] = await Promise.all([287 runSyncInitiator(dialerSession, dialer),288 runSyncResponder(waiterSession, waiter),289 ]);290291 expect(dialerOutcome.status).toBe("completed");292 expect(waiterOutcome.status).toBe("completed");293 expect(dialerApplied).toEqual([waiterManifest]);294 expect(waiterApplied).toEqual([dialerManifest]);295 });296297 it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {298 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);299 const [session, peer] = inMemoryPair();300 const vault = makeVaultAdapter({ localVersion: 5 });301302 const initPromise = runSyncInitiator(session, vault);303304 await peer.receive(); 305 306 await peer.send({307 type: "SYNC_HELLO",308 vaultId: "vault-abc",309 deviceId: "p",310 version: 3, 311 deviceTime: Date.now() - 200_000,312 });313 314 await new Promise((r) => setTimeout(r, 0));315 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));316317 318 await peer.receive(); 319 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });320 await peer.receive(); 321 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });322 await peer.receive(); 323 await peer.receive(); 324 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });325 await peer.receive(); 326 await peer.send({ type: "SYNC_DONE" });327328 const outcome = await initPromise;329 expect(outcome.status).toBe("completed");330 warnSpy.mockRestore();331 });332});333334describe("runSyncInitiator", () => {335 it("syncs two mesh peers directly with their introducer absent", async () => {336 const [dialerSession, waiterSession] = symmetricSessionPair();337 const dialer = makeVaultAdapter({338 deviceId: "laptop-a",339 localVersion: 10,340 entries: { a: 10 },341 ciphertext: { a: "cipher-a" },342 });343 const waiter = makeVaultAdapter({344 deviceId: "laptop-c",345 localVersion: 11,346 entries: { c: 11 },347 ciphertext: { c: "cipher-c" },348 });349350 const [dialerOutcome, waiterOutcome] = await Promise.all([351 runSyncInitiator(dialerSession, dialer),352 runSyncResponder(waiterSession, waiter),353 ]);354355 expect(dialerOutcome.status).toBe("completed");356 expect(waiterOutcome.status).toBe("completed");357 expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);358 expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);359 });360361 it("reports the privacy-safe protocol stage when a device stops answering", async () => {362 vi.useFakeTimers();363 try {364 const [session, peer] = inMemoryPair();365 const vault = makeVaultAdapter({});366 const initPromise = runSyncInitiator(session, vault);367368 const hello = await peer.receive();369 expect(hello.type).toBe("SYNC_HELLO");370371 await vi.advanceTimersByTimeAsync(30_000);372 await expect(initPromise).resolves.toMatchObject({373 status: "timeout",374 error: "sync_timeout:waiting_for_device_hello",375 });376 } finally {377 vi.useRealTimers();378 }379 });380381 it("completes a full sync with bi-directional updates", async () => {382 const [session, peer] = inMemoryPair();383 const vault = makeVaultAdapter({384 entries: { a: 100, b: 50 }, 385 tombstones: { x: 70 },386 ciphertext: { a: "ct-a", b: "ct-b" },387 });388389 const initPromise = runSyncInitiator(session, vault);390391 const peerProtocol = (async () => {392 const hello = await peer.receive();393 expect(hello.type).toBe("SYNC_HELLO");394 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now() });395396 const ourManifest = await peer.receive();397 expect(ourManifest.type).toBe("SYNC_MANIFEST");398 await peer.send({399 type: "SYNC_MANIFEST",400 entries: { a: 80, b: 90, c: 200 }, 401 tombstones: { x: 60, y: 99 }, 402 });403404 const ourRequest = await peer.receive();405 expect(ourRequest.type).toBe("SYNC_REQUEST");406 407 if (ourRequest.type === "SYNC_REQUEST") {408 expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);409 }410 411 await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });412413 const ourEntries = await peer.receive();414 expect(ourEntries.type).toBe("SYNC_ENTRIES");415 if (ourEntries.type === "SYNC_ENTRIES") {416 expect(ourEntries.entries).toEqual({ a: "ct-a" });417 }418 await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });419420 const ourTombs = await peer.receive();421 expect(ourTombs.type).toBe("SYNC_TOMBSTONES");422 if (ourTombs.type === "SYNC_TOMBSTONES") {423 424 expect(ourTombs.tombstones).toEqual({ x: 70 });425 }426 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });427428 const ourDone = await peer.receive();429 expect(ourDone.type).toBe("SYNC_DONE");430 await peer.send({ type: "SYNC_DONE" });431 })();432433 const outcome = await initPromise;434 await peerProtocol;435436 expect(outcome.status).toBe("completed");437 expect(outcome.entriesSent).toBe(1);438 expect(outcome.entriesReceived).toBe(2);439 expect(outcome.tombstonesSent).toBe(1);440 expect(outcome.tombstonesReceived).toBe(1);441 expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);442 expect(vault.appliedTombstones).toEqual([{ y: 99 }]);443 });444445 it("aborts with vault_mismatch on a peer with a different vault_id", async () => {446 const [session, peer] = inMemoryPair();447 const vault = makeVaultAdapter({ vaultId: "vault-A" });448449 const initPromise = runSyncInitiator(session, vault);450 const peerProtocol = (async () => {451 const hello = await peer.receive();452 expect(hello.type).toBe("SYNC_HELLO");453 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now() });454 455 const err = await peer.receive();456 expect(err.type).toBe("SYNC_ERROR");457 })();458459 const outcome = await initPromise;460 await peerProtocol;461 expect(outcome.status).toBe("vault_mismatch");462 });463464 it("exchanges device manifests when both adapters opt in", async () => {465 const [session, peer] = inMemoryPair();466 const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");467 const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");468469 const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);470 const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);471 const applied: SignedDeviceManifest[] = [];472473 const base = makeVaultAdapter({});474 const vault: SyncVaultAdapter = {475 ...base,476 async buildOutgoingDeviceManifest() { return outgoing; },477 async applyIncomingDeviceManifest(m, authenticatedKey) {478 expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);479 applied.push(m);480 },481 };482483 const initPromise = runSyncInitiator(session, vault);484 const peerProtocol = (async () => {485 await peer.receive(); 486 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });487 await peer.receive(); 488 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });489 await peer.receive(); 490 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });491 await peer.receive(); 492 await peer.receive(); 493 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });494 const ourManifest = await peer.receive();495 expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");496 if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {497 expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");498 }499 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });500 await peer.receive(); 501 await peer.send({ type: "SYNC_DONE" });502 })();503504 const outcome = await initPromise;505 await peerProtocol;506 expect(outcome.status).toBe("completed");507 expect(applied).toHaveLength(1);508 expect(applied[0].body.issuerDeviceId).toBe("them");509 });510511 it("uses null to represent an absent device manifest", async () => {512 const [session, peer] = inMemoryPair();513 const applyIncoming = vi.fn();514 const vault: SyncVaultAdapter = {515 ...makeVaultAdapter({}),516 async buildOutgoingDeviceManifest() { return null; },517 applyIncomingDeviceManifest: applyIncoming,518 };519520 const initPromise = runSyncInitiator(session, vault);521 const peerProtocol = (async () => {522 await peer.receive();523 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });524 await peer.receive();525 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });526 await peer.receive();527 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });528 await peer.receive();529 await peer.receive();530 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });531 const manifest = await peer.receive();532 expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });533 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });534 await peer.receive();535 await peer.send({ type: "SYNC_DONE" });536 })();537538 const outcome = await initPromise;539 await peerProtocol;540 expect(outcome.status).toBe("completed");541 expect(applyIncoming).not.toHaveBeenCalled();542 });543544 it("returns errored when peer sends a wrong frame", async () => {545 const [session, peer] = inMemoryPair();546 const vault = makeVaultAdapter({ vaultId: "v" });547548 const initPromise = runSyncInitiator(session, vault);549 void (async () => {550 await peer.receive(); 551 await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });552 await peer.receive(); 553 await peer.send({ type: "SYNC_DONE" }); 554 })();555556 const outcome = await initPromise;557 expect(outcome.status).toBe("errored");558 expect(outcome.error).toMatch(/SYNC_MANIFEST/);559 });560561 it("reports completed when the peer closes the socket while we wait for its closing SYNC_DONE", async () => {562 563 564 565 566 567 568 const [session, peer] = inMemoryPair();569 const vault = makeVaultAdapter({ localVersion: 5 });570571 const initPromise = runSyncInitiator(session, vault);572 void (async () => {573 await peer.receive(); 574 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now() });575 await peer.receive(); 576 577 await peer.closeInitiatorReceive();578 })();579580 const outcome = await initPromise;581 expect(outcome.status).toBe("completed");582 });583});