1import { describe, expect, it, vi } from "vitest";2import {3 runSyncInitiator,4 runSyncResponder,5 type SyncVaultAdapter,6} from "./sync-coordinator";7import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";8import type { SyncSession } from "../shared/sync-transport";9import { buildSignedDeviceManifest } from "../shared/device-manifest";10import { deriveSyncSigningKeypair } from "../shared/sync-identity";1112function inMemoryPair(): [SyncSession, FakePeer] {13 const a: Uint8Array[] = [];14 const b: Uint8Array[] = [];15 const aR: Array<(v: Uint8Array) => void> = [];16 const bR: Array<(v: Uint8Array) => void> = [];17 const aRej: Array<(e: Error) => void> = [];18 let closed = false;1920 const ourSession: SyncSession = {21 send(bytes) {22 const r = bR.shift();23 if (r) r(bytes); else b.push(bytes);24 },25 receive(): Promise<Uint8Array> {26 const v = a.shift();27 if (v) return Promise.resolve(v);28 if (closed) return Promise.reject(new Error("WebSocket closed"));29 return new Promise<Uint8Array>((res, rej) => { aR.push(res); aRej.push(rej); });30 },31 async close() { },32 remoteStaticPublicKey: new Uint8Array(32),33 };3435 const peer: FakePeer = {36 async send(msg: SyncMessage) {37 const bytes = new TextEncoder().encode(JSON.stringify(msg));38 const r = aR.shift();39 if (r) r(bytes); else a.push(bytes);40 },41 async receive(): Promise<SyncMessage> {42 const v = b.shift();43 const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));44 return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;45 },46 4748495051 async closeInitiatorReceive() {52 if (closed) return;53 closed = true;54 while (aRej.length > 0) aRej.shift()!(new Error("WebSocket closed"));55 },56 };57 return [ourSession, peer];58}5960function symmetricSessionPair(): [SyncSession, SyncSession] {61 const queues: [Uint8Array[], Uint8Array[]] = [[], []];62 const readers: [63 Array<(value: Uint8Array) => void>,64 Array<(value: Uint8Array) => void>,65 ] = [[], []];66 const make = (side: 0 | 1): SyncSession => ({67 send(bytes) {68 const other = side === 0 ? 1 : 0;69 const reader = readers[other].shift();70 if (reader) reader(bytes);71 else queues[other].push(bytes);72 },73 receive() {74 const queued = queues[side].shift();75 if (queued) return Promise.resolve(queued);76 return new Promise<Uint8Array>((resolve) => readers[side].push(resolve));77 },78 async close() {},79 remoteStaticPublicKey: new Uint8Array(32).fill(side === 0 ? 0x22 : 0x11),80 });81 return [make(0), make(1)];82}8384interface FakePeer {85 send(msg: SyncMessage): Promise<void>;86 receive(): Promise<SyncMessage>;87 88 closeInitiatorReceive(): Promise<void>;89}9091function makeVaultAdapter(opts: {92 vaultId?: string;93 deviceId?: string;94 localVersion?: number;95 entries?: Record<string, number>;96 tombstones?: Record<string, number>;97 ciphertext?: Record<string, string>;98}): SyncVaultAdapter & {99 appliedEntries: Record<string, string>[];100 appliedTombstones: Record<string, number>[];101} {102 const appliedEntries: Record<string, string>[] = [];103 const appliedTombstones: Record<string, number>[] = [];104 return {105 async computeVaultId() { return opts.vaultId ?? "vault-abc"; },106 async getOurDeviceId() { return opts.deviceId ?? "device-init"; },107 108 109 async getLocalVersion() { return opts.localVersion ?? 5; },110 async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },111 async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },112 async getEncryptedEntries(ids) {113 const out: Record<string, string> = {};114 const cipher = opts.ciphertext ?? {};115 for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];116 return out;117 },118 async applyEntries(entries) { appliedEntries.push(entries); },119 async applyTombstones(t) { appliedTombstones.push(t); },120 appliedEntries,121 appliedTombstones,122 };123}124125describe("runSyncInitiator — HELLO fields and version optimisation", () => {126 it("sends HELLO with version and deviceTime", async () => {127 const [session, peer] = inMemoryPair();128 const vault = makeVaultAdapter({ localVersion: 7 });129130 const initPromise = runSyncInitiator(session, vault);131132 const sentHello = await peer.receive();133 expect(sentHello.type).toBe("SYNC_HELLO");134 if (sentHello.type === "SYNC_HELLO") {135 expect(sentHello.version).toBe(7);136 expect(typeof sentHello.deviceTime).toBe("number");137 expect(sentHello.deviceTime).toBeGreaterThan(0);138 }139 140 await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now() });141 await peer.receive(); 142 await initPromise;143 });144145 it("skips manifest exchange and returns completed when versions are equal", async () => {146 const [session, peer] = inMemoryPair();147 const vault = makeVaultAdapter({ localVersion: 10 });148149 const initPromise = runSyncInitiator(session, vault);150151 await peer.receive(); 152 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now() });153154 155 const done = await peer.receive();156 expect(done.type).toBe("SYNC_DONE");157158 159 await peer.send({ type: "SYNC_DONE" });160161 const outcome = await initPromise;162 expect(outcome.status).toBe("completed");163 expect(outcome.entriesReceived).toBe(0);164 expect(outcome.entriesSent).toBe(0);165 });166167 it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {168 const [dialerSession, waiterSession] = symmetricSessionPair();169 const dialerManifest = {170 body: {171 manifestVersion: 3,172 issuerDeviceId: "dialer",173 issuedAt: 1,174 devices: [],175 revoked: [],176 },177 signatureHex: "11".repeat(64),178 } satisfies SignedDeviceManifest;179 const waiterManifest = {180 body: {181 manifestVersion: 3,182 issuerDeviceId: "waiter",183 issuedAt: 1,184 devices: [],185 revoked: [],186 },187 signatureHex: "22".repeat(64),188 } satisfies SignedDeviceManifest;189 const dialerApplied: SignedDeviceManifest[] = [];190 const waiterApplied: SignedDeviceManifest[] = [];191 const dialer: SyncVaultAdapter = {192 ...makeVaultAdapter({ deviceId: "dialer", localVersion: 7 }),193 async buildOutgoingDeviceManifest() { return dialerManifest; },194 async applyIncomingDeviceManifest(manifest) { dialerApplied.push(manifest); },195 };196 const waiter: SyncVaultAdapter = {197 ...makeVaultAdapter({ deviceId: "waiter", localVersion: 7 }),198 async buildOutgoingDeviceManifest() { return waiterManifest; },199 async applyIncomingDeviceManifest(manifest) { waiterApplied.push(manifest); },200 };201202 const [dialerOutcome, waiterOutcome] = await Promise.all([203 runSyncInitiator(dialerSession, dialer),204 runSyncResponder(waiterSession, waiter),205 ]);206207 expect(dialerOutcome.status).toBe("completed");208 expect(waiterOutcome.status).toBe("completed");209 expect(dialerApplied).toEqual([waiterManifest]);210 expect(waiterApplied).toEqual([dialerManifest]);211 });212213 it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {214 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);215 const [session, peer] = inMemoryPair();216 const vault = makeVaultAdapter({ localVersion: 5 });217218 const initPromise = runSyncInitiator(session, vault);219220 await peer.receive(); 221 222 await peer.send({223 type: "SYNC_HELLO",224 vaultId: "vault-abc",225 deviceId: "p",226 version: 3, 227 deviceTime: Date.now() - 200_000,228 });229 230 await new Promise((r) => setTimeout(r, 0));231 expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));232233 234 await peer.receive(); 235 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });236 await peer.receive(); 237 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });238 await peer.receive(); 239 await peer.receive(); 240 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });241 await peer.receive(); 242 await peer.send({ type: "SYNC_DONE" });243244 const outcome = await initPromise;245 expect(outcome.status).toBe("completed");246 warnSpy.mockRestore();247 });248});249250describe("runSyncInitiator", () => {251 it("syncs two mesh peers directly with their introducer absent", async () => {252 const [dialerSession, waiterSession] = symmetricSessionPair();253 const dialer = makeVaultAdapter({254 deviceId: "laptop-a",255 localVersion: 10,256 entries: { a: 10 },257 ciphertext: { a: "cipher-a" },258 });259 const waiter = makeVaultAdapter({260 deviceId: "laptop-c",261 localVersion: 11,262 entries: { c: 11 },263 ciphertext: { c: "cipher-c" },264 });265266 const [dialerOutcome, waiterOutcome] = await Promise.all([267 runSyncInitiator(dialerSession, dialer),268 runSyncResponder(waiterSession, waiter),269 ]);270271 expect(dialerOutcome.status).toBe("completed");272 expect(waiterOutcome.status).toBe("completed");273 expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);274 expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);275 });276277 it("reports the privacy-safe protocol stage when a device stops answering", async () => {278 vi.useFakeTimers();279 try {280 const [session, peer] = inMemoryPair();281 const vault = makeVaultAdapter({});282 const initPromise = runSyncInitiator(session, vault);283284 const hello = await peer.receive();285 expect(hello.type).toBe("SYNC_HELLO");286287 await vi.advanceTimersByTimeAsync(30_000);288 await expect(initPromise).resolves.toMatchObject({289 status: "timeout",290 error: "sync_timeout:waiting_for_device_hello",291 });292 } finally {293 vi.useRealTimers();294 }295 });296297 it("completes a full sync with bi-directional updates", async () => {298 const [session, peer] = inMemoryPair();299 const vault = makeVaultAdapter({300 entries: { a: 100, b: 50 }, 301 tombstones: { x: 70 },302 ciphertext: { a: "ct-a", b: "ct-b" },303 });304305 const initPromise = runSyncInitiator(session, vault);306307 const peerProtocol = (async () => {308 const hello = await peer.receive();309 expect(hello.type).toBe("SYNC_HELLO");310 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now() });311312 const ourManifest = await peer.receive();313 expect(ourManifest.type).toBe("SYNC_MANIFEST");314 await peer.send({315 type: "SYNC_MANIFEST",316 entries: { a: 80, b: 90, c: 200 }, 317 tombstones: { x: 60, y: 99 }, 318 });319320 const ourRequest = await peer.receive();321 expect(ourRequest.type).toBe("SYNC_REQUEST");322 323 if (ourRequest.type === "SYNC_REQUEST") {324 expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);325 }326 327 await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });328329 const ourEntries = await peer.receive();330 expect(ourEntries.type).toBe("SYNC_ENTRIES");331 if (ourEntries.type === "SYNC_ENTRIES") {332 expect(ourEntries.entries).toEqual({ a: "ct-a" });333 }334 await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });335336 const ourTombs = await peer.receive();337 expect(ourTombs.type).toBe("SYNC_TOMBSTONES");338 if (ourTombs.type === "SYNC_TOMBSTONES") {339 340 expect(ourTombs.tombstones).toEqual({ x: 70 });341 }342 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });343344 const ourDone = await peer.receive();345 expect(ourDone.type).toBe("SYNC_DONE");346 await peer.send({ type: "SYNC_DONE" });347 })();348349 const outcome = await initPromise;350 await peerProtocol;351352 expect(outcome.status).toBe("completed");353 expect(outcome.entriesSent).toBe(1);354 expect(outcome.entriesReceived).toBe(2);355 expect(outcome.tombstonesSent).toBe(1);356 expect(outcome.tombstonesReceived).toBe(1);357 expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);358 expect(vault.appliedTombstones).toEqual([{ y: 99 }]);359 });360361 it("aborts with vault_mismatch on a peer with a different vault_id", async () => {362 const [session, peer] = inMemoryPair();363 const vault = makeVaultAdapter({ vaultId: "vault-A" });364365 const initPromise = runSyncInitiator(session, vault);366 const peerProtocol = (async () => {367 const hello = await peer.receive();368 expect(hello.type).toBe("SYNC_HELLO");369 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now() });370 371 const err = await peer.receive();372 expect(err.type).toBe("SYNC_ERROR");373 })();374375 const outcome = await initPromise;376 await peerProtocol;377 expect(outcome.status).toBe("vault_mismatch");378 });379380 it("exchanges device manifests when both adapters opt in", async () => {381 const [session, peer] = inMemoryPair();382 const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");383 const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");384385 const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);386 const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);387 const applied: SignedDeviceManifest[] = [];388389 const base = makeVaultAdapter({});390 const vault: SyncVaultAdapter = {391 ...base,392 async buildOutgoingDeviceManifest() { return outgoing; },393 async applyIncomingDeviceManifest(m, authenticatedKey) {394 expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);395 applied.push(m);396 },397 };398399 const initPromise = runSyncInitiator(session, vault);400 const peerProtocol = (async () => {401 await peer.receive(); 402 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });403 await peer.receive(); 404 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });405 await peer.receive(); 406 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });407 await peer.receive(); 408 await peer.receive(); 409 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });410 const ourManifest = await peer.receive();411 expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");412 if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {413 expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");414 }415 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });416 await peer.receive(); 417 await peer.send({ type: "SYNC_DONE" });418 })();419420 const outcome = await initPromise;421 await peerProtocol;422 expect(outcome.status).toBe("completed");423 expect(applied).toHaveLength(1);424 expect(applied[0].body.issuerDeviceId).toBe("them");425 });426427 it("uses null to represent an absent device manifest", async () => {428 const [session, peer] = inMemoryPair();429 const applyIncoming = vi.fn();430 const vault: SyncVaultAdapter = {431 ...makeVaultAdapter({}),432 async buildOutgoingDeviceManifest() { return null; },433 applyIncomingDeviceManifest: applyIncoming,434 };435436 const initPromise = runSyncInitiator(session, vault);437 const peerProtocol = (async () => {438 await peer.receive();439 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });440 await peer.receive();441 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });442 await peer.receive();443 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });444 await peer.receive();445 await peer.receive();446 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });447 const manifest = await peer.receive();448 expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });449 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });450 await peer.receive();451 await peer.send({ type: "SYNC_DONE" });452 })();453454 const outcome = await initPromise;455 await peerProtocol;456 expect(outcome.status).toBe("completed");457 expect(applyIncoming).not.toHaveBeenCalled();458 });459460 it("returns errored when peer sends a wrong frame", async () => {461 const [session, peer] = inMemoryPair();462 const vault = makeVaultAdapter({ vaultId: "v" });463464 const initPromise = runSyncInitiator(session, vault);465 void (async () => {466 await peer.receive(); 467 await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });468 await peer.receive(); 469 await peer.send({ type: "SYNC_DONE" }); 470 })();471472 const outcome = await initPromise;473 expect(outcome.status).toBe("errored");474 expect(outcome.error).toMatch(/SYNC_MANIFEST/);475 });476477 it("reports completed when the peer closes the socket while we wait for its closing SYNC_DONE", async () => {478 479 480 481 482 483 484 const [session, peer] = inMemoryPair();485 const vault = makeVaultAdapter({ localVersion: 5 });486487 const initPromise = runSyncInitiator(session, vault);488 void (async () => {489 await peer.receive(); 490 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now() });491 await peer.receive(); 492 493 await peer.closeInitiatorReceive();494 })();495496 const outcome = await initPromise;497 expect(outcome.status).toBe("completed");498 });499});