sync-runner.test.ts

12.3 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { PairedDevice, SyncMessage } from "../shared/sync-types";3import type {4  SyncSession,5} from "../shared/sync-transport";6import type { SyncVaultAdapter } from "./sync-coordinator";7import type { SyncRunnerDeps } from "./sync-runner";89type StorageShape = Record<string, unknown>;1011function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {12  const area = (store: StorageShape) => ({13    get: vi.fn(async (key?: string | string[]) => {14      if (!key) return store;15      if (typeof key === "string") return { [key]: store[key] };16      return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});17    }),18    set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),19    remove: vi.fn(async (keys: string | string[]) => {20      const list = Array.isArray(keys) ? keys : [keys];21      for (const k of list) delete store[k];22    }),23  });24  return {25    storage: { local: area(local), sync: area(sync) },26    alarms: {27      clear: vi.fn(),28      create: vi.fn(),29      onAlarm: { addListener: vi.fn() },30    },31    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },32    runtime: { sendMessage: vi.fn(async () => undefined) },33  } as unknown as typeof chrome;34}3536const REMOTE_PUBKEY_HEX = "ab".repeat(32);3738function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {39  return {40    deviceId: "pixel-xyz",41    deviceName: "Pixel",42    deviceType: "android",43    longTermPublicKeyHex: REMOTE_PUBKEY_HEX,44    rendezvousSecretHex: "11".repeat(32),45    wsPort: 49142,46    pairedAt: 1700000000000,47    ...overrides,48  };49}5051interface FakePeer {52  send(msg: SyncMessage): Promise<void>;53  receive(): Promise<SyncMessage>;54}5556/**57 * In-memory SyncSession ↔ FakePeer pair. The session is what the58 * runner passes to runSyncInitiator; the peer is the other end of the59 * wire, controlled by the test body. Mirrors the pattern in60 * sync-coordinator.test.ts.61 */62function inMemoryPair(): { session: SyncSession; 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: SyncSession = {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 SyncMessage;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    version: 0,119    deviceTime: Date.now(),120  });121  const manifest = await peer.receive();122  if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");123124  // Peer manifest declares the entries it will ship in SYNC_ENTRIES.125  const peerEntryManifest: Record<string, number> = {};126  for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {127    peerEntryManifest[id] = Date.now();128  }129  await peer.send({130    type: "SYNC_MANIFEST",131    entries: peerEntryManifest,132    tombstones: {},133  });134135  const ourRequest = await peer.receive();136  if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");137138  // Peer asks for the ids it wants from us.139  await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });140141  const ourEntries = await peer.receive();142  if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");143144  // Only ship SYNC_ENTRIES when the initiator actually requested any. The145  // coordinator only awaits this frame when wantFromPeer.length > 0.146  if (Object.keys(peerEntryManifest).length > 0) {147    await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });148  }149150  const ourTombstones = await peer.receive();151  if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");152  await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });153154  const done = await peer.receive();155  if (done.type !== "SYNC_DONE") throw new Error("expected DONE");156  await peer.send({ type: "SYNC_DONE" });157}158159function emptyAdapter(): SyncVaultAdapter {160  return {161    async computeVaultId() { return "vault-abc"; },162    async getOurDeviceId() { return "device-init"; },163    // Non-zero so the equal-version short circuit doesn't fire in runner tests164    // that exercise the full manifest-diff flow.165    async getLocalVersion() { return 5; },166    async buildEntryManifest() { return {}; },167    async buildTombstoneManifest() { return {}; },168    async getEncryptedEntries() { return {}; },169    async applyEntries() { /* no-op */ },170    async applyTombstones() { /* no-op */ },171  };172}173174const relaySettings = async () => ({175  mode: "public" as const,176  relayUrl: "wss://relay.peachpasswords.com",177});178179describe("productionSyncRunner", () => {180  let local: StorageShape;181  let sync: StorageShape;182183  beforeEach(() => {184    vi.resetModules();185    local = {};186    sync = {};187    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);188    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));189  });190191  async function bootVault(): Promise<typeof import("./vault")> {192    const vault = await import("./vault");193    await vault.createVault("correct horse battery staple");194    return vault;195  }196197  it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {198    const vault = await bootVault();199    vault.lock();200    const { runWithDeps } = await import("./sync-runner");201    const deps: SyncRunnerDeps = {202      openRelaySession: vi.fn(),203      getSyncSettings: relaySettings,204      createAdapter: emptyAdapter,205    };206    const result = await runWithDeps(makeDevice(), deps);207    expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });208    expect(deps.openRelaySession).not.toHaveBeenCalled();209  });210211  it("throws when the device has no longTermPublicKeyHex", async () => {212    await bootVault();213    const { runWithDeps } = await import("./sync-runner");214    const deps: SyncRunnerDeps = {215      openRelaySession: vi.fn(),216      getSyncSettings: relaySettings,217      createAdapter: emptyAdapter,218    };219    await expect(220      runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),221    ).rejects.toThrow("paired_device_missing_pubkey");222  });223224  it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {225    await bootVault();226    const { runWithDeps } = await import("./sync-runner");227    const deps: SyncRunnerDeps = {228      openRelaySession: vi.fn(),229      getSyncSettings: relaySettings,230      createAdapter: emptyAdapter,231    };232    await expect(233      runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),234    ).rejects.toThrow("paired_device_missing_rendezvous_secret");235  });236237  it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {238    await bootVault();239    const { session, peer } = inMemoryPair();240    const openRelaySession = vi.fn(async () => session);241    const deps: SyncRunnerDeps = {242      openRelaySession,243      getSyncSettings: relaySettings,244      createAdapter: emptyAdapter,245    };246    const { runWithDeps } = await import("./sync-runner");247    const runPromise = runWithDeps(makeDevice(), deps);248    await runPeer(peer, {249      peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },250    });251    const result = await runPromise;252    expect(result.ok).toBe(true);253    if (result.ok) {254      expect(result.entriesReceived).toBe(3);255      expect(result.entriesSent).toBe(0);256    }257  });258259  it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {260    await bootVault();261    const { session, peer } = inMemoryPair();262    const adapter: SyncVaultAdapter = {263      ...emptyAdapter(),264      async getEncryptedEntries(ids) {265        const out: Record<string, string> = {};266        for (const id of ids) out[id] = "ENVELOPE_" + id;267        return out;268      },269    };270    const deps: SyncRunnerDeps = {271      openRelaySession: vi.fn(async () => session),272      getSyncSettings: relaySettings,273      createAdapter: () => adapter,274    };275    const { runWithDeps } = await import("./sync-runner");276    const runPromise = runWithDeps(makeDevice(), deps);277    await runPeer(peer, { wantFromUsIds: ["a", "b"] });278    const result = await runPromise;279    expect(result.ok).toBe(true);280    if (result.ok) expect(result.entriesSent).toBe(2);281  });282283  it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {284    await bootVault();285    const { session, peer } = inMemoryPair();286    const deps: SyncRunnerDeps = {287      openRelaySession: vi.fn(async () => session),288      getSyncSettings: relaySettings,289      createAdapter: emptyAdapter,290    };291    const { runWithDeps } = await import("./sync-runner");292    const runPromise = runWithDeps(makeDevice(), deps);293    // Peer responds with a vault_id that does NOT match ours, forcing the294    // coordinator to send SYNC_ERROR and return status:"vault_mismatch".295    (async () => {296      const hello = await peer.receive();297      if (hello.type === "SYNC_HELLO") {298        await peer.send({299          type: "SYNC_HELLO",300          vaultId: "wrong-vault-id",301          deviceId: "peer-device",302          version: 0,303          deviceTime: Date.now(),304        });305      }306    })().catch(() => undefined);307    await expect(runPromise).rejects.toThrow("vault_id_mismatch");308  });309});310311describe("sync-trigger persistence", () => {312  let local: StorageShape;313  let sync: StorageShape;314315  beforeEach(() => {316    vi.resetModules();317    local = {};318    sync = {};319    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);320    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));321  });322323  it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {324    const trigger = await import("./sync-trigger");325    const paired = await import("./paired-devices");326    trigger._resetSyncTriggerForTest();327    await paired.upsertPairedDevice({328      deviceId: "P",329      deviceName: "Pixel",330      deviceType: "android",331      longTermPublicKeyHex: "a".repeat(64),332      trustSource: "direct",333      pairedAt: 1700000000000,334    });335    trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));336    await trigger.triggerSyncIfReachable({ reason: "manual" });337    expect(typeof local.peach_last_sync_at).toBe("number");338    expect(local.peach_last_sync_at).toBeGreaterThan(0);339  });340341  it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {342    local.peach_last_sync_at = 1_700_005_000_000;343    const trigger = await import("./sync-trigger");344    trigger._resetSyncTriggerForTest();345    expect(trigger.getSyncState().lastSyncAt).toBeUndefined();346    await trigger.hydratePersistedSyncState();347    expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);348  });349});