sync-runner.test.ts

17.9 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { PairedDevice, SyncMessage } from "../shared/sync-types";3import type {4  SyncSession,5  SyncEndpoint,6} from "../shared/sync-transport";7import type { SyncVaultAdapter } from "./sync-coordinator";8import type { SyncRunnerDeps } from "./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    rendezvousSecretHex: "11".repeat(32),46    wsPort: 49142,47    pairedAt: 1700000000000,48    ...overrides,49  };50}5152interface FakePeer {53  send(msg: SyncMessage): Promise<void>;54  receive(): Promise<SyncMessage>;55}5657/**58 * In-memory SyncSession ↔ FakePeer pair. The session is what the59 * runner passes to runSyncInitiator; the peer is the other end of the60 * wire, controlled by the test body. Mirrors the pattern in61 * sync-coordinator.test.ts.62 */63function inMemoryPair(): { session: SyncSession; peer: FakePeer; closed: { v: boolean } } {64  const inboundToSession: Uint8Array[] = [];65  const outboundFromSession: Uint8Array[] = [];66  const sessionReaders: Array<(v: Uint8Array) => void> = [];67  const peerReaders: Array<(v: Uint8Array) => void> = [];68  const closed = { v: false };6970  const session: SyncSession = {71    send(bytes) {72      const r = peerReaders.shift();73      if (r) r(bytes); else outboundFromSession.push(bytes);74    },75    receive(): Promise<Uint8Array> {76      const v = inboundToSession.shift();77      if (v) return Promise.resolve(v);78      return new Promise<Uint8Array>((res) => sessionReaders.push(res));79    },80    async close() { closed.v = true; },81    remoteStaticPublicKey: new Uint8Array(32),82  };83  const peer: FakePeer = {84    async send(msg) {85      const bytes = new TextEncoder().encode(JSON.stringify(msg));86      const r = sessionReaders.shift();87      if (r) r(bytes); else inboundToSession.push(bytes);88    },89    async receive() {90      const v = outboundFromSession.shift();91      const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));92      return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;93    },94  };95  return { session, peer, closed };96}9798/**99 * Drive a happy-path SYNC_DONE handshake on the peer side, matching the100 * coordinator's HELLO → MANIFEST → REQUEST → ENTRIES → TOMBSTONES → DONE101 * sequence. `peerEntries` lets the peer send a SYNC_ENTRIES payload of any102 * size for the entriesReceived assertion; `wantFromUs` requests N entries103 * from our side (drives entriesSent).104 */105async function runPeer(106  peer: FakePeer,107  opts: {108    vaultId?: string;109    peerEntriesB64?: Record<string, string>;110    wantFromUsIds?: string[];111  } = {},112): Promise<void> {113  const hello = await peer.receive();114  if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");115  await peer.send({116    type: "SYNC_HELLO",117    vaultId: opts.vaultId ?? hello.vaultId,118    deviceId: "peer-device",119    version: 0,120    deviceTime: Date.now(),121  });122  const manifest = await peer.receive();123  if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");124125  // Peer manifest declares the entries it will ship in SYNC_ENTRIES.126  const peerEntryManifest: Record<string, number> = {};127  for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {128    peerEntryManifest[id] = Date.now();129  }130  await peer.send({131    type: "SYNC_MANIFEST",132    entries: peerEntryManifest,133    tombstones: {},134  });135136  const ourRequest = await peer.receive();137  if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");138139  // Peer asks for the ids it wants from us.140  await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });141142  const ourEntries = await peer.receive();143  if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");144145  // Only ship SYNC_ENTRIES when the initiator actually requested any. The146  // coordinator only awaits this frame when wantFromPeer.length > 0.147  if (Object.keys(peerEntryManifest).length > 0) {148    await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });149  }150151  const ourTombstones = await peer.receive();152  if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");153  await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });154155  const done = await peer.receive();156  if (done.type !== "SYNC_DONE") throw new Error("expected DONE");157  await peer.send({ type: "SYNC_DONE" });158}159160function emptyAdapter(): SyncVaultAdapter {161  return {162    async computeVaultId() { return "vault-abc"; },163    async getOurDeviceId() { return "device-init"; },164    // Non-zero so the equal-version short circuit doesn't fire in runner tests165    // that exercise the full manifest-diff flow.166    async getLocalVersion() { return 5; },167    async buildEntryManifest() { return {}; },168    async buildTombstoneManifest() { return {}; },169    async getEncryptedEntries() { return {}; },170    async applyEntries() { /* no-op */ },171    async applyTombstones() { /* no-op */ },172  };173}174175describe("productionSyncRunner", () => {176  let local: StorageShape;177  let sync: StorageShape;178179  beforeEach(() => {180    vi.resetModules();181    local = {};182    sync = {};183    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);184    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));185  });186187  async function bootVault(): Promise<typeof import("./vault")> {188    const vault = await import("./vault");189    await vault.createVault("correct horse battery staple");190    return vault;191  }192193  it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {194    const vault = await bootVault();195    vault.lock();196    const { runWithDeps } = await import("./sync-runner");197    const deps: SyncRunnerDeps = {198      openSession: vi.fn(),199      probeSubnet: vi.fn(),200      getSubnet: vi.fn(async () => "192.168.1"),201      createAdapter: emptyAdapter,202    };203    const result = await runWithDeps(makeDevice(), deps);204    expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });205    expect(deps.openSession).not.toHaveBeenCalled();206  });207208  it("throws when the device has no longTermPublicKeyHex", async () => {209    await bootVault();210    const { runWithDeps } = await import("./sync-runner");211    const deps: SyncRunnerDeps = {212      openSession: vi.fn(),213      probeSubnet: vi.fn(),214      getSubnet: vi.fn(),215      createAdapter: emptyAdapter,216    };217    await expect(218      runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),219    ).rejects.toThrow("paired_device_missing_pubkey");220  });221222  it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {223    await bootVault();224    const { runWithDeps } = await import("./sync-runner");225    const deps: SyncRunnerDeps = {226      openSession: vi.fn(),227      probeSubnet: vi.fn(),228      getSubnet: vi.fn(),229      createAdapter: emptyAdapter,230    };231    await expect(232      runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),233    ).rejects.toThrow("paired_device_missing_rendezvous_secret");234  });235236  it("uses the cached last-known IP first and skips the subnet probe", async () => {237    await bootVault();238    const { setLastKnownIp } = await import("./paired-devices");239    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");240241    const { session, peer } = inMemoryPair();242    const openSession = vi.fn(async (_e: SyncEndpoint) => session);243    const probeSubnet = vi.fn();244    const deps: SyncRunnerDeps = {245      openSession,246      probeSubnet,247      getSubnet: vi.fn(async () => "192.168.1"),248      createAdapter: emptyAdapter,249    };250251    const { runWithDeps } = await import("./sync-runner");252    const runPromise = runWithDeps(makeDevice(), deps);253    await runPeer(peer);254    const result = await runPromise;255    expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });256    expect(openSession).toHaveBeenCalledTimes(1);257    expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 49142 });258    expect(probeSubnet).not.toHaveBeenCalled();259  });260261  it("falls back to subnet probe when cached IP fails to connect", async () => {262    await bootVault();263    const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");264    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");265266    const { session, peer } = inMemoryPair();267    const openSession = vi.fn()268      .mockRejectedValueOnce(new Error("ECONNREFUSED"))269      .mockRejectedValueOnce(new Error("ECONNREFUSED"))270      .mockRejectedValueOnce(new Error("ECONNREFUSED"))271      .mockResolvedValueOnce(session);272    const probeSubnet = vi.fn(async (273      ...args: Parameters<SyncRunnerDeps["probeSubnet"]>274    ) => ({275      ip: "192.168.1.99",276      port: 49142,277      rendezvousId: args[4][2],278    }));279    const deps: SyncRunnerDeps = {280      openSession,281      probeSubnet,282      getSubnet: vi.fn(async () => "192.168.1"),283      createAdapter: emptyAdapter,284    };285286    const { runWithDeps } = await import("./sync-runner");287    const runPromise = runWithDeps(makeDevice(), deps);288    await runPeer(peer);289    await runPromise;290    expect(openSession).toHaveBeenCalledTimes(4);291    expect(openSession.mock.calls[3][0]).toEqual({ ip: "192.168.1.99", port: 49142 });292    expect(openSession.mock.calls[3][3]).toBe(probeSubnet.mock.calls[0][4][2]);293    expect(probeSubnet).toHaveBeenCalledTimes(1);294    expect(probeSubnet.mock.calls[0][4]).toHaveLength(3);295    expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");296  });297298  it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {299    await bootVault();300    const { setLastKnownIp } = await import("./paired-devices");301    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");302303    const deps: SyncRunnerDeps = {304      openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),305      probeSubnet: vi.fn(async () => null),306      getSubnet: vi.fn(async () => "192.168.1"),307      createAdapter: emptyAdapter,308    };309    const { runWithDeps } = await import("./sync-runner");310    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");311  });312313  it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {314    await bootVault();315    const deps: SyncRunnerDeps = {316      openSession: vi.fn(),317      probeSubnet: vi.fn(),318      getSubnet: vi.fn(async () => null),319      createAdapter: emptyAdapter,320    };321    const { runWithDeps } = await import("./sync-runner");322    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");323  });324325  it("falls back to the configured relay after LAN discovery fails", async () => {326    await bootVault();327    const { session, peer } = inMemoryPair();328    const openRelaySession = vi.fn()329      .mockRejectedValueOnce(new Error("sync_handshake_timeout"))330      .mockResolvedValueOnce(session);331    const deps: SyncRunnerDeps = {332      openSession: vi.fn(),333      probeSubnet: vi.fn(),334      openRelaySession,335      getRelaySettings: vi.fn(async () => ({336        relayUrl: "wss://relay.example.test",337        lanOnly: false,338      })),339      getSubnet: vi.fn(async () => null),340      createAdapter: emptyAdapter,341    };342343    const { runWithDeps } = await import("./sync-runner");344    const runPromise = runWithDeps(345      makeDevice({ relayUrl: "wss://paired-relay.example.test" }),346      deps,347    );348    await runPeer(peer);349    await expect(runPromise).resolves.toEqual({350      ok: true,351      entriesReceived: 0,352      entriesSent: 0,353    });354    expect(openRelaySession).toHaveBeenCalledTimes(2);355    expect(openRelaySession.mock.calls[0][0]).toBe(356      "wss://paired-relay.example.test",357    );358    expect(openRelaySession.mock.calls[0][3]).not.toBe(359      openRelaySession.mock.calls[1][3],360    );361  });362363  it("never opens a relay session in LAN-only mode", async () => {364    await bootVault();365    const openRelaySession = vi.fn();366    const deps: SyncRunnerDeps = {367      openSession: vi.fn(),368      probeSubnet: vi.fn(),369      openRelaySession,370      getRelaySettings: vi.fn(async () => ({371        relayUrl: "wss://relay.example.test",372        lanOnly: true,373      })),374      getSubnet: vi.fn(async () => null),375      createAdapter: emptyAdapter,376    };377378    const { runWithDeps } = await import("./sync-runner");379    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow(380      "subnet_unknown",381    );382    expect(openRelaySession).not.toHaveBeenCalled();383  });384385  it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {386    await bootVault();387    const { session, peer } = inMemoryPair();388    const openSession = vi.fn(async () => session);389    const deps: SyncRunnerDeps = {390      openSession,391      probeSubnet: vi.fn(async () => ({392        ip: "192.168.1.1",393        port: 49142,394        rendezvousId: "A".repeat(43),395      })),396      getSubnet: vi.fn(async () => "192.168.1"),397      createAdapter: emptyAdapter,398    };399    const { runWithDeps } = await import("./sync-runner");400    const runPromise = runWithDeps(makeDevice(), deps);401    await runPeer(peer, {402      peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },403    });404    const result = await runPromise;405    expect(result.ok).toBe(true);406    if (result.ok) {407      expect(result.entriesReceived).toBe(3);408      expect(result.entriesSent).toBe(0);409    }410  });411412  it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {413    await bootVault();414    const { session, peer } = inMemoryPair();415    const adapter: SyncVaultAdapter = {416      ...emptyAdapter(),417      async getEncryptedEntries(ids) {418        const out: Record<string, string> = {};419        for (const id of ids) out[id] = "ENVELOPE_" + id;420        return out;421      },422    };423    const deps: SyncRunnerDeps = {424      openSession: vi.fn(async () => session),425      probeSubnet: vi.fn(async () => ({426        ip: "192.168.1.1",427        port: 49142,428        rendezvousId: "A".repeat(43),429      })),430      getSubnet: vi.fn(async () => "192.168.1"),431      createAdapter: () => adapter,432    };433    const { runWithDeps } = await import("./sync-runner");434    const runPromise = runWithDeps(makeDevice(), deps);435    await runPeer(peer, { wantFromUsIds: ["a", "b"] });436    const result = await runPromise;437    expect(result.ok).toBe(true);438    if (result.ok) expect(result.entriesSent).toBe(2);439  });440441  it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {442    await bootVault();443    const { session, peer } = inMemoryPair();444    const deps: SyncRunnerDeps = {445      openSession: vi.fn(async () => session),446      probeSubnet: vi.fn(async () => ({447        ip: "192.168.1.1",448        port: 49142,449        rendezvousId: "A".repeat(43),450      })),451      getSubnet: vi.fn(async () => "192.168.1"),452      createAdapter: emptyAdapter,453    };454    const { runWithDeps } = await import("./sync-runner");455    const runPromise = runWithDeps(makeDevice(), deps);456    // Peer responds with a vault_id that does NOT match ours, forcing the457    // coordinator to send SYNC_ERROR and return status:"vault_mismatch".458    (async () => {459      const hello = await peer.receive();460      if (hello.type === "SYNC_HELLO") {461        await peer.send({462          type: "SYNC_HELLO",463          vaultId: "wrong-vault-id",464          deviceId: "peer-device",465          version: 0,466          deviceTime: Date.now(),467        });468      }469    })().catch(() => undefined);470    await expect(runPromise).rejects.toThrow("vault_id_mismatch");471  });472});473474describe("sync-trigger persistence", () => {475  let local: StorageShape;476  let sync: StorageShape;477478  beforeEach(() => {479    vi.resetModules();480    local = {};481    sync = {};482    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);483    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));484  });485486  it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {487    const trigger = await import("./sync-trigger");488    const paired = await import("./paired-devices");489    trigger._resetSyncTriggerForTest();490    await paired.upsertPairedDevice({491      deviceId: "P",492      deviceName: "Pixel",493      deviceType: "android",494      longTermPublicKeyHex: "a".repeat(64),495      trustSource: "direct",496      pairedAt: 1700000000000,497    });498    trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));499    await trigger.triggerSyncIfReachable({ reason: "manual" });500    expect(typeof local.peach_last_sync_at).toBe("number");501    expect(local.peach_last_sync_at).toBeGreaterThan(0);502  });503504  it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {505    local.peach_last_sync_at = 1_700_005_000_000;506    const trigger = await import("./sync-trigger");507    trigger._resetSyncTriggerForTest();508    expect(trigger.getSyncState().lastSyncAt).toBeUndefined();509    await trigger.hydratePersistedSyncState();510    expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);511  });512});