1import { describe, expect, it } from "vitest";2import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";3import type { ChirpSyncMessage } from "../shared/sync-types";4import type { ChirpSyncSession } from "../shared/chirp-sync-transport";56function inMemoryPair(): [ChirpSyncSession, FakePeer] {7 const a: Uint8Array[] = [];8 const b: Uint8Array[] = [];9 const aR: Array<(v: Uint8Array) => void> = [];10 const bR: Array<(v: Uint8Array) => void> = [];1112 const ourSession: ChirpSyncSession = {13 send(bytes) {14 const r = bR.shift();15 if (r) r(bytes); else b.push(bytes);16 },17 receive(): Promise<Uint8Array> {18 const v = a.shift();19 if (v) return Promise.resolve(v);20 return new Promise<Uint8Array>((res) => aR.push(res));21 },22 async close() { },23 remoteStaticPublicKey: new Uint8Array(32),24 };2526 const peer: FakePeer = {27 async send(msg: ChirpSyncMessage) {28 const bytes = new TextEncoder().encode(JSON.stringify(msg));29 const r = aR.shift();30 if (r) r(bytes); else a.push(bytes);31 },32 async receive(): Promise<ChirpSyncMessage> {33 const v = b.shift();34 const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));35 return JSON.parse(new TextDecoder().decode(bytes)) as ChirpSyncMessage;36 },37 };38 return [ourSession, peer];39}4041interface FakePeer {42 send(msg: ChirpSyncMessage): Promise<void>;43 receive(): Promise<ChirpSyncMessage>;44}4546function makeVaultAdapter(opts: {47 vaultId?: string;48 deviceId?: string;49 entries?: Record<string, number>;50 tombstones?: Record<string, number>;51 ciphertext?: Record<string, string>;52}): ChirpSyncVaultAdapter & {53 appliedEntries: Record<string, string>[];54 appliedTombstones: Record<string, number>[];55} {56 const appliedEntries: Record<string, string>[] = [];57 const appliedTombstones: Record<string, number>[] = [];58 return {59 async computeVaultId() { return opts.vaultId ?? "vault-abc"; },60 async getOurDeviceId() { return opts.deviceId ?? "device-init"; },61 async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },62 async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },63 async getEncryptedEntries(ids) {64 const out: Record<string, string> = {};65 const cipher = opts.ciphertext ?? {};66 for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];67 return out;68 },69 async applyEntries(entries) { appliedEntries.push(entries); },70 async applyTombstones(t) { appliedTombstones.push(t); },71 appliedEntries,72 appliedTombstones,73 };74}7576describe("runChirpSyncInitiator", () => {77 it("completes a full sync with bi-directional updates", async () => {78 const [session, peer] = inMemoryPair();79 const vault = makeVaultAdapter({80 entries: { a: 100, b: 50 }, 81 tombstones: { x: 70 },82 ciphertext: { a: "ct-a", b: "ct-b" },83 });8485 const initPromise = runChirpSyncInitiator(session, vault);8687 const peerProtocol = (async () => {88 const hello = await peer.receive();89 expect(hello.type).toBe("SYNC_HELLO");90 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer" });9192 const ourManifest = await peer.receive();93 expect(ourManifest.type).toBe("SYNC_MANIFEST");94 await peer.send({95 type: "SYNC_MANIFEST",96 entries: { a: 80, b: 90, c: 200 }, 97 tombstones: { x: 60, y: 99 }, 98 });99100 const ourRequest = await peer.receive();101 expect(ourRequest.type).toBe("SYNC_REQUEST");102 103 if (ourRequest.type === "SYNC_REQUEST") {104 expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);105 }106 107 await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });108109 const ourEntries = await peer.receive();110 expect(ourEntries.type).toBe("SYNC_ENTRIES");111 if (ourEntries.type === "SYNC_ENTRIES") {112 expect(ourEntries.entries).toEqual({ a: "ct-a" });113 }114 await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });115116 const ourTombs = await peer.receive();117 expect(ourTombs.type).toBe("SYNC_TOMBSTONES");118 if (ourTombs.type === "SYNC_TOMBSTONES") {119 120 expect(ourTombs.tombstones).toEqual({ x: 70 });121 }122 await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });123124 const ourDone = await peer.receive();125 expect(ourDone.type).toBe("SYNC_DONE");126 await peer.send({ type: "SYNC_DONE" });127 })();128129 const outcome = await initPromise;130 await peerProtocol;131132 expect(outcome.status).toBe("completed");133 expect(outcome.entriesSent).toBe(1);134 expect(outcome.entriesReceived).toBe(2);135 expect(outcome.tombstonesSent).toBe(1);136 expect(outcome.tombstonesReceived).toBe(1);137 expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);138 expect(vault.appliedTombstones).toEqual([{ y: 99 }]);139 });140141 it("aborts with vault_mismatch on a peer with a different vault_id", async () => {142 const [session, peer] = inMemoryPair();143 const vault = makeVaultAdapter({ vaultId: "vault-A" });144145 const initPromise = runChirpSyncInitiator(session, vault);146 const peerProtocol = (async () => {147 const hello = await peer.receive();148 expect(hello.type).toBe("SYNC_HELLO");149 await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil" });150 151 const err = await peer.receive();152 expect(err.type).toBe("SYNC_ERROR");153 })();154155 const outcome = await initPromise;156 await peerProtocol;157 expect(outcome.status).toBe("vault_mismatch");158 });159160 it("returns errored when peer sends a wrong frame", async () => {161 const [session, peer] = inMemoryPair();162 const vault = makeVaultAdapter({ vaultId: "v" });163164 const initPromise = runChirpSyncInitiator(session, vault);165 void (async () => {166 await peer.receive(); 167 await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p" });168 await peer.receive(); 169 await peer.send({ type: "SYNC_DONE" }); 170 })();171172 const outcome = await initPromise;173 expect(outcome.status).toBe("errored");174 expect(outcome.error).toMatch(/SYNC_MANIFEST/);175 });176});