1import { describe, expect, it } from "vitest";2import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";3import type { ChirpSyncMessage, SignedDeviceManifest } from "../shared/sync-types";4import type { ChirpSyncSession } from "../shared/chirp-sync-transport";5import { buildSignedDeviceManifest } from "../shared/device-manifest";6import { deriveSyncSigningKeypair } from "../shared/sync-identity";78function inMemoryPair(): [ChirpSyncSession, FakePeer] {9 const a: Uint8Array[] = [];10 const b: Uint8Array[] = [];11 const aR: Array<(v: Uint8Array) => void> = [];12 const bR: Array<(v: Uint8Array) => void> = [];1314 const ourSession: ChirpSyncSession = {15 send(bytes) {16 const r = bR.shift();17 if (r) r(bytes); else b.push(bytes);18 },19 receive(): Promise<Uint8Array> {20 const v = a.shift();21 if (v) return Promise.resolve(v);22 return new Promise<Uint8Array>((res) => aR.push(res));23 },24 async close() { },25 remoteStaticPublicKey: new Uint8Array(32),26 };2728 const peer: FakePeer = {29 async send(msg: ChirpSyncMessage) {30 const bytes = new TextEncoder().encode(JSON.stringify(msg));31 const r = aR.shift();32 if (r) r(bytes); else a.push(bytes);33 },34 async receive(): Promise<ChirpSyncMessage> {35 const v = b.shift();36 const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));37 return JSON.parse(new TextDecoder().decode(bytes)) as ChirpSyncMessage;38 },39 };40 return [ourSession, peer];41}4243interface FakePeer {44 send(msg: ChirpSyncMessage): Promise<void>;45 receive(): Promise<ChirpSyncMessage>;46}4748function makeVaultAdapter(opts: {49 vaultId?: string;50 deviceId?: string;51 localVersion?: number;52 entries?: Record<string, number>;53 tombstones?: Record<string, number>;54 ciphertext?: Record<string, string>;55}): ChirpSyncVaultAdapter & {56 appliedEntries: Record<string, string>[];57 appliedTombstones: Record<string, number>[];58} {59 const appliedEntries: Record<string, string>[] = [];60 const appliedTombstones: Record<string, number>[] = [];61 return {62 async computeVaultId() { return opts.vaultId ?? "vault-abc"; },63 async getOurDeviceId() { return opts.deviceId ?? "device-init"; },64 65 66 async getLocalVersion() { return opts.localVersion ?? 5; },67 async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },68 async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },69 async getEncryptedEntries(ids) {70 const out: Record<string, string> = {};71 const cipher = opts.ciphertext ?? {};72 for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];73 return out;74 },75 async applyEntries(entries) { appliedEntries.push(entries); },76 async applyTombstones(t) { appliedTombstones.push(t); },77 appliedEntries,78 appliedTombstones,79 };80}8182describe("runChirpSyncInitiator", () => {83 it("completes a full sync with bi-directional updates", async () => {84 const [session, peer] = inMemoryPair();85 const vault = makeVaultAdapter({86 entries: { a: 100, b: 50 }, 87 tombstones: { x: 70 },88 ciphertext: { a: "ct-a", b: "ct-b" },89 });9091 const initPromise = runChirpSyncInitiator(session, vault);9293 const peerProtocol = (async () => {94 const hello = await peer.receive();95 expect(hello.type).toBe("SYNC_HELLO");96 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now() });9798 const ourManifest = await peer.receive();99 expect(ourManifest.type).toBe("SYNC_MANIFEST");100 await peer.send({101 type: "SYNC_MANIFEST",102 entries: { a: 80, b: 90, c: 200 }, 103 tombstones: { x: 60, y: 99 }, 104 });105106 const ourRequest = await peer.receive();107 expect(ourRequest.type).toBe("SYNC_REQUEST");108 109 if (ourRequest.type === "SYNC_REQUEST") {110 expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);111 }112 113 await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });114115 const ourEntries = await peer.receive();116 expect(ourEntries.type).toBe("SYNC_ENTRIES");117 if (ourEntries.type === "SYNC_ENTRIES") {118 expect(ourEntries.entries).toEqual({ a: "ct-a" });119 }120 await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });121122 const ourTombs = await peer.receive();123 expect(ourTombs.type).toBe("SYNC_TOMBSTONES");124 if (ourTombs.type === "SYNC_TOMBSTONES") {125 126 expect(ourTombs.tombstones).toEqual({ x: 70 });127 }128 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });129130 const ourDone = await peer.receive();131 expect(ourDone.type).toBe("SYNC_DONE");132 await peer.send({ type: "SYNC_DONE" });133 })();134135 const outcome = await initPromise;136 await peerProtocol;137138 expect(outcome.status).toBe("completed");139 expect(outcome.entriesSent).toBe(1);140 expect(outcome.entriesReceived).toBe(2);141 expect(outcome.tombstonesSent).toBe(1);142 expect(outcome.tombstonesReceived).toBe(1);143 expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);144 expect(vault.appliedTombstones).toEqual([{ y: 99 }]);145 });146147 it("aborts with vault_mismatch on a peer with a different vault_id", async () => {148 const [session, peer] = inMemoryPair();149 const vault = makeVaultAdapter({ vaultId: "vault-A" });150151 const initPromise = runChirpSyncInitiator(session, vault);152 const peerProtocol = (async () => {153 const hello = await peer.receive();154 expect(hello.type).toBe("SYNC_HELLO");155 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now() });156 157 const err = await peer.receive();158 expect(err.type).toBe("SYNC_ERROR");159 })();160161 const outcome = await initPromise;162 await peerProtocol;163 expect(outcome.status).toBe("vault_mismatch");164 });165166 it("exchanges device manifests when both adapters opt in", async () => {167 const [session, peer] = inMemoryPair();168 const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");169 const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");170171 const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);172 const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);173 const applied: SignedDeviceManifest[] = [];174175 const base = makeVaultAdapter({});176 const vault: ChirpSyncVaultAdapter = {177 ...base,178 async buildOutgoingDeviceManifest() { return outgoing; },179 async applyIncomingDeviceManifest(m) { applied.push(m); },180 };181182 const initPromise = runChirpSyncInitiator(session, vault);183 const peerProtocol = (async () => {184 await peer.receive(); 185 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });186 await peer.receive(); 187 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });188 await peer.receive(); 189 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });190 await peer.receive(); 191 await peer.receive(); 192 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });193 const ourManifest = await peer.receive();194 expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");195 if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {196 expect(ourManifest.manifest.body.issuerDeviceId).toBe("us");197 }198 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });199 await peer.receive(); 200 await peer.send({ type: "SYNC_DONE" });201 })();202203 const outcome = await initPromise;204 await peerProtocol;205 expect(outcome.status).toBe("completed");206 expect(applied).toHaveLength(1);207 expect(applied[0].body.issuerDeviceId).toBe("them");208 });209210 it("returns errored when peer sends a wrong frame", async () => {211 const [session, peer] = inMemoryPair();212 const vault = makeVaultAdapter({ vaultId: "v" });213214 const initPromise = runChirpSyncInitiator(session, vault);215 void (async () => {216 await peer.receive(); 217 await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });218 await peer.receive(); 219 await peer.send({ type: "SYNC_DONE" }); 220 })();221222 const outcome = await initPromise;223 expect(outcome.status).toBe("errored");224 expect(outcome.error).toMatch(/SYNC_MANIFEST/);225 });226});