chirp-sync-coordinator.test.ts

8.6 KB · raw

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() { /* no-op */ },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 }, // we have 'a' at t=100, 'b' at t=5083      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 }, // we have newer 'a'; peer has newer 'b' and 'c'99        tombstones: { x: 60, y: 99 },        // peer has newer tombstone for 'y'100      });101102      const ourRequest = await peer.receive();103      expect(ourRequest.type).toBe("SYNC_REQUEST");104      // We should request the entries the peer reported newer105      if (ourRequest.type === "SYNC_REQUEST") {106        expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);107      }108      // Peer asks us for entries the peer claimed it had older versions of109      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        // We had x@70, peer had x@60, so we ship x@70122        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      // Peer should receive a SYNC_ERROR and we should stop.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(); // hello181      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p" });182      await peer.receive(); // manifest183      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });184      await peer.receive(); // request185      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });186      await peer.receive(); // entries187      await peer.receive(); // tombstones188      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(); // done196      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(); // hello213      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p" });214      await peer.receive(); // our manifest215      await peer.send({ type: "SYNC_DONE" }); // wrong frame216    })();217218    const outcome = await initPromise;219    expect(outcome.status).toBe("errored");220    expect(outcome.error).toMatch(/SYNC_MANIFEST/);221  });222});