sync-runner.test.ts

14.7 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(12  local: StorageShape,13  sync: StorageShape,14  session: StorageShape = {},15): typeof chrome {16  const area = (store: StorageShape) => ({17    get: vi.fn(async (key?: string | string[]) => {18      if (!key) return store;19      if (typeof key === "string") return { [key]: store[key] };20      return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});21    }),22    set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),23    remove: vi.fn(async (keys: string | string[]) => {24      const list = Array.isArray(keys) ? keys : [keys];25      for (const k of list) delete store[k];26    }),27  });28  return {29    storage: { local: area(local), sync: area(sync), session: area(session) },30    alarms: {31      clear: vi.fn(),32      create: vi.fn(),33      onAlarm: { addListener: vi.fn() },34    },35    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },36    runtime: { sendMessage: vi.fn(async () => undefined) },37  } as unknown as typeof chrome;38}3940const REMOTE_PUBKEY_HEX = "ab".repeat(32);4142function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {43  return {44    deviceId: "pixel-xyz",45    deviceName: "Pixel",46    deviceType: "android",47    longTermPublicKeyHex: REMOTE_PUBKEY_HEX,48    rendezvousSecretHex: "11".repeat(32),49    wsPort: 49142,50    pairedAt: 1700000000000,51    ...overrides,52  };53}5455interface FakePeer {56  send(msg: SyncMessage): Promise<void>;57  receive(): Promise<SyncMessage>;58}5960/**61 * In-memory SyncSession ↔ FakePeer pair. The session is what the62 * runner passes to runSyncInitiator; the peer is the other end of the63 * wire, controlled by the test body. Mirrors the pattern in64 * sync-coordinator.test.ts.65 */66function inMemoryPair(): { session: SyncSession; peer: FakePeer; closed: { v: boolean } } {67  const inboundToSession: Uint8Array[] = [];68  const outboundFromSession: Uint8Array[] = [];69  const sessionReaders: Array<(v: Uint8Array) => void> = [];70  const peerReaders: Array<(v: Uint8Array) => void> = [];71  const closed = { v: false };7273  const session: SyncSession = {74    send(bytes) {75      const r = peerReaders.shift();76      if (r) r(bytes); else outboundFromSession.push(bytes);77    },78    receive(): Promise<Uint8Array> {79      const v = inboundToSession.shift();80      if (v) return Promise.resolve(v);81      return new Promise<Uint8Array>((res) => sessionReaders.push(res));82    },83    async close() { closed.v = true; },84    remoteStaticPublicKey: new Uint8Array(32),85  };86  const peer: FakePeer = {87    async send(msg) {88      const bytes = new TextEncoder().encode(JSON.stringify(msg));89      const r = sessionReaders.shift();90      if (r) r(bytes); else inboundToSession.push(bytes);91    },92    async receive() {93      const v = outboundFromSession.shift();94      const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));95      return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;96    },97  };98  return { session, peer, closed };99}100101/**102 * Drive a happy-path SYNC_DONE handshake on the peer side, matching the103 * coordinator's HELLO → MANIFEST → REQUEST → ENTRIES → TOMBSTONES → DONE104 * sequence. `peerEntries` lets the peer send a SYNC_ENTRIES payload of any105 * size for the entriesReceived assertion; `wantFromUs` requests N entries106 * from our side (drives entriesSent).107 */108async function runPeer(109  peer: FakePeer,110  opts: {111    vaultId?: string;112    peerEntriesB64?: Record<string, string>;113    wantFromUsIds?: string[];114  } = {},115): Promise<void> {116  const hello = await peer.receive();117  if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");118  await peer.send({119    type: "SYNC_HELLO",120    vaultId: opts.vaultId ?? hello.vaultId,121    deviceId: "peer-device",122    version: 0,123    deviceTime: Date.now(),124    protocolVersion: 3,125  });126  const manifest = await peer.receive();127  if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");128129  // Peer manifest declares the entries it will ship in SYNC_ENTRIES.130  const peerEntryManifest: Record<string, number> = {};131  for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {132    peerEntryManifest[id] = Date.now();133  }134  await peer.send({135    type: "SYNC_MANIFEST",136    entries: peerEntryManifest,137    tombstones: {},138  });139140  const ourRequest = await peer.receive();141  if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");142143  // Peer asks for the ids it wants from us.144  await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });145146  const ourEntries = await peer.receive();147  if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");148149  // Only ship SYNC_ENTRIES when the initiator actually requested any. The150  // coordinator only awaits this frame when wantFromPeer.length > 0.151  if (Object.keys(peerEntryManifest).length > 0) {152    await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });153  }154155  const ourTombstones = await peer.receive();156  if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");157  await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });158159  const done = await peer.receive();160  if (done.type !== "SYNC_DONE") throw new Error("expected DONE");161  await peer.send({ type: "SYNC_DONE" });162}163164function emptyAdapter(): SyncVaultAdapter {165  return {166    async computeVaultId() { return "vault-abc"; },167    async getOurDeviceId() { return "device-init"; },168    // Non-zero so the equal-version short circuit doesn't fire in runner tests169    // that exercise the full manifest-diff flow.170    async getLocalVersion() { return 5; },171    async buildEntryManifest() { return {}; },172    async buildTombstoneManifest() { return {}; },173    async getEncryptedEntries() { return {}; },174    async applyEntries() { /* no-op */ },175    async applyTombstones() { /* no-op */ },176  };177}178179const relaySettings = async () => ({180  mode: "public" as const,181  relayUrl: "wss://relay.peachpasswords.com",182});183184describe("productionSyncRunner", () => {185  let local: StorageShape;186  let sync: StorageShape;187  let sessionStore: StorageShape;188189  beforeEach(() => {190    vi.resetModules();191    local = {};192    sync = {};193    sessionStore = {};194    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync, sessionStore);195    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));196  });197198  async function bootVault(): Promise<typeof import("./vault")> {199    const vault = await import("./vault");200    await vault.createVault("correct horse battery staple");201    // Every real install mints its identity with (or before) its vault, so a202    // fixture that seeds trust state without one is an install §11.5 refuses.203    const identity = await import("./device-identity");204    await identity.getOrCreateDeviceId();205    return vault;206  }207208  // PB-26-213: device rows carry no reusable secrets; the runner resolves209  // them from the encrypted pair-secret record / session copy.210  async function seedPairSecret(secret = "11".repeat(32)): Promise<void> {211    const vault = await import("./vault");212    const rootHex = vault.getRootKeyHex();213    if (!rootHex) throw new Error("vault must be unlocked to seed pair secrets");214    const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));215    try {216      const { setPairSecret } = await import("./pair-secrets");217      await setPairSecret("pixel-xyz", secret, root);218    } finally {219      root.fill(0);220    }221  }222223  // PB-26-104: locked sync has to keep working with the spool moved to224  // chrome.storage.session. The previous fix removed the private key from the225  // spool outright, which silently turned every locked round into VAULT_LOCKED.226  it("completes a sync round while locked, using the session-scoped spool", async () => {227    const vault = await bootVault();228    await vault.addEntry({ type: "login", title: "Locked Round", password: "swordfish" });229    const { refreshLockedSyncSpool } = await import("./sync-vault-adapter");230    await refreshLockedSyncSpool();231    await seedPairSecret();232    vault.lock();233    expect(vault.isUnlocked()).toBe(false);234235    const { session, peer } = inMemoryPair();236    const deps: SyncRunnerDeps = {237      openRelaySession: vi.fn(async () => session),238      getSyncSettings: relaySettings,239      createAdapter: emptyAdapter,240    };241    const { runWithDeps } = await import("./sync-runner");242    const runPromise = runWithDeps(makeDevice(), deps);243    await runPeer(peer, {});244    const result = await runPromise;245246    expect(result.ok).toBe(true);247    // Nothing about the spool reached disk-backed storage.248    expect(JSON.stringify(local)).not.toContain("longTermPrivateKey");249    expect(local.peach_locked_sync_spool_v1).toBeUndefined();250  });251252  it("returns VAULT_LOCKED result (does not throw) when the vault is locked", async () => {253    const vault = await bootVault();254    vault.lock();255    const { runWithDeps } = await import("./sync-runner");256    const deps: SyncRunnerDeps = {257      openRelaySession: vi.fn(),258      getSyncSettings: relaySettings,259      createAdapter: emptyAdapter,260    };261    const result = await runWithDeps(makeDevice(), deps);262    expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });263    expect(deps.openRelaySession).not.toHaveBeenCalled();264  });265266  it("throws when the device has no longTermPublicKeyHex", async () => {267    await bootVault();268    const { runWithDeps } = await import("./sync-runner");269    const deps: SyncRunnerDeps = {270      openRelaySession: vi.fn(),271      getSyncSettings: relaySettings,272      createAdapter: emptyAdapter,273    };274    await expect(275      runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),276    ).rejects.toThrow("paired_device_missing_pubkey");277  });278279  it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {280    await bootVault();281    const { runWithDeps } = await import("./sync-runner");282    const deps: SyncRunnerDeps = {283      openRelaySession: vi.fn(),284      getSyncSettings: relaySettings,285      createAdapter: emptyAdapter,286    };287    await expect(288      runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),289    ).rejects.toThrow("paired_device_missing_rendezvous_secret");290  });291292  it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {293    await bootVault();294    await seedPairSecret();295    const { session, peer } = inMemoryPair();296    const openRelaySession = vi.fn(async () => session);297    const deps: SyncRunnerDeps = {298      openRelaySession,299      getSyncSettings: relaySettings,300      createAdapter: emptyAdapter,301    };302    const { runWithDeps } = await import("./sync-runner");303    const runPromise = runWithDeps(makeDevice(), deps);304    await runPeer(peer, {305      peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },306    });307    const result = await runPromise;308    expect(result.ok).toBe(true);309    if (result.ok) {310      expect(result.entriesReceived).toBe(3);311      expect(result.entriesSent).toBe(0);312    }313  });314315  it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {316    await bootVault();317    await seedPairSecret();318    const { session, peer } = inMemoryPair();319    const adapter: SyncVaultAdapter = {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      openRelaySession: vi.fn(async () => session),329      getSyncSettings: relaySettings,330      createAdapter: () => adapter,331    };332    const { runWithDeps } = await import("./sync-runner");333    const runPromise = runWithDeps(makeDevice(), deps);334    await runPeer(peer, { wantFromUsIds: ["a", "b"] });335    const result = await runPromise;336    expect(result.ok).toBe(true);337    if (result.ok) expect(result.entriesSent).toBe(2);338  });339340  it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {341    await bootVault();342    await seedPairSecret();343    const { session, peer } = inMemoryPair();344    const deps: SyncRunnerDeps = {345      openRelaySession: vi.fn(async () => session),346      getSyncSettings: relaySettings,347      createAdapter: emptyAdapter,348    };349    const { runWithDeps } = await import("./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          version: 0,361          deviceTime: Date.now(),362          protocolVersion: 3,363        });364      }365    })().catch(() => undefined);366    await expect(runPromise).rejects.toThrow("vault_id_mismatch");367  });368});369370describe("sync-trigger persistence", () => {371  let local: StorageShape;372  let sync: StorageShape;373374  beforeEach(() => {375    vi.resetModules();376    local = {};377    sync = {};378    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);379    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));380  });381382  it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {383    const trigger = await import("./sync-trigger");384    const paired = await import("./paired-devices");385    trigger._resetSyncTriggerForTest();386    await paired.upsertPairedDevice({387      deviceId: "P",388      deviceName: "Pixel",389      deviceType: "android",390      longTermPublicKeyHex: "a".repeat(64),391      trustSource: "direct",392      pairedAt: 1700000000000,393    });394    trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));395    await trigger.triggerSyncIfReachable({ reason: "manual" });396    expect(typeof local.peach_last_sync_at).toBe("number");397    expect(local.peach_last_sync_at).toBeGreaterThan(0);398  });399400  it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {401    local.peach_last_sync_at = 1_700_005_000_000;402    const trigger = await import("./sync-trigger");403    trigger._resetSyncTriggerForTest();404    expect(trigger.getSyncState().lastSyncAt).toBeUndefined();405    await trigger.hydratePersistedSyncState();406    expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);407  });408});