chirp-sync-coordinator.test.ts

8.9 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  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    // Use a non-zero default so the equal-version short circuit doesn't fire in65    // tests that aren't explicitly testing that path.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 }, // we have 'a' at t=100, 'b' at t=5087      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 }, // we have newer 'a'; peer has newer 'b' and 'c'103        tombstones: { x: 60, y: 99 },        // peer has newer tombstone for 'y'104      });105106      const ourRequest = await peer.receive();107      expect(ourRequest.type).toBe("SYNC_REQUEST");108      // We should request the entries the peer reported newer109      if (ourRequest.type === "SYNC_REQUEST") {110        expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);111      }112      // Peer asks us for entries the peer claimed it had older versions of113      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        // We had x@70, peer had x@60, so we ship x@70126        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      // Peer should receive a SYNC_ERROR and we should stop.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(); // hello185      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });186      await peer.receive(); // manifest187      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });188      await peer.receive(); // request189      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });190      await peer.receive(); // entries191      await peer.receive(); // tombstones192      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(); // done200      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(); // hello217      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });218      await peer.receive(); // our manifest219      await peer.send({ type: "SYNC_DONE" }); // wrong frame220    })();221222    const outcome = await initPromise;223    expect(outcome.status).toBe("errored");224    expect(outcome.error).toMatch(/SYNC_MANIFEST/);225  });226});