chirp-sync-runner.test.ts

14.7 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { PairedDevice, ChirpSyncMessage } from "../shared/sync-types";3import type {4  ChirpSyncSession,5  SyncEndpoint,6} from "../shared/chirp-sync-transport";7import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";8import type { SyncRunnerDeps } from "./chirp-sync-runner";910type StorageShape = Record<string, unknown>;1112function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {13  const area = (store: StorageShape) => ({14    get: vi.fn(async (key?: string | string[]) => {15      if (!key) return store;16      if (typeof key === "string") return { [key]: store[key] };17      return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});18    }),19    set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),20    remove: vi.fn(async (keys: string | string[]) => {21      const list = Array.isArray(keys) ? keys : [keys];22      for (const k of list) delete store[k];23    }),24  });25  return {26    storage: { local: area(local), sync: area(sync) },27    alarms: {28      clear: vi.fn(),29      create: vi.fn(),30      onAlarm: { addListener: vi.fn() },31    },32    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },33    runtime: { sendMessage: vi.fn(async () => undefined) },34  } as unknown as typeof chrome;35}3637const REMOTE_PUBKEY_HEX = "ab".repeat(32);3839function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {40  return {41    deviceId: "pixel-xyz",42    deviceName: "Pixel",43    deviceType: "android",44    longTermPublicKeyHex: REMOTE_PUBKEY_HEX,45    wsPort: 8765,46    pairedAt: 1700000000000,47    ...overrides,48  };49}5051interface FakePeer {52  send(msg: ChirpSyncMessage): Promise<void>;53  receive(): Promise<ChirpSyncMessage>;54}5556/**57 * In-memory ChirpSyncSession ↔ FakePeer pair. The session is what the58 * runner passes to runChirpSyncInitiator; the peer is the other end of the59 * wire, controlled by the test body. Mirrors the pattern in60 * chirp-sync-coordinator.test.ts.61 */62function inMemoryPair(): { session: ChirpSyncSession; peer: FakePeer; closed: { v: boolean } } {63  const inboundToSession: Uint8Array[] = [];64  const outboundFromSession: Uint8Array[] = [];65  const sessionReaders: Array<(v: Uint8Array) => void> = [];66  const peerReaders: Array<(v: Uint8Array) => void> = [];67  const closed = { v: false };6869  const session: ChirpSyncSession = {70    send(bytes) {71      const r = peerReaders.shift();72      if (r) r(bytes); else outboundFromSession.push(bytes);73    },74    receive(): Promise<Uint8Array> {75      const v = inboundToSession.shift();76      if (v) return Promise.resolve(v);77      return new Promise<Uint8Array>((res) => sessionReaders.push(res));78    },79    async close() { closed.v = true; },80    remoteStaticPublicKey: new Uint8Array(32),81  };82  const peer: FakePeer = {83    async send(msg) {84      const bytes = new TextEncoder().encode(JSON.stringify(msg));85      const r = sessionReaders.shift();86      if (r) r(bytes); else inboundToSession.push(bytes);87    },88    async receive() {89      const v = outboundFromSession.shift();90      const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));91      return JSON.parse(new TextDecoder().decode(bytes)) as ChirpSyncMessage;92    },93  };94  return { session, peer, closed };95}9697/**98 * Drive a happy-path SYNC_DONE handshake on the peer side, matching the99 * coordinator's HELLO → MANIFEST → REQUEST → ENTRIES → TOMBSTONES → DONE100 * sequence. `peerEntries` lets the peer send a SYNC_ENTRIES payload of any101 * size for the entriesReceived assertion; `wantFromUs` requests N entries102 * from our side (drives entriesSent).103 */104async function runPeer(105  peer: FakePeer,106  opts: {107    vaultId?: string;108    peerEntriesB64?: Record<string, string>;109    wantFromUsIds?: string[];110  } = {},111): Promise<void> {112  const hello = await peer.receive();113  if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");114  await peer.send({115    type: "SYNC_HELLO",116    vaultId: opts.vaultId ?? hello.vaultId,117    deviceId: "peer-device",118  });119  const manifest = await peer.receive();120  if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");121122  // Peer manifest declares the entries it will ship in SYNC_ENTRIES.123  const peerEntryManifest: Record<string, number> = {};124  for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {125    peerEntryManifest[id] = Date.now();126  }127  await peer.send({128    type: "SYNC_MANIFEST",129    entries: peerEntryManifest,130    tombstones: {},131  });132133  const ourRequest = await peer.receive();134  if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");135136  // Peer asks for the ids it wants from us.137  await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });138139  const ourEntries = await peer.receive();140  if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");141142  // Only ship SYNC_ENTRIES when the initiator actually requested any. The143  // coordinator only awaits this frame when wantFromPeer.length > 0.144  if (Object.keys(peerEntryManifest).length > 0) {145    await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });146  }147148  const ourTombstones = await peer.receive();149  if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");150  await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });151152  const done = await peer.receive();153  if (done.type !== "SYNC_DONE") throw new Error("expected DONE");154  await peer.send({ type: "SYNC_DONE" });155}156157function emptyAdapter(): ChirpSyncVaultAdapter {158  return {159    async computeVaultId() { return "vault-abc"; },160    async getOurDeviceId() { return "device-init"; },161    async buildEntryManifest() { return {}; },162    async buildTombstoneManifest() { return {}; },163    async getEncryptedEntries() { return {}; },164    async applyEntries() { /* no-op */ },165    async applyTombstones() { /* no-op */ },166  };167}168169describe("productionSyncRunner", () => {170  let local: StorageShape;171  let sync: StorageShape;172173  beforeEach(() => {174    vi.resetModules();175    local = {};176    sync = {};177    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);178    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));179  });180181  async function bootVault(): Promise<typeof import("./vault")> {182    const vault = await import("./vault");183    await vault.createVault("correct horse battery staple");184    return vault;185  }186187  it("throws VaultLockedError when the vault is locked", async () => {188    const vault = await bootVault();189    vault.lock();190    const { runWithDeps } = await import("./chirp-sync-runner");191    const deps: SyncRunnerDeps = {192      openSession: vi.fn(),193      probeSubnet: vi.fn(),194      getSubnet: vi.fn(async () => "192.168.1"),195      createAdapter: emptyAdapter,196    };197    await expect(runWithDeps(makeDevice(), deps)).rejects.toMatchObject({198      name: "VaultLockedError",199    });200    expect(deps.openSession).not.toHaveBeenCalled();201  });202203  it("throws when the device has no longTermPublicKeyHex", async () => {204    await bootVault();205    const { runWithDeps } = await import("./chirp-sync-runner");206    const deps: SyncRunnerDeps = {207      openSession: vi.fn(),208      probeSubnet: vi.fn(),209      getSubnet: vi.fn(),210      createAdapter: emptyAdapter,211    };212    await expect(213      runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),214    ).rejects.toThrow("paired_device_missing_pubkey");215  });216217  it("uses the cached last-known IP first and skips the subnet probe", async () => {218    await bootVault();219    const { setLastKnownIp } = await import("./paired-devices");220    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");221222    const { session, peer } = inMemoryPair();223    const openSession = vi.fn(async (_e: SyncEndpoint) => session);224    const probeSubnet = vi.fn();225    const deps: SyncRunnerDeps = {226      openSession,227      probeSubnet,228      getSubnet: vi.fn(async () => "192.168.1"),229      createAdapter: emptyAdapter,230    };231232    const { runWithDeps } = await import("./chirp-sync-runner");233    const runPromise = runWithDeps(makeDevice(), deps);234    await runPeer(peer);235    const result = await runPromise;236    expect(result).toEqual({ entriesReceived: 0, entriesSent: 0 });237    expect(openSession).toHaveBeenCalledTimes(1);238    expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 8765 });239    expect(probeSubnet).not.toHaveBeenCalled();240  });241242  it("falls back to subnet probe when cached IP fails to connect", async () => {243    await bootVault();244    const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");245    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");246247    const { session, peer } = inMemoryPair();248    const openSession = vi.fn()249      .mockRejectedValueOnce(new Error("ECONNREFUSED"))250      .mockResolvedValueOnce(session);251    const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 8765 }));252    const deps: SyncRunnerDeps = {253      openSession,254      probeSubnet,255      getSubnet: vi.fn(async () => "192.168.1"),256      createAdapter: emptyAdapter,257    };258259    const { runWithDeps } = await import("./chirp-sync-runner");260    const runPromise = runWithDeps(makeDevice(), deps);261    await runPeer(peer);262    await runPromise;263    expect(openSession).toHaveBeenCalledTimes(2);264    expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 8765 });265    expect(probeSubnet).toHaveBeenCalledTimes(1);266    expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");267  });268269  it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {270    await bootVault();271    const { setLastKnownIp } = await import("./paired-devices");272    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");273274    const deps: SyncRunnerDeps = {275      openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),276      probeSubnet: vi.fn(async () => null),277      getSubnet: vi.fn(async () => "192.168.1"),278      createAdapter: emptyAdapter,279    };280    const { runWithDeps } = await import("./chirp-sync-runner");281    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");282  });283284  it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {285    await bootVault();286    const deps: SyncRunnerDeps = {287      openSession: vi.fn(),288      probeSubnet: vi.fn(),289      getSubnet: vi.fn(async () => null),290      createAdapter: emptyAdapter,291    };292    const { runWithDeps } = await import("./chirp-sync-runner");293    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");294  });295296  it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {297    await bootVault();298    const { session, peer } = inMemoryPair();299    const openSession = vi.fn(async () => session);300    const deps: SyncRunnerDeps = {301      openSession,302      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),303      getSubnet: vi.fn(async () => "192.168.1"),304      createAdapter: emptyAdapter,305    };306    const { runWithDeps } = await import("./chirp-sync-runner");307    const runPromise = runWithDeps(makeDevice(), deps);308    await runPeer(peer, {309      peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },310    });311    const result = await runPromise;312    expect(result.entriesReceived).toBe(3);313    expect(result.entriesSent).toBe(0);314  });315316  it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {317    await bootVault();318    const { session, peer } = inMemoryPair();319    const adapter: ChirpSyncVaultAdapter = {320      ...emptyAdapter(),321      async getEncryptedEntries(ids) {322        const out: Record<string, string> = {};323        for (const id of ids) out[id] = "ENVELOPE_" + id;324        return out;325      },326    };327    const deps: SyncRunnerDeps = {328      openSession: vi.fn(async () => session),329      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),330      getSubnet: vi.fn(async () => "192.168.1"),331      createAdapter: () => adapter,332    };333    const { runWithDeps } = await import("./chirp-sync-runner");334    const runPromise = runWithDeps(makeDevice(), deps);335    await runPeer(peer, { wantFromUsIds: ["a", "b"] });336    const result = await runPromise;337    expect(result.entriesSent).toBe(2);338  });339340  it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {341    await bootVault();342    const { session, peer } = inMemoryPair();343    const deps: SyncRunnerDeps = {344      openSession: vi.fn(async () => session),345      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),346      getSubnet: vi.fn(async () => "192.168.1"),347      createAdapter: emptyAdapter,348    };349    const { runWithDeps } = await import("./chirp-sync-runner");350    const runPromise = runWithDeps(makeDevice(), deps);351    // Peer responds with a vault_id that does NOT match ours, forcing the352    // coordinator to send SYNC_ERROR and return status:"vault_mismatch".353    (async () => {354      const hello = await peer.receive();355      if (hello.type === "SYNC_HELLO") {356        await peer.send({357          type: "SYNC_HELLO",358          vaultId: "wrong-vault-id",359          deviceId: "peer-device",360        });361      }362    })().catch(() => undefined);363    await expect(runPromise).rejects.toThrow("vault_id_mismatch");364  });365});366367describe("sync-trigger persistence", () => {368  let local: StorageShape;369  let sync: StorageShape;370371  beforeEach(() => {372    vi.resetModules();373    local = {};374    sync = {};375    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);376    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));377  });378379  it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {380    const trigger = await import("./sync-trigger");381    const paired = await import("./paired-devices");382    trigger._resetSyncTriggerForTest();383    await paired.upsertPairedDevice({384      deviceId: "P",385      deviceName: "Pixel",386      deviceType: "android",387      longTermPublicKeyHex: "a".repeat(64),388      pairedAt: 1700000000000,389    });390    trigger.setSyncRunner(async () => ({ entriesReceived: 1, entriesSent: 0 }));391    await trigger.triggerSyncIfReachable({ reason: "manual" });392    expect(typeof local.peach_last_sync_at).toBe("number");393    expect(local.peach_last_sync_at).toBeGreaterThan(0);394  });395396  it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {397    local.peach_last_sync_at = 1_700_005_000_000;398    const trigger = await import("./sync-trigger");399    trigger._resetSyncTriggerForTest();400    expect(trigger.getSyncState().lastSyncAt).toBeUndefined();401    await trigger.hydratePersistedSyncState();402    expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);403  });404});