chirp-sync-coordinator.test.ts

6.5 KB · raw

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() { /* no-op */ },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 }, // we have 'a' at t=100, 'b' at t=5081      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 }, // we have newer 'a'; peer has newer 'b' and 'c'97        tombstones: { x: 60, y: 99 },        // peer has newer tombstone for 'y'98      });99100      const ourRequest = await peer.receive();101      expect(ourRequest.type).toBe("SYNC_REQUEST");102      // We should request the entries the peer reported newer103      if (ourRequest.type === "SYNC_REQUEST") {104        expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);105      }106      // Peer asks us for entries the peer claimed it had older versions of107      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        // We had x@70, peer had x@60, so we ship x@70120        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      // Peer should receive a SYNC_ERROR and we should stop.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(); // hello167      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p" });168      await peer.receive(); // our manifest169      await peer.send({ type: "SYNC_DONE" }); // wrong frame170    })();171172    const outcome = await initPromise;173    expect(outcome.status).toBe("errored");174    expect(outcome.error).toMatch(/SYNC_MANIFEST/);175  });176});