sync-coordinator.test.ts

30.4 KB · raw

1import { describe, expect, it, vi } from "vitest";2import { SYNC_LIMITS } from "../shared/sync-limits";3import {4  runSyncInitiator,5  runSyncResponder,6  type SyncVaultAdapter,7} from "./sync-coordinator";8import {9  DEVICE_MANIFEST_VERSION,10  SYNC_PROTOCOL_VERSION,11  type SyncMessage,12  type SignedDeviceManifest,13} from "../shared/sync-types";14import type { SyncSession } from "../shared/sync-transport";15import { buildSignedDeviceManifest } from "../shared/device-manifest";16import { deriveSyncSigningKeypair } from "../shared/legacy-identity-derivation.test-support";1718function inMemoryPair(): [SyncSession, FakePeer] {19  const a: Uint8Array[] = [];20  const b: Uint8Array[] = [];21  const aR: Array<(v: Uint8Array) => void> = [];22  const bR: Array<(v: Uint8Array) => void> = [];23  const aRej: Array<(e: Error) => void> = [];24  let closed = false;2526  const ourSession: SyncSession = {27    send(bytes) {28      const r = bR.shift();29      if (r) r(bytes); else b.push(bytes);30    },31    receive(): Promise<Uint8Array> {32      const v = a.shift();33      if (v) return Promise.resolve(v);34      if (closed) return Promise.reject(new Error("WebSocket closed"));35      return new Promise<Uint8Array>((res, rej) => { aR.push(res); aRej.push(rej); });36    },37    async close() { /* no-op */ },38    remoteStaticPublicKey: new Uint8Array(32),39  };4041  const peer: FakePeer = {42    async send(msg: SyncMessage) {43      const bytes = new TextEncoder().encode(JSON.stringify(msg));44      const r = aR.shift();45      if (r) r(bytes); else a.push(bytes);46    },47    async receive(): Promise<SyncMessage> {48      const v = b.shift();49      const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));50      return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;51    },52    /**53     * Android's listener closes the socket the moment it has our final54     * SYNC_DONE — there is no courtesy echo. Surface that as a "WebSocket55     * closed" rejection on the initiator's parked receive().56     */57    async closeInitiatorReceive() {58      if (closed) return;59      closed = true;60      while (aRej.length > 0) aRej.shift()!(new Error("WebSocket closed"));61    },62  };63  return [ourSession, peer];64}6566function symmetricSessionPair(): [SyncSession, SyncSession] {67  const queues: [Uint8Array[], Uint8Array[]] = [[], []];68  const readers: [69    Array<(value: Uint8Array) => void>,70    Array<(value: Uint8Array) => void>,71  ] = [[], []];72  const make = (side: 0 | 1): SyncSession => ({73    send(bytes) {74      const other = side === 0 ? 1 : 0;75      const reader = readers[other].shift();76      if (reader) reader(bytes);77      else queues[other].push(bytes);78    },79    receive() {80      const queued = queues[side].shift();81      if (queued) return Promise.resolve(queued);82      return new Promise<Uint8Array>((resolve) => readers[side].push(resolve));83    },84    async close() {},85    remoteStaticPublicKey: new Uint8Array(32).fill(side === 0 ? 0x22 : 0x11),86  });87  return [make(0), make(1)];88}8990interface FakePeer {91  send(msg: SyncMessage): Promise<void>;92  receive(): Promise<SyncMessage>;93  /** Hang up on the initiator's parked receive(), as Android does at DONE. */94  closeInitiatorReceive(): Promise<void>;95}9697function makeVaultAdapter(opts: {98  vaultId?: string;99  deviceId?: string;100  localVersion?: number;101  entries?: Record<string, number>;102  tombstones?: Record<string, number>;103  ciphertext?: Record<string, string>;104}): SyncVaultAdapter & {105  appliedEntries: Record<string, string>[];106  appliedTombstones: Record<string, number>[];107} {108  const appliedEntries: Record<string, string>[] = [];109  const appliedTombstones: Record<string, number>[] = [];110  return {111    async computeVaultId() { return opts.vaultId ?? "vault-abc"; },112    async getOurDeviceId() { return opts.deviceId ?? "device-init"; },113    // Use a non-zero default so the equal-version short circuit doesn't fire in114    // tests that aren't explicitly testing that path.115    async getLocalVersion() { return opts.localVersion ?? 5; },116    async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },117    async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },118    async getEncryptedEntries(ids) {119      const out: Record<string, string> = {};120      const cipher = opts.ciphertext ?? {};121      for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];122      return out;123    },124    async applyEntries(entries) { appliedEntries.push(entries); },125    async applyTombstones(t) { appliedTombstones.push(t); },126    appliedEntries,127    appliedTombstones,128  };129}130131// PB-26-111: SYNC_REQUEST.entryIds was bounded only by the 24 MiB frame size —132// roughly a million UUIDs — while each requested id costs an HKDF derivation133// plus an AES-GCM encryption of the entry envelope. An authenticated peer could134// burn ~30s of service-worker CPU per dial, repeatably, with no data effect that135// would look like an attack.136describe("runSyncInitiator — bounded SYNC_REQUEST (PB-26-111)", () => {137  it("rejects an oversized id list before any per-entry work", async () => {138    const [session, peer] = inMemoryPair();139    const vault = makeVaultAdapter({ vaultId: "vault-abc", entries: { "id-1": 1 } });140    let encryptedFor: string[] | null = null;141    const spied = Object.assign({}, vault, {142      async getEncryptedEntries(ids: string[]) {143        encryptedFor = ids;144        return {};145      },146    });147148    const initPromise = runSyncInitiator(session, spied);149    void (async () => {150      await peer.receive(); // hello151      await peer.send({152        type: "SYNC_HELLO",153        vaultId: "vault-abc",154        deviceId: "p",155        version: 0,156        deviceTime: Date.now(),157        protocolVersion: 3,158      });159      await peer.receive(); // our manifest160      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });161      await peer.receive(); // our request162      // Ask for more ids than the protocol allows.163      await peer.send({164        type: "SYNC_REQUEST",165        entryIds: Array.from(166          { length: SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT + 1 },167          (_, i) => `id-${i}`,168        ),169      });170    })();171172    const outcome = await initPromise;173    expect(outcome.status).toBe("errored");174    expect(outcome.error).toMatch(/entry count exceeds limit/);175    // The whole point: the bound fires before the expensive per-id work.176    expect(encryptedFor).toBeNull();177  });178179  it("still serves a request at the limit", async () => {180    const [session, peer] = inMemoryPair();181    const ids = Array.from({ length: 3 }, (_, i) => `id-${i}`);182    const vault = makeVaultAdapter({183      vaultId: "vault-abc",184      entries: { "id-0": 1, "id-1": 1, "id-2": 1 },185      ciphertext: { "id-0": "AAAA", "id-1": "BBBB", "id-2": "CCCC" },186    });187188    const initPromise = runSyncInitiator(session, vault);189    void (async () => {190      await peer.receive(); // hello191      await peer.send({192        type: "SYNC_HELLO",193        vaultId: "vault-abc",194        deviceId: "p",195        version: 0,196        deviceTime: Date.now(),197        protocolVersion: 3,198      });199      await peer.receive(); // our manifest200      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });201      await peer.receive(); // our request202      await peer.send({ type: "SYNC_REQUEST", entryIds: ids });203      await peer.receive(); // SYNC_ENTRIES204      await peer.receive(); // SYNC_TOMBSTONES205      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });206      await peer.receive(); // SYNC_DONE207      await peer.send({ type: "SYNC_DONE" });208    })();209210    const outcome = await initPromise;211    expect(outcome.status).toBe("completed");212    expect(outcome.entriesSent).toBe(3);213  });214});215216describe("runSyncInitiator — SYNC_MANIFEST limits (PB-26-312)", () => {217  it("rejects oversized entry and tombstone manifests before diffing", async () => {218    const [session, peer] = inMemoryPair();219    const vault = makeVaultAdapter({ vaultId: "vault-abc" });220    const initPromise = runSyncInitiator(session, vault);221    void (async () => {222      await peer.receive();223      await peer.send({224        type: "SYNC_HELLO",225        vaultId: "vault-abc",226        deviceId: "p",227        version: 0,228        deviceTime: Date.now(),229        protocolVersion: 3,230      });231      await peer.receive();232      const entries = Object.fromEntries(233        Array.from({ length: SYNC_LIMITS.MAX_ENTRY_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),234      );235      await peer.send({ type: "SYNC_MANIFEST", entries, tombstones: {} });236    })();237    await expect(initPromise).resolves.toMatchObject({238      status: "errored",239      error: expect.stringMatching(/manifest entry count exceeds limit/),240    });241242    const [tombstoneSession, tombstonePeer] = inMemoryPair();243    const tombstonePromise = runSyncInitiator(tombstoneSession, vault);244    void (async () => {245      await tombstonePeer.receive();246      await tombstonePeer.send({247        type: "SYNC_HELLO",248        vaultId: "vault-abc",249        deviceId: "p",250        version: 0,251        deviceTime: Date.now(),252        protocolVersion: 3,253      });254      await tombstonePeer.receive();255      const tombstones = Object.fromEntries(256        Array.from({ length: SYNC_LIMITS.MAX_TOMBSTONE_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),257      );258      await tombstonePeer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones });259    })();260    await expect(tombstonePromise).resolves.toMatchObject({261      status: "errored",262      error: expect.stringMatching(/manifest tombstone count exceeds limit/),263    });264  });265});266267describe("runSyncInitiator — HELLO fields and version optimisation", () => {268  it("sends HELLO with version and deviceTime", async () => {269    const [session, peer] = inMemoryPair();270    const vault = makeVaultAdapter({ localVersion: 7 });271272    const initPromise = runSyncInitiator(session, vault);273274    const sentHello = await peer.receive();275    expect(sentHello.type).toBe("SYNC_HELLO");276    if (sentHello.type === "SYNC_HELLO") {277      expect(sentHello.version).toBe(7);278      expect(typeof sentHello.deviceTime).toBe("number");279      expect(sentHello.deviceTime).toBeGreaterThan(0);280    }281    // Send a mismatched vaultId to abort early so the test doesn't hang.282    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now(), protocolVersion: 3 });283    await peer.receive(); // SYNC_ERROR284    await initPromise;285  });286287  it("exchanges entry manifests when vault versions are equal", async () => {288    const [dialerSession, waiterSession] = symmetricSessionPair();289    const dialer = makeVaultAdapter({290      deviceId: "dialer",291      localVersion: 10,292      entries: { a: 10 },293      ciphertext: { a: "cipher-a" },294    });295    const waiter = makeVaultAdapter({296      deviceId: "waiter",297      localVersion: 10,298      entries: { b: 11 },299      ciphertext: { b: "cipher-b" },300    });301302    const [dialerOutcome, waiterOutcome] = await Promise.all([303      runSyncInitiator(dialerSession, dialer),304      runSyncResponder(waiterSession, waiter),305    ]);306307    expect(dialerOutcome.status).toBe("completed");308    expect(waiterOutcome.status).toBe("completed");309    expect(dialer.appliedEntries).toEqual([{ b: "cipher-b" }]);310    expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);311  });312313  it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {314    const [dialerSession, waiterSession] = symmetricSessionPair();315    const dialerManifest = {316      body: {317        manifestVersion: 5,318        issuerDeviceId: "dialer",319        issuedAt: 1,320        devices: [],321        revoked: [],322      },323      signatureHex: "11".repeat(64),324    } satisfies SignedDeviceManifest;325    const waiterManifest = {326      body: {327        manifestVersion: 5,328        issuerDeviceId: "waiter",329        issuedAt: 1,330        devices: [],331        revoked: [],332      },333      signatureHex: "22".repeat(64),334    } satisfies SignedDeviceManifest;335    const dialerApplied: SignedDeviceManifest[] = [];336    const waiterApplied: SignedDeviceManifest[] = [];337    const dialer: SyncVaultAdapter = {338      ...makeVaultAdapter({ deviceId: "dialer", localVersion: 7 }),339      async buildOutgoingDeviceManifest() { return dialerManifest; },340      async applyIncomingDeviceManifest(manifest) { dialerApplied.push(manifest); },341    };342    const waiter: SyncVaultAdapter = {343      ...makeVaultAdapter({ deviceId: "waiter", localVersion: 7 }),344      async buildOutgoingDeviceManifest() { return waiterManifest; },345      async applyIncomingDeviceManifest(manifest) { waiterApplied.push(manifest); },346    };347348    const [dialerOutcome, waiterOutcome] = await Promise.all([349      runSyncInitiator(dialerSession, dialer),350      runSyncResponder(waiterSession, waiter),351    ]);352353    expect(dialerOutcome.status).toBe("completed");354    expect(waiterOutcome.status).toBe("completed");355    expect(dialerApplied).toEqual([waiterManifest]);356    expect(waiterApplied).toEqual([dialerManifest]);357  });358359  it("negotiates the device-manifest step from both HELLOs in every role and version case", async () => {360    for (const initiatorSupports of [false, true]) {361      for (const responderSupports of [false, true]) {362        for (const equalVersions of [false, true]) {363          const [initiatorSession, responderSession] = symmetricSessionPair();364          const appliedByInitiator: SignedDeviceManifest[] = [];365          const appliedByResponder: SignedDeviceManifest[] = [];366          const manifest = (issuerDeviceId: string): SignedDeviceManifest => ({367            body: {368              manifestVersion: DEVICE_MANIFEST_VERSION,369              issuerDeviceId,370              issuedAt: 1,371              devices: [],372              revoked: [],373            },374            signatureHex: "11".repeat(64),375          });376          const adapter = (377            deviceId: string,378            localVersion: number,379            supports: boolean,380            applied: SignedDeviceManifest[],381          ): SyncVaultAdapter => ({382            ...makeVaultAdapter({ deviceId, localVersion }),383            ...(supports ? {384              async buildOutgoingDeviceManifest() { return manifest(deviceId); },385              async applyIncomingDeviceManifest(incoming: SignedDeviceManifest) {386                applied.push(incoming);387              },388            } : {}),389          });390          const initiator = adapter("initiator", 7, initiatorSupports, appliedByInitiator);391          const responder = adapter(392            "responder",393            equalVersions ? 7 : 9,394            responderSupports,395            appliedByResponder,396          );397398          const [initiatorOutcome, responderOutcome] = await Promise.all([399            runSyncInitiator(initiatorSession, initiator),400            runSyncResponder(responderSession, responder),401          ]);402          const bothSupport = initiatorSupports && responderSupports;403404          expect(initiatorOutcome.status).toBe("completed");405          expect(responderOutcome.status).toBe("completed");406          expect(appliedByInitiator).toHaveLength(bothSupport ? 1 : 0);407          expect(appliedByResponder).toHaveLength(bothSupport ? 1 : 0);408        }409      }410    }411  });412413  it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {414    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);415    const [session, peer] = inMemoryPair();416    const vault = makeVaultAdapter({ localVersion: 5 });417418    const initPromise = runSyncInitiator(session, vault);419420    await peer.receive(); // SYNC_HELLO421    // Peer reports a deviceTime 200 s in the past — beyond the 120 s threshold.422    await peer.send({423      type: "SYNC_HELLO",424      vaultId: "vault-abc",425      deviceId: "p",426      version: 3, // different version so exchange proceeds427        deviceTime: Date.now() - 200_000,428        protocolVersion: 3,429      });430    // Yield so the coordinator's async continuation can run the clock-skew check.431    await new Promise((r) => setTimeout(r, 0));432    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));433434    // Drive the sync to completion so the test doesn't leak.435    await peer.receive(); // SYNC_MANIFEST436    await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });437    await peer.receive(); // SYNC_REQUEST438    await peer.send({ type: "SYNC_REQUEST", entryIds: [] });439    await peer.receive(); // SYNC_ENTRIES440    await peer.receive(); // SYNC_TOMBSTONES441    await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });442    await peer.receive(); // SYNC_DONE443    await peer.send({ type: "SYNC_DONE" });444445    const outcome = await initPromise;446    expect(outcome.status).toBe("completed");447    warnSpy.mockRestore();448  });449});450451describe("runSyncInitiator", () => {452  it("syncs two mesh peers directly with their introducer absent", async () => {453    const [dialerSession, waiterSession] = symmetricSessionPair();454    const dialer = makeVaultAdapter({455      deviceId: "laptop-a",456      localVersion: 10,457      entries: { a: 10 },458      ciphertext: { a: "cipher-a" },459    });460    const waiter = makeVaultAdapter({461      deviceId: "laptop-c",462      localVersion: 11,463      entries: { c: 11 },464      ciphertext: { c: "cipher-c" },465    });466467    const [dialerOutcome, waiterOutcome] = await Promise.all([468      runSyncInitiator(dialerSession, dialer),469      runSyncResponder(waiterSession, waiter),470    ]);471472    expect(dialerOutcome.status).toBe("completed");473    expect(waiterOutcome.status).toBe("completed");474    expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);475    expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);476  });477478  it("reports the privacy-safe protocol stage when a device stops answering", async () => {479    vi.useFakeTimers();480    try {481      const [session, peer] = inMemoryPair();482      const vault = makeVaultAdapter({});483      const initPromise = runSyncInitiator(session, vault);484485      const hello = await peer.receive();486      expect(hello.type).toBe("SYNC_HELLO");487488      await vi.advanceTimersByTimeAsync(30_000);489      await expect(initPromise).resolves.toMatchObject({490        status: "timeout",491        error: "sync_timeout:waiting_for_device_hello",492      });493    } finally {494      vi.useRealTimers();495    }496  });497498  it("completes a full sync with bi-directional updates", async () => {499    const [session, peer] = inMemoryPair();500    const vault = makeVaultAdapter({501      entries: { a: 100, b: 50 }, // we have 'a' at t=100, 'b' at t=50502      tombstones: { x: 70 },503      ciphertext: { a: "ct-a", b: "ct-b" },504    });505506    const initPromise = runSyncInitiator(session, vault);507508    const peerProtocol = (async () => {509      const hello = await peer.receive();510      expect(hello.type).toBe("SYNC_HELLO");511      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now(), protocolVersion: 3 });512513      const ourManifest = await peer.receive();514      expect(ourManifest.type).toBe("SYNC_MANIFEST");515      await peer.send({516        type: "SYNC_MANIFEST",517        entries: { a: 80, b: 90, c: 200 }, // we have newer 'a'; peer has newer 'b' and 'c'518        tombstones: { x: 60, y: 99 },        // peer has newer tombstone for 'y'519      });520521      const ourRequest = await peer.receive();522      expect(ourRequest.type).toBe("SYNC_REQUEST");523      // We should request the entries the peer reported newer524      if (ourRequest.type === "SYNC_REQUEST") {525        expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);526      }527      // Peer asks us for entries the peer claimed it had older versions of528      await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });529530      const ourEntries = await peer.receive();531      expect(ourEntries.type).toBe("SYNC_ENTRIES");532      if (ourEntries.type === "SYNC_ENTRIES") {533        expect(ourEntries.entries).toEqual({ a: "ct-a" });534      }535      await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });536537      const ourTombs = await peer.receive();538      expect(ourTombs.type).toBe("SYNC_TOMBSTONES");539      if (ourTombs.type === "SYNC_TOMBSTONES") {540        // We had x@70, peer had x@60, so we ship x@70541        expect(ourTombs.tombstones).toEqual({ x: 70 });542      }543      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });544545      const ourDone = await peer.receive();546      expect(ourDone.type).toBe("SYNC_DONE");547      await peer.send({ type: "SYNC_DONE" });548    })();549550    const outcome = await initPromise;551    await peerProtocol;552553    expect(outcome.status).toBe("completed");554    expect(outcome.entriesSent).toBe(1);555    expect(outcome.entriesReceived).toBe(2);556    expect(outcome.tombstonesSent).toBe(1);557    expect(outcome.tombstonesReceived).toBe(1);558    expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);559    expect(vault.appliedTombstones).toEqual([{ y: 99 }]);560  });561562  it("aborts with vault_mismatch on a peer with a different vault_id", async () => {563    const [session, peer] = inMemoryPair();564    const vault = makeVaultAdapter({ vaultId: "vault-A" });565566    const initPromise = runSyncInitiator(session, vault);567    const peerProtocol = (async () => {568      const hello = await peer.receive();569      expect(hello.type).toBe("SYNC_HELLO");570      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now(), protocolVersion: 3 });571      // Peer should receive a SYNC_ERROR and we should stop.572      const err = await peer.receive();573      expect(err.type).toBe("SYNC_ERROR");574    })();575576    const outcome = await initPromise;577    await peerProtocol;578    expect(outcome.status).toBe("vault_mismatch");579  });580581  it("rejects a stale peer with PEER_PROTOCOL_OUTDATED", async () => {582    const [session, peer] = inMemoryPair();583    const vault = makeVaultAdapter({});584585    const initPromise = runSyncInitiator(session, vault);586    const peerProtocol = (async () => {587      await peer.receive(); // hello588      await peer.send({589        type: "SYNC_HELLO",590        vaultId: "vault-abc",591        deviceId: "p",592        version: 3,593        deviceTime: Date.now(),594      });595      const err = await peer.receive();596      expect(err.type).toBe("SYNC_ERROR");597    })();598599    const outcome = await initPromise;600    await peerProtocol;601    expect(outcome.status).toBe("errored");602    expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");603  });604605  it("rejects a lower explicit protocol version as repair-required", async () => {606    const [session, peer] = inMemoryPair();607    const vault = makeVaultAdapter({});608609    const initPromise = runSyncInitiator(session, vault);610    const peerProtocol = (async () => {611      await peer.receive(); // hello612      await peer.send({613        type: "SYNC_HELLO",614        vaultId: "vault-abc",615        deviceId: "p",616        version: 3,617        deviceTime: Date.now(),618        protocolVersion: SYNC_PROTOCOL_VERSION - 1,619      });620      const err = await peer.receive();621      expect(err).toEqual({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });622    })();623624    const outcome = await initPromise;625    await peerProtocol;626    expect(outcome.status).toBe("errored");627    expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");628  });629630  it("rejects a future protocol version rather than failing open", async () => {631    const [session, peer] = inMemoryPair();632    const vault = makeVaultAdapter({});633634    const initPromise = runSyncInitiator(session, vault);635    const peerProtocol = (async () => {636      await peer.receive(); // hello637      await peer.send({638        type: "SYNC_HELLO",639        vaultId: "vault-abc",640        deviceId: "p",641        version: 3,642        deviceTime: Date.now(),643        protocolVersion: SYNC_PROTOCOL_VERSION + 1,644      });645      const err = await peer.receive();646      expect(err).toEqual({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });647    })();648649    const outcome = await initPromise;650    await peerProtocol;651    expect(outcome.status).toBe("errored");652    expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");653  });654655  it("responder rejects an incompatible peer before sending any manifest", async () => {656    const [session, peer] = inMemoryPair();657    const vault = makeVaultAdapter({});658659    const respondPromise = runSyncResponder(session, vault);660    await peer.send({661      type: "SYNC_HELLO",662      vaultId: "vault-abc",663      deviceId: "p",664      version: 3,665      deviceTime: Date.now(),666      protocolVersion: SYNC_PROTOCOL_VERSION + 1,667    });668669    const reply = await peer.receive();670    expect(reply).toEqual({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });671    const outcome = await respondPromise;672    expect(outcome.status).toBe("errored");673    expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");674  });675676  it("initiator surfaces repair-required when the responder rejects first", async () => {677    const [session, peer] = inMemoryPair();678    const vault = makeVaultAdapter({});679680    const initPromise = runSyncInitiator(session, vault);681    await peer.receive(); // hello682    await peer.send({ type: "SYNC_ERROR", error: "PEER_PROTOCOL_OUTDATED" });683684    const outcome = await initPromise;685    expect(outcome.status).toBe("errored");686    expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");687  });688689  it("exchanges device manifests when both adapters opt in", async () => {690    const [session, peer] = inMemoryPair();691    const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");692    const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");693694    const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);695    const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);696    const applied: SignedDeviceManifest[] = [];697698    const base = makeVaultAdapter({});699    const vault: SyncVaultAdapter = {700      ...base,701      async buildOutgoingDeviceManifest() { return outgoing; },702      async applyIncomingDeviceManifest(m, authenticatedKey) {703        expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);704        applied.push(m);705      },706    };707708    const initPromise = runSyncInitiator(session, vault);709    const peerProtocol = (async () => {710      await peer.receive(); // hello711      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 3, deviceManifestVersion: 5 });712      await peer.receive(); // manifest713      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });714      await peer.receive(); // request715      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });716      await peer.receive(); // entries717      await peer.receive(); // tombstones718      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });719      const ourManifest = await peer.receive();720      expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");721      if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {722        expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");723      }724      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });725      await peer.receive(); // done726      await peer.send({ type: "SYNC_DONE" });727    })();728729    const outcome = await initPromise;730    await peerProtocol;731    expect(outcome.status).toBe("completed");732    expect(applied).toHaveLength(1);733    expect(applied[0].body.issuerDeviceId).toBe("them");734  });735736  it("uses null to represent an absent device manifest", async () => {737    const [session, peer] = inMemoryPair();738    const applyIncoming = vi.fn();739    const vault: SyncVaultAdapter = {740      ...makeVaultAdapter({}),741      async buildOutgoingDeviceManifest() { return null; },742      applyIncomingDeviceManifest: applyIncoming,743    };744745    const initPromise = runSyncInitiator(session, vault);746    const peerProtocol = (async () => {747      await peer.receive();748      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 3, deviceManifestVersion: 5 });749      await peer.receive();750      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });751      await peer.receive();752      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });753      await peer.receive();754      await peer.receive();755      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });756      const manifest = await peer.receive();757      expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });758      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });759      await peer.receive();760      await peer.send({ type: "SYNC_DONE" });761    })();762763    const outcome = await initPromise;764    await peerProtocol;765    expect(outcome.status).toBe("completed");766    expect(applyIncoming).not.toHaveBeenCalled();767  });768769  it("returns errored when peer sends a wrong frame", async () => {770    const [session, peer] = inMemoryPair();771    const vault = makeVaultAdapter({ vaultId: "v" });772773    const initPromise = runSyncInitiator(session, vault);774    void (async () => {775      await peer.receive(); // hello776      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 3 });777      await peer.receive(); // our manifest778      await peer.send({ type: "SYNC_DONE" }); // wrong frame779    })();780781    const outcome = await initPromise;782    expect(outcome.status).toBe("errored");783    expect(outcome.error).toMatch(/SYNC_MANIFEST/);784  });785786  it("reports completed when the peer closes the socket while we wait for its closing SYNC_DONE", async () => {787    // Bug: by the time we send our own SYNC_DONE, every entry and tombstone788    // has already been exchanged and applied on both sides. The peer's reply789    // is a courtesy, not a commit. Android closes the socket the moment it790    // has our DONE; previously the initiator's parked receive() rejected with791    // "WebSocket closed" and the run was reported as `errored` → run_failed792    // → red, even though the data had demonstrably landed.793    const [session, peer] = inMemoryPair();794    const vault = makeVaultAdapter({ localVersion: 5 });795796    const initPromise = runSyncInitiator(session, vault);797    void (async () => {798      await peer.receive(); // hello799      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now(), protocolVersion: 3 });800      await peer.receive(); // manifest801      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });802      await peer.receive(); // request803      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });804      await peer.receive(); // unconditional entries805      await peer.receive(); // tombstones806      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });807      await peer.receive(); // done808      // No SYNC_DONE echo — Android just hangs up.809      await peer.closeInitiatorReceive();810    })();811812    const outcome = await initPromise;813    expect(outcome.status).toBe("completed");814  });815});