sync-coordinator.test.ts

24.9 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 type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";9import type { SyncSession } from "../shared/sync-transport";10import { buildSignedDeviceManifest } from "../shared/device-manifest";11import { deriveSyncSigningKeypair } from "../shared/sync-identity";1213function inMemoryPair(): [SyncSession, FakePeer] {14  const a: Uint8Array[] = [];15  const b: Uint8Array[] = [];16  const aR: Array<(v: Uint8Array) => void> = [];17  const bR: Array<(v: Uint8Array) => void> = [];18  const aRej: Array<(e: Error) => void> = [];19  let closed = false;2021  const ourSession: SyncSession = {22    send(bytes) {23      const r = bR.shift();24      if (r) r(bytes); else b.push(bytes);25    },26    receive(): Promise<Uint8Array> {27      const v = a.shift();28      if (v) return Promise.resolve(v);29      if (closed) return Promise.reject(new Error("WebSocket closed"));30      return new Promise<Uint8Array>((res, rej) => { aR.push(res); aRej.push(rej); });31    },32    async close() { /* no-op */ },33    remoteStaticPublicKey: new Uint8Array(32),34  };3536  const peer: FakePeer = {37    async send(msg: SyncMessage) {38      const bytes = new TextEncoder().encode(JSON.stringify(msg));39      const r = aR.shift();40      if (r) r(bytes); else a.push(bytes);41    },42    async receive(): Promise<SyncMessage> {43      const v = b.shift();44      const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));45      return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;46    },47    /**48     * Android's listener closes the socket the moment it has our final49     * SYNC_DONE — there is no courtesy echo. Surface that as a "WebSocket50     * closed" rejection on the initiator's parked receive().51     */52    async closeInitiatorReceive() {53      if (closed) return;54      closed = true;55      while (aRej.length > 0) aRej.shift()!(new Error("WebSocket closed"));56    },57  };58  return [ourSession, peer];59}6061function symmetricSessionPair(): [SyncSession, SyncSession] {62  const queues: [Uint8Array[], Uint8Array[]] = [[], []];63  const readers: [64    Array<(value: Uint8Array) => void>,65    Array<(value: Uint8Array) => void>,66  ] = [[], []];67  const make = (side: 0 | 1): SyncSession => ({68    send(bytes) {69      const other = side === 0 ? 1 : 0;70      const reader = readers[other].shift();71      if (reader) reader(bytes);72      else queues[other].push(bytes);73    },74    receive() {75      const queued = queues[side].shift();76      if (queued) return Promise.resolve(queued);77      return new Promise<Uint8Array>((resolve) => readers[side].push(resolve));78    },79    async close() {},80    remoteStaticPublicKey: new Uint8Array(32).fill(side === 0 ? 0x22 : 0x11),81  });82  return [make(0), make(1)];83}8485interface FakePeer {86  send(msg: SyncMessage): Promise<void>;87  receive(): Promise<SyncMessage>;88  /** Hang up on the initiator's parked receive(), as Android does at DONE. */89  closeInitiatorReceive(): Promise<void>;90}9192function makeVaultAdapter(opts: {93  vaultId?: string;94  deviceId?: string;95  localVersion?: number;96  entries?: Record<string, number>;97  tombstones?: Record<string, number>;98  ciphertext?: Record<string, string>;99}): SyncVaultAdapter & {100  appliedEntries: Record<string, string>[];101  appliedTombstones: Record<string, number>[];102} {103  const appliedEntries: Record<string, string>[] = [];104  const appliedTombstones: Record<string, number>[] = [];105  return {106    async computeVaultId() { return opts.vaultId ?? "vault-abc"; },107    async getOurDeviceId() { return opts.deviceId ?? "device-init"; },108    // Use a non-zero default so the equal-version short circuit doesn't fire in109    // tests that aren't explicitly testing that path.110    async getLocalVersion() { return opts.localVersion ?? 5; },111    async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },112    async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },113    async getEncryptedEntries(ids) {114      const out: Record<string, string> = {};115      const cipher = opts.ciphertext ?? {};116      for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];117      return out;118    },119    async applyEntries(entries) { appliedEntries.push(entries); },120    async applyTombstones(t) { appliedTombstones.push(t); },121    appliedEntries,122    appliedTombstones,123  };124}125126// PB-26-111: SYNC_REQUEST.entryIds was bounded only by the 24 MiB frame size —127// roughly a million UUIDs — while each requested id costs an HKDF derivation128// plus an AES-GCM encryption of the entry envelope. An authenticated peer could129// burn ~30s of service-worker CPU per dial, repeatably, with no data effect that130// would look like an attack.131describe("runSyncInitiator — bounded SYNC_REQUEST (PB-26-111)", () => {132  it("rejects an oversized id list before any per-entry work", async () => {133    const [session, peer] = inMemoryPair();134    const vault = makeVaultAdapter({ vaultId: "vault-abc", entries: { "id-1": 1 } });135    let encryptedFor: string[] | null = null;136    const spied = Object.assign({}, vault, {137      async getEncryptedEntries(ids: string[]) {138        encryptedFor = ids;139        return {};140      },141    });142143    const initPromise = runSyncInitiator(session, spied);144    void (async () => {145      await peer.receive(); // hello146      await peer.send({147        type: "SYNC_HELLO",148        vaultId: "vault-abc",149        deviceId: "p",150        version: 0,151        deviceTime: Date.now(),152        protocolVersion: 2,153      });154      await peer.receive(); // our manifest155      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });156      await peer.receive(); // our request157      // Ask for more ids than the protocol allows.158      await peer.send({159        type: "SYNC_REQUEST",160        entryIds: Array.from(161          { length: SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT + 1 },162          (_, i) => `id-${i}`,163        ),164      });165    })();166167    const outcome = await initPromise;168    expect(outcome.status).toBe("errored");169    expect(outcome.error).toMatch(/entry count exceeds limit/);170    // The whole point: the bound fires before the expensive per-id work.171    expect(encryptedFor).toBeNull();172  });173174  it("still serves a request at the limit", async () => {175    const [session, peer] = inMemoryPair();176    const ids = Array.from({ length: 3 }, (_, i) => `id-${i}`);177    const vault = makeVaultAdapter({178      vaultId: "vault-abc",179      entries: { "id-0": 1, "id-1": 1, "id-2": 1 },180      ciphertext: { "id-0": "AAAA", "id-1": "BBBB", "id-2": "CCCC" },181    });182183    const initPromise = runSyncInitiator(session, vault);184    void (async () => {185      await peer.receive(); // hello186      await peer.send({187        type: "SYNC_HELLO",188        vaultId: "vault-abc",189        deviceId: "p",190        version: 0,191        deviceTime: Date.now(),192        protocolVersion: 2,193      });194      await peer.receive(); // our manifest195      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });196      await peer.receive(); // our request197      await peer.send({ type: "SYNC_REQUEST", entryIds: ids });198      await peer.receive(); // SYNC_ENTRIES199      await peer.receive(); // SYNC_TOMBSTONES200      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });201      await peer.receive(); // SYNC_DONE202      await peer.send({ type: "SYNC_DONE" });203    })();204205    const outcome = await initPromise;206    expect(outcome.status).toBe("completed");207    expect(outcome.entriesSent).toBe(3);208  });209});210211describe("runSyncInitiator — SYNC_MANIFEST limits (PB-26-312)", () => {212  it("rejects oversized entry and tombstone manifests before diffing", async () => {213    const [session, peer] = inMemoryPair();214    const vault = makeVaultAdapter({ vaultId: "vault-abc" });215    const initPromise = runSyncInitiator(session, vault);216    void (async () => {217      await peer.receive();218      await peer.send({219        type: "SYNC_HELLO",220        vaultId: "vault-abc",221        deviceId: "p",222        version: 0,223        deviceTime: Date.now(),224        protocolVersion: 2,225      });226      await peer.receive();227      const entries = Object.fromEntries(228        Array.from({ length: SYNC_LIMITS.MAX_ENTRY_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),229      );230      await peer.send({ type: "SYNC_MANIFEST", entries, tombstones: {} });231    })();232    await expect(initPromise).resolves.toMatchObject({233      status: "errored",234      error: expect.stringMatching(/manifest entry count exceeds limit/),235    });236237    const [tombstoneSession, tombstonePeer] = inMemoryPair();238    const tombstonePromise = runSyncInitiator(tombstoneSession, vault);239    void (async () => {240      await tombstonePeer.receive();241      await tombstonePeer.send({242        type: "SYNC_HELLO",243        vaultId: "vault-abc",244        deviceId: "p",245        version: 0,246        deviceTime: Date.now(),247        protocolVersion: 2,248      });249      await tombstonePeer.receive();250      const tombstones = Object.fromEntries(251        Array.from({ length: SYNC_LIMITS.MAX_TOMBSTONE_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),252      );253      await tombstonePeer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones });254    })();255    await expect(tombstonePromise).resolves.toMatchObject({256      status: "errored",257      error: expect.stringMatching(/manifest tombstone count exceeds limit/),258    });259  });260});261262describe("runSyncInitiator — HELLO fields and version optimisation", () => {263  it("sends HELLO with version and deviceTime", async () => {264    const [session, peer] = inMemoryPair();265    const vault = makeVaultAdapter({ localVersion: 7 });266267    const initPromise = runSyncInitiator(session, vault);268269    const sentHello = await peer.receive();270    expect(sentHello.type).toBe("SYNC_HELLO");271    if (sentHello.type === "SYNC_HELLO") {272      expect(sentHello.version).toBe(7);273      expect(typeof sentHello.deviceTime).toBe("number");274      expect(sentHello.deviceTime).toBeGreaterThan(0);275    }276    // Send a mismatched vaultId to abort early so the test doesn't hang.277    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now(), protocolVersion: 2 });278    await peer.receive(); // SYNC_ERROR279    await initPromise;280  });281282  it("skips manifest exchange and returns completed when versions are equal", async () => {283    const [session, peer] = inMemoryPair();284    const vault = makeVaultAdapter({ localVersion: 10 });285286    const initPromise = runSyncInitiator(session, vault);287288    await peer.receive(); // SYNC_HELLO289    await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now(), protocolVersion: 2 });290291    // Coordinator should send SYNC_DONE without any manifest exchange.292    const done = await peer.receive();293    expect(done.type).toBe("SYNC_DONE");294295    // No SYNC_MANIFEST was sent — peer's queue only has the DONE.296    await peer.send({ type: "SYNC_DONE" });297298    const outcome = await initPromise;299    expect(outcome.status).toBe("completed");300    expect(outcome.entriesReceived).toBe(0);301    expect(outcome.entriesSent).toBe(0);302  });303304  it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {305    const [dialerSession, waiterSession] = symmetricSessionPair();306    const dialerManifest = {307      body: {308        manifestVersion: 3,309        issuerDeviceId: "dialer",310        issuedAt: 1,311        devices: [],312        revoked: [],313      },314      signatureHex: "11".repeat(64),315    } satisfies SignedDeviceManifest;316    const waiterManifest = {317      body: {318        manifestVersion: 3,319        issuerDeviceId: "waiter",320        issuedAt: 1,321        devices: [],322        revoked: [],323      },324      signatureHex: "22".repeat(64),325    } satisfies SignedDeviceManifest;326    const dialerApplied: SignedDeviceManifest[] = [];327    const waiterApplied: SignedDeviceManifest[] = [];328    const dialer: SyncVaultAdapter = {329      ...makeVaultAdapter({ deviceId: "dialer", localVersion: 7 }),330      async buildOutgoingDeviceManifest() { return dialerManifest; },331      async applyIncomingDeviceManifest(manifest) { dialerApplied.push(manifest); },332    };333    const waiter: SyncVaultAdapter = {334      ...makeVaultAdapter({ deviceId: "waiter", localVersion: 7 }),335      async buildOutgoingDeviceManifest() { return waiterManifest; },336      async applyIncomingDeviceManifest(manifest) { waiterApplied.push(manifest); },337    };338339    const [dialerOutcome, waiterOutcome] = await Promise.all([340      runSyncInitiator(dialerSession, dialer),341      runSyncResponder(waiterSession, waiter),342    ]);343344    expect(dialerOutcome.status).toBe("completed");345    expect(waiterOutcome.status).toBe("completed");346    expect(dialerApplied).toEqual([waiterManifest]);347    expect(waiterApplied).toEqual([dialerManifest]);348  });349350  it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {351    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);352    const [session, peer] = inMemoryPair();353    const vault = makeVaultAdapter({ localVersion: 5 });354355    const initPromise = runSyncInitiator(session, vault);356357    await peer.receive(); // SYNC_HELLO358    // Peer reports a deviceTime 200 s in the past — beyond the 120 s threshold.359    await peer.send({360      type: "SYNC_HELLO",361      vaultId: "vault-abc",362      deviceId: "p",363      version: 3, // different version so exchange proceeds364        deviceTime: Date.now() - 200_000,365        protocolVersion: 2,366      });367    // Yield so the coordinator's async continuation can run the clock-skew check.368    await new Promise((r) => setTimeout(r, 0));369    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));370371    // Drive the sync to completion so the test doesn't leak.372    await peer.receive(); // SYNC_MANIFEST373    await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });374    await peer.receive(); // SYNC_REQUEST375    await peer.send({ type: "SYNC_REQUEST", entryIds: [] });376    await peer.receive(); // SYNC_ENTRIES377    await peer.receive(); // SYNC_TOMBSTONES378    await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });379    await peer.receive(); // SYNC_DONE380    await peer.send({ type: "SYNC_DONE" });381382    const outcome = await initPromise;383    expect(outcome.status).toBe("completed");384    warnSpy.mockRestore();385  });386});387388describe("runSyncInitiator", () => {389  it("syncs two mesh peers directly with their introducer absent", async () => {390    const [dialerSession, waiterSession] = symmetricSessionPair();391    const dialer = makeVaultAdapter({392      deviceId: "laptop-a",393      localVersion: 10,394      entries: { a: 10 },395      ciphertext: { a: "cipher-a" },396    });397    const waiter = makeVaultAdapter({398      deviceId: "laptop-c",399      localVersion: 11,400      entries: { c: 11 },401      ciphertext: { c: "cipher-c" },402    });403404    const [dialerOutcome, waiterOutcome] = await Promise.all([405      runSyncInitiator(dialerSession, dialer),406      runSyncResponder(waiterSession, waiter),407    ]);408409    expect(dialerOutcome.status).toBe("completed");410    expect(waiterOutcome.status).toBe("completed");411    expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);412    expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);413  });414415  it("reports the privacy-safe protocol stage when a device stops answering", async () => {416    vi.useFakeTimers();417    try {418      const [session, peer] = inMemoryPair();419      const vault = makeVaultAdapter({});420      const initPromise = runSyncInitiator(session, vault);421422      const hello = await peer.receive();423      expect(hello.type).toBe("SYNC_HELLO");424425      await vi.advanceTimersByTimeAsync(30_000);426      await expect(initPromise).resolves.toMatchObject({427        status: "timeout",428        error: "sync_timeout:waiting_for_device_hello",429      });430    } finally {431      vi.useRealTimers();432    }433  });434435  it("completes a full sync with bi-directional updates", async () => {436    const [session, peer] = inMemoryPair();437    const vault = makeVaultAdapter({438      entries: { a: 100, b: 50 }, // we have 'a' at t=100, 'b' at t=50439      tombstones: { x: 70 },440      ciphertext: { a: "ct-a", b: "ct-b" },441    });442443    const initPromise = runSyncInitiator(session, vault);444445    const peerProtocol = (async () => {446      const hello = await peer.receive();447      expect(hello.type).toBe("SYNC_HELLO");448      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now(), protocolVersion: 2 });449450      const ourManifest = await peer.receive();451      expect(ourManifest.type).toBe("SYNC_MANIFEST");452      await peer.send({453        type: "SYNC_MANIFEST",454        entries: { a: 80, b: 90, c: 200 }, // we have newer 'a'; peer has newer 'b' and 'c'455        tombstones: { x: 60, y: 99 },        // peer has newer tombstone for 'y'456      });457458      const ourRequest = await peer.receive();459      expect(ourRequest.type).toBe("SYNC_REQUEST");460      // We should request the entries the peer reported newer461      if (ourRequest.type === "SYNC_REQUEST") {462        expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);463      }464      // Peer asks us for entries the peer claimed it had older versions of465      await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });466467      const ourEntries = await peer.receive();468      expect(ourEntries.type).toBe("SYNC_ENTRIES");469      if (ourEntries.type === "SYNC_ENTRIES") {470        expect(ourEntries.entries).toEqual({ a: "ct-a" });471      }472      await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });473474      const ourTombs = await peer.receive();475      expect(ourTombs.type).toBe("SYNC_TOMBSTONES");476      if (ourTombs.type === "SYNC_TOMBSTONES") {477        // We had x@70, peer had x@60, so we ship x@70478        expect(ourTombs.tombstones).toEqual({ x: 70 });479      }480      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });481482      const ourDone = await peer.receive();483      expect(ourDone.type).toBe("SYNC_DONE");484      await peer.send({ type: "SYNC_DONE" });485    })();486487    const outcome = await initPromise;488    await peerProtocol;489490    expect(outcome.status).toBe("completed");491    expect(outcome.entriesSent).toBe(1);492    expect(outcome.entriesReceived).toBe(2);493    expect(outcome.tombstonesSent).toBe(1);494    expect(outcome.tombstonesReceived).toBe(1);495    expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);496    expect(vault.appliedTombstones).toEqual([{ y: 99 }]);497  });498499  it("aborts with vault_mismatch on a peer with a different vault_id", async () => {500    const [session, peer] = inMemoryPair();501    const vault = makeVaultAdapter({ vaultId: "vault-A" });502503    const initPromise = runSyncInitiator(session, vault);504    const peerProtocol = (async () => {505      const hello = await peer.receive();506      expect(hello.type).toBe("SYNC_HELLO");507      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now(), protocolVersion: 2 });508      // Peer should receive a SYNC_ERROR and we should stop.509      const err = await peer.receive();510      expect(err.type).toBe("SYNC_ERROR");511    })();512513    const outcome = await initPromise;514    await peerProtocol;515    expect(outcome.status).toBe("vault_mismatch");516  });517518  it("rejects a stale peer with PEER_PROTOCOL_OUTDATED", async () => {519    const [session, peer] = inMemoryPair();520    const vault = makeVaultAdapter({});521522    const initPromise = runSyncInitiator(session, vault);523    const peerProtocol = (async () => {524      await peer.receive(); // hello525      await peer.send({526        type: "SYNC_HELLO",527        vaultId: "vault-abc",528        deviceId: "p",529        version: 3,530        deviceTime: Date.now(),531      });532      const err = await peer.receive();533      expect(err.type).toBe("SYNC_ERROR");534    })();535536    const outcome = await initPromise;537    await peerProtocol;538    expect(outcome.status).toBe("errored");539    expect(outcome.error).toBe("PEER_PROTOCOL_OUTDATED");540  });541542  it("exchanges device manifests when both adapters opt in", async () => {543    const [session, peer] = inMemoryPair();544    const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");545    const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");546547    const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);548    const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);549    const applied: SignedDeviceManifest[] = [];550551    const base = makeVaultAdapter({});552    const vault: SyncVaultAdapter = {553      ...base,554      async buildOutgoingDeviceManifest() { return outgoing; },555      async applyIncomingDeviceManifest(m, authenticatedKey) {556        expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);557        applied.push(m);558      },559    };560561    const initPromise = runSyncInitiator(session, vault);562    const peerProtocol = (async () => {563      await peer.receive(); // hello564      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });565      await peer.receive(); // manifest566      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });567      await peer.receive(); // request568      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });569      await peer.receive(); // entries570      await peer.receive(); // tombstones571      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });572      const ourManifest = await peer.receive();573      expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");574      if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {575        expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");576      }577      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });578      await peer.receive(); // done579      await peer.send({ type: "SYNC_DONE" });580    })();581582    const outcome = await initPromise;583    await peerProtocol;584    expect(outcome.status).toBe("completed");585    expect(applied).toHaveLength(1);586    expect(applied[0].body.issuerDeviceId).toBe("them");587  });588589  it("uses null to represent an absent device manifest", async () => {590    const [session, peer] = inMemoryPair();591    const applyIncoming = vi.fn();592    const vault: SyncVaultAdapter = {593      ...makeVaultAdapter({}),594      async buildOutgoingDeviceManifest() { return null; },595      applyIncomingDeviceManifest: applyIncoming,596    };597598    const initPromise = runSyncInitiator(session, vault);599    const peerProtocol = (async () => {600      await peer.receive();601      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });602      await peer.receive();603      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });604      await peer.receive();605      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });606      await peer.receive();607      await peer.receive();608      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });609      const manifest = await peer.receive();610      expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });611      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });612      await peer.receive();613      await peer.send({ type: "SYNC_DONE" });614    })();615616    const outcome = await initPromise;617    await peerProtocol;618    expect(outcome.status).toBe("completed");619    expect(applyIncoming).not.toHaveBeenCalled();620  });621622  it("returns errored when peer sends a wrong frame", async () => {623    const [session, peer] = inMemoryPair();624    const vault = makeVaultAdapter({ vaultId: "v" });625626    const initPromise = runSyncInitiator(session, vault);627    void (async () => {628      await peer.receive(); // hello629      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });630      await peer.receive(); // our manifest631      await peer.send({ type: "SYNC_DONE" }); // wrong frame632    })();633634    const outcome = await initPromise;635    expect(outcome.status).toBe("errored");636    expect(outcome.error).toMatch(/SYNC_MANIFEST/);637  });638639  it("reports completed when the peer closes the socket while we wait for its closing SYNC_DONE", async () => {640    // Bug: by the time we send our own SYNC_DONE, every entry and tombstone641    // has already been exchanged and applied on both sides. The peer's reply642    // is a courtesy, not a commit. Android closes the socket the moment it643    // has our DONE; previously the initiator's parked receive() rejected with644    // "WebSocket closed" and the run was reported as `errored` → run_failed645    // → red, even though the data had demonstrably landed.646    const [session, peer] = inMemoryPair();647    const vault = makeVaultAdapter({ localVersion: 5 });648649    const initPromise = runSyncInitiator(session, vault);650    void (async () => {651      await peer.receive(); // hello652      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now(), protocolVersion: 2 });653      await peer.receive(); // our done654      // No SYNC_DONE echo — Android just hangs up.655      await peer.closeInitiatorReceive();656    })();657658    const outcome = await initPromise;659    expect(outcome.status).toBe("completed");660  });661});