sync-runner.test.ts

16.1 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("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {326    await bootVault();327    const { session, peer } = inMemoryPair();328    const openSession = vi.fn(async () => session);329    const deps: SyncRunnerDeps = {330      openSession,331      probeSubnet: vi.fn(async () => ({332        ip: "192.168.1.1",333        port: 49142,334        rendezvousId: "A".repeat(43),335      })),336      getSubnet: vi.fn(async () => "192.168.1"),337      createAdapter: emptyAdapter,338    };339    const { runWithDeps } = await import("./sync-runner");340    const runPromise = runWithDeps(makeDevice(), deps);341    await runPeer(peer, {342      peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },343    });344    const result = await runPromise;345    expect(result.ok).toBe(true);346    if (result.ok) {347      expect(result.entriesReceived).toBe(3);348      expect(result.entriesSent).toBe(0);349    }350  });351352  it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {353    await bootVault();354    const { session, peer } = inMemoryPair();355    const adapter: SyncVaultAdapter = {356      ...emptyAdapter(),357      async getEncryptedEntries(ids) {358        const out: Record<string, string> = {};359        for (const id of ids) out[id] = "ENVELOPE_" + id;360        return out;361      },362    };363    const deps: SyncRunnerDeps = {364      openSession: vi.fn(async () => session),365      probeSubnet: vi.fn(async () => ({366        ip: "192.168.1.1",367        port: 49142,368        rendezvousId: "A".repeat(43),369      })),370      getSubnet: vi.fn(async () => "192.168.1"),371      createAdapter: () => adapter,372    };373    const { runWithDeps } = await import("./sync-runner");374    const runPromise = runWithDeps(makeDevice(), deps);375    await runPeer(peer, { wantFromUsIds: ["a", "b"] });376    const result = await runPromise;377    expect(result.ok).toBe(true);378    if (result.ok) expect(result.entriesSent).toBe(2);379  });380381  it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {382    await bootVault();383    const { session, peer } = inMemoryPair();384    const deps: SyncRunnerDeps = {385      openSession: vi.fn(async () => session),386      probeSubnet: vi.fn(async () => ({387        ip: "192.168.1.1",388        port: 49142,389        rendezvousId: "A".repeat(43),390      })),391      getSubnet: vi.fn(async () => "192.168.1"),392      createAdapter: emptyAdapter,393    };394    const { runWithDeps } = await import("./sync-runner");395    const runPromise = runWithDeps(makeDevice(), deps);396    // Peer responds with a vault_id that does NOT match ours, forcing the397    // coordinator to send SYNC_ERROR and return status:"vault_mismatch".398    (async () => {399      const hello = await peer.receive();400      if (hello.type === "SYNC_HELLO") {401        await peer.send({402          type: "SYNC_HELLO",403          vaultId: "wrong-vault-id",404          deviceId: "peer-device",405          version: 0,406          deviceTime: Date.now(),407        });408      }409    })().catch(() => undefined);410    await expect(runPromise).rejects.toThrow("vault_id_mismatch");411  });412});413414describe("sync-trigger persistence", () => {415  let local: StorageShape;416  let sync: StorageShape;417418  beforeEach(() => {419    vi.resetModules();420    local = {};421    sync = {};422    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);423    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));424  });425426  it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {427    const trigger = await import("./sync-trigger");428    const paired = await import("./paired-devices");429    trigger._resetSyncTriggerForTest();430    await paired.upsertPairedDevice({431      deviceId: "P",432      deviceName: "Pixel",433      deviceType: "android",434      longTermPublicKeyHex: "a".repeat(64),435      trustSource: "direct",436      pairedAt: 1700000000000,437    });438    trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));439    await trigger.triggerSyncIfReachable({ reason: "manual" });440    expect(typeof local.peach_last_sync_at).toBe("number");441    expect(local.peach_last_sync_at).toBeGreaterThan(0);442  });443444  it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {445    local.peach_last_sync_at = 1_700_005_000_000;446    const trigger = await import("./sync-trigger");447    trigger._resetSyncTriggerForTest();448    expect(trigger.getSyncState().lastSyncAt).toBeUndefined();449    await trigger.hydratePersistedSyncState();450    expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);451  });452});