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 entries?: Record<string, number>;52 tombstones?: Record<string, number>;53 ciphertext?: Record<string, string>;54}): ChirpSyncVaultAdapter & {55 appliedEntries: Record<string, string>[];56 appliedTombstones: Record<string, number>[];57} {58 const appliedEntries: Record<string, string>[] = [];59 const appliedTombstones: Record<string, number>[] = [];60 return {61 async computeVaultId() { return opts.vaultId ?? "vault-abc"; },62 async getOurDeviceId() { return opts.deviceId ?? "device-init"; },63 async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },64 async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },65 async getEncryptedEntries(ids) {66 const out: Record<string, string> = {};67 const cipher = opts.ciphertext ?? {};68 for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];69 return out;70 },71 async applyEntries(entries) { appliedEntries.push(entries); },72 async applyTombstones(t) { appliedTombstones.push(t); },73 appliedEntries,74 appliedTombstones,75 };76}7778describe("runChirpSyncInitiator", () => {79 it("completes a full sync with bi-directional updates", async () => {80 const [session, peer] = inMemoryPair();81 const vault = makeVaultAdapter({82 entries: { a: 100, b: 50 }, 83 tombstones: { x: 70 },84 ciphertext: { a: "ct-a", b: "ct-b" },85 });8687 const initPromise = runChirpSyncInitiator(session, vault);8889 const peerProtocol = (async () => {90 const hello = await peer.receive();91 expect(hello.type).toBe("SYNC_HELLO");92 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer" });9394 const ourManifest = await peer.receive();95 expect(ourManifest.type).toBe("SYNC_MANIFEST");96 await peer.send({97 type: "SYNC_MANIFEST",98 entries: { a: 80, b: 90, c: 200 }, 99 tombstones: { x: 60, y: 99 }, 100 });101102 const ourRequest = await peer.receive();103 expect(ourRequest.type).toBe("SYNC_REQUEST");104 105 if (ourRequest.type === "SYNC_REQUEST") {106 expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);107 }108 109 await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });110111 const ourEntries = await peer.receive();112 expect(ourEntries.type).toBe("SYNC_ENTRIES");113 if (ourEntries.type === "SYNC_ENTRIES") {114 expect(ourEntries.entries).toEqual({ a: "ct-a" });115 }116 await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });117118 const ourTombs = await peer.receive();119 expect(ourTombs.type).toBe("SYNC_TOMBSTONES");120 if (ourTombs.type === "SYNC_TOMBSTONES") {121 122 expect(ourTombs.tombstones).toEqual({ x: 70 });123 }124 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });125126 const ourDone = await peer.receive();127 expect(ourDone.type).toBe("SYNC_DONE");128 await peer.send({ type: "SYNC_DONE" });129 })();130131 const outcome = await initPromise;132 await peerProtocol;133134 expect(outcome.status).toBe("completed");135 expect(outcome.entriesSent).toBe(1);136 expect(outcome.entriesReceived).toBe(2);137 expect(outcome.tombstonesSent).toBe(1);138 expect(outcome.tombstonesReceived).toBe(1);139 expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);140 expect(vault.appliedTombstones).toEqual([{ y: 99 }]);141 });142143 it("aborts with vault_mismatch on a peer with a different vault_id", async () => {144 const [session, peer] = inMemoryPair();145 const vault = makeVaultAdapter({ vaultId: "vault-A" });146147 const initPromise = runChirpSyncInitiator(session, vault);148 const peerProtocol = (async () => {149 const hello = await peer.receive();150 expect(hello.type).toBe("SYNC_HELLO");151 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil" });152 153 const err = await peer.receive();154 expect(err.type).toBe("SYNC_ERROR");155 })();156157 const outcome = await initPromise;158 await peerProtocol;159 expect(outcome.status).toBe("vault_mismatch");160 });161162 it("exchanges device manifests when both adapters opt in", async () => {163 const [session, peer] = inMemoryPair();164 const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");165 const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");166167 const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);168 const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);169 const applied: SignedDeviceManifest[] = [];170171 const base = makeVaultAdapter({});172 const vault: ChirpSyncVaultAdapter = {173 ...base,174 async buildOutgoingDeviceManifest() { return outgoing; },175 async applyIncomingDeviceManifest(m) { applied.push(m); },176 };177178 const initPromise = runChirpSyncInitiator(session, vault);179 const peerProtocol = (async () => {180 await peer.receive(); 181 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p" });182 await peer.receive(); 183 await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });184 await peer.receive(); 185 await peer.send({ type: "SYNC_REQUEST", entryIds: [] });186 await peer.receive(); 187 await peer.receive(); 188 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });189 const ourManifest = await peer.receive();190 expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");191 if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {192 expect(ourManifest.manifest.body.issuerDeviceId).toBe("us");193 }194 await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });195 await peer.receive(); 196 await peer.send({ type: "SYNC_DONE" });197 })();198199 const outcome = await initPromise;200 await peerProtocol;201 expect(outcome.status).toBe("completed");202 expect(applied).toHaveLength(1);203 expect(applied[0].body.issuerDeviceId).toBe("them");204 });205206 it("returns errored when peer sends a wrong frame", async () => {207 const [session, peer] = inMemoryPair();208 const vault = makeVaultAdapter({ vaultId: "v" });209210 const initPromise = runChirpSyncInitiator(session, vault);211 void (async () => {212 await peer.receive(); 213 await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p" });214 await peer.receive(); 215 await peer.send({ type: "SYNC_DONE" }); 216 })();217218 const outcome = await initPromise;219 expect(outcome.status).toBe("errored");220 expect(outcome.error).toMatch(/SYNC_MANIFEST/);221 });222});