sync-trigger.test.ts

17.0 KB · raw

1import { describe, expect, it, beforeEach, vi } from "vitest";2import type { PairedDevice } from "../shared/sync-types";3import { upsertPairedDevice, findPairedDeviceById } from "./paired-devices";4import {5  triggerSyncIfReachable,6  setSyncDialTargetSelector,7  setSyncRunner,8  _resetSyncTriggerForTest,9  getSyncState,10  hydratePersistedSyncState,11} from "./sync-trigger";1213function makeStorageStub() {14  const sync = new Map<string, unknown>();15  const local = new Map<string, unknown>();16  function area(store: Map<string, unknown>) {17    return {18      async get(key?: string | string[] | null) {19        if (key === null || key === undefined) {20          const all: Record<string, unknown> = {};21          for (const [k, v] of store) all[k] = v;22          return all;23        }24        const keys = Array.isArray(key) ? key : [key];25        const out: Record<string, unknown> = {};26        for (const k of keys) if (store.has(k)) out[k] = store.get(k);27        return out;28      },29      async set(values: Record<string, unknown>) {30        for (const [k, v] of Object.entries(values)) store.set(k, v);31      },32      async remove(key: string | string[]) {33        const keys = Array.isArray(key) ? key : [key];34        for (const k of keys) store.delete(k);35      },36    };37  }38  (globalThis as unknown as { chrome: unknown }).chrome = {39    storage: { sync: area(sync), local: area(local) },40    runtime: { sendMessage: () => Promise.resolve() },41  };42}4344const ANDROID: PairedDevice = {45  deviceId: "AAA-android",46  deviceName: "Pixel",47  deviceType: "android",48  longTermPublicKeyHex: "a".repeat(64),49  trustSource: "direct",50  pairedAt: 1700000000000,51};5253const IOS: PairedDevice = {54  deviceId: "BBB-ios",55  deviceName: "iPhone",56  deviceType: "ios",57  longTermPublicKeyHex: "b".repeat(64),58  trustSource: "direct",59  pairedAt: 1700000000000,60};6162const EXT: PairedDevice = {63  deviceId: "CCC-ext",64  deviceName: "Other Chrome",65  deviceType: "extension",66  longTermPublicKeyHex: "c".repeat(64),67  pairedAt: 1700000000000,68};6970describe("triggerSyncIfReachable", () => {71  beforeEach(() => {72    makeStorageStub();73    _resetSyncTriggerForTest();74  });7576  it("does not call the runner when no mobile hosts are paired", async () => {77    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));78    setSyncRunner(runner);79    await upsertPairedDevice(EXT);80    await triggerSyncIfReachable({ reason: "manual" });81    expect(runner).not.toHaveBeenCalled();82  });8384  it("reports a healthy waiting state when this device owns the listener role", async () => {85    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));86    setSyncRunner(runner);87    setSyncDialTargetSelector(async () => []);88    await upsertPairedDevice(ANDROID);8990    await triggerSyncIfReachable({ reason: "manual" });9192    expect(runner).not.toHaveBeenCalled();93    expect(getSyncState()).toMatchObject({94      status: "idle",95      outcome: "waiting_for_peer",96      detail: "Pixel [type=android, trust=direct]",97    });98  });99100  it("calls the runner once per mobile host on a single trigger", async () => {101    const seen: string[] = [];102    setSyncRunner(async (device) => {103      seen.push(device.deviceId);104      return { ok: true, entriesReceived: 1, entriesSent: 0 };105    });106    await upsertPairedDevice(ANDROID);107    await upsertPairedDevice(IOS);108    await upsertPairedDevice(EXT);109    await triggerSyncIfReachable({ reason: "manual" });110    expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);111  });112113  it("marks lastSyncedAt on each host that completes successfully", async () => {114    setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));115    await upsertPairedDevice(ANDROID);116    await upsertPairedDevice(IOS);117    await triggerSyncIfReachable({ reason: "manual" });118    const a = await findPairedDeviceById(ANDROID.deviceId);119    const b = await findPairedDeviceById(IOS.deviceId);120    expect(typeof a?.lastSyncedAt).toBe("number");121    expect(typeof b?.lastSyncedAt).toBe("number");122  });123124  it("does not start a second sync if one is already in flight (non-force)", async () => {125    const runner = vi.fn(async () => {126      await new Promise<void>((r) => setTimeout(r, 50));127      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };128    });129    setSyncRunner(runner);130    await upsertPairedDevice(ANDROID);131    const a = triggerSyncIfReachable({ reason: "vault_change" });132    const b = triggerSyncIfReachable({ reason: "vault_change" });133    await Promise.all([a, b]);134    expect(runner).toHaveBeenCalledTimes(1);135  });136137  it("force:true bypasses the syncInFlight guard for manual triggers", async () => {138    const runner = vi.fn(async () => {139      await new Promise<void>((r) => setTimeout(r, 30));140      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };141    });142    setSyncRunner(runner);143    await upsertPairedDevice(ANDROID);144    const a = triggerSyncIfReachable({ reason: "vault_change" });145    const b = triggerSyncIfReachable({ reason: "manual", force: true });146    await Promise.all([a, b]);147    expect(runner).toHaveBeenCalledTimes(2);148  });149150  it("transitions to error state when no host succeeds", async () => {151    setSyncRunner(async () => {152      throw new Error("unreachable");153    });154    await upsertPairedDevice(ANDROID);155    await triggerSyncIfReachable({ reason: "manual" });156    expect(getSyncState().status).toBe("error");157    expect(getSyncState().error).toBe("unreachable");158  });159160  it("returns to idle state on success with aggregated entry counts", async () => {161    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));162    await upsertPairedDevice(ANDROID);163    await upsertPairedDevice(IOS);164    await triggerSyncIfReachable({ reason: "manual" });165    const s = getSyncState();166    expect(s.status).toBe("idle");167    expect(s.entriesReceived).toBe(6);168    expect(s.entriesSent).toBe(4);169    expect(typeof s.lastSyncAt).toBe("number");170  });171172  it("syncInFlight is cleared in finally even when runner throws", async () => {173    setSyncRunner(async () => { throw new Error("runner exploded"); });174    await upsertPairedDevice(ANDROID);175    await triggerSyncIfReachable({ reason: "manual", force: true });176    // After the trigger resolves, a second non-force trigger must be able to run.177    let ran = false;178    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });179    await triggerSyncIfReachable({ reason: "vault_change" });180    expect(ran).toBe(true);181  });182183  it("syncs a vouched device — mesh introduction is the point of vouching", async () => {184    // A device only reaches the registry as "vouched" after the user confirms185    // it out of the MV-7 pending queue (CONFIRM_VOUCHED_DEVICE). Skipping it186    // here made that confirmation a no-op: the device was listed but never187    // synced. An UNconfirmed device is still never a target — it is absent188    // from the registry entirely, which the vault-adapter specs cover.189    const vouched: PairedDevice = {190      ...ANDROID,191      deviceId: "VVV-vouched",192      trustSource: "vouched",193    };194    const seen: string[] = [];195    setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });196    await upsertPairedDevice(vouched);197    await triggerSyncIfReachable({ reason: "foreground" });198    expect(seen).toEqual(["VVV-vouched"]);199  });200201  it("keeps the legacy no-selector test fallback limited to native hosts", async () => {202    let ran = false;203    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });204    await upsertPairedDevice({205      ...ANDROID,206      deviceId: "EXT-other",207      deviceType: "extension",208      trustSource: "direct",209    });210    await triggerSyncIfReachable({ reason: "foreground" });211    expect(ran).toBe(false);212  });213214  it("lets the production role selector choose an extension dial target", async () => {215    const seen: string[] = [];216    setSyncRunner(async (device) => {217      seen.push(device.deviceId);218      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };219    });220    setSyncDialTargetSelector(async (devices) =>221      devices.filter((device) => device.deviceType === "extension")222    );223    await upsertPairedDevice({224      ...EXT,225      trustSource: "vouched",226    });227228    await triggerSyncIfReachable({ reason: "foreground" });229230    expect(seen).toEqual(["CCC-ext"]);231  });232233  it("persists peach_last_sync_result to storage after a successful sync", async () => {234    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));235    await upsertPairedDevice(ANDROID);236    await triggerSyncIfReachable({ reason: "manual" });237    // Read the raw storage value to confirm persistence.238    const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })239      .chrome.storage.local.get("peach_last_sync_result");240    expect(raw.peach_last_sync_result).toMatchObject({241      outcome: "completed_changes",242      entriesReceived: 3,243      entriesSent: 1,244    });245    expect(typeof (raw.peach_last_sync_result as { at: unknown }).at).toBe("number");246  });247248  // Every terminating path must be distinguishable. Before this, "nobody to249  // sync with", "runner not wired" and "synced, nothing to do" were all a bare250  // `return` — the user and the logs saw the same nothing in all three cases.251  describe("reported outcomes", () => {252    it("reports no_paired_devices when the registry is empty", async () => {253      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));254      await triggerSyncIfReachable({ reason: "manual" });255      const s = getSyncState();256      expect(s.outcome).toBe("no_paired_devices");257      expect(s.status).toBe("idle");258      expect(typeof s.finishedAt).toBe("number");259    });260261    it("reports no_eligible_host naming the deviceType and trustSource found", async () => {262      // This is the diagnostic that matters on real hardware: a device that263      // paired fine but whose type/trust puts it outside the host filter.264      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));265      await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" });266      await upsertPairedDevice({267        ...ANDROID,268        deviceId: "untrusted-android",269        deviceName: "Pixel",270        trustSource: undefined,271      });272      await triggerSyncIfReachable({ reason: "manual" });273      const s = getSyncState();274      expect(s.outcome).toBe("no_eligible_host");275      expect(s.status).toBe("error");276      expect(s.detail).toContain("Other Chrome [type=extension, trust=unset]");277      expect(s.detail).toContain("Pixel [type=android, trust=unset]");278    });279280    it("reports runner_unavailable when the runner slot is never wired", async () => {281      setSyncRunner(null);282      await upsertPairedDevice(ANDROID);283      await triggerSyncIfReachable({ reason: "manual" });284      const s = getSyncState();285      expect(s.outcome).toBe("runner_unavailable");286      expect(s.status).toBe("error");287    });288289    it("distinguishes completed_no_changes from a failure", async () => {290      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));291      await upsertPairedDevice(ANDROID);292      await triggerSyncIfReachable({ reason: "manual" });293      const s = getSyncState();294      expect(s.outcome).toBe("completed_no_changes");295      expect(s.status).toBe("idle");296      expect(s.error).toBeUndefined();297    });298299    it("surfaces a runner throw as its own message rather than swallowing it", async () => {300      setSyncRunner(async () => {301        throw new Error("relay_sync_failed:WebSocket connect timed out");302      });303      await upsertPairedDevice(ANDROID);304      await triggerSyncIfReachable({ reason: "manual" });305      const s = getSyncState();306      expect(s.outcome).toBe("relay_unreachable");307      expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out");308      // relay_unreachable is now a network-class warning, not a failure — the309      // detail carries the diagnostic but `error` stays undefined so the310      // indicator does not turn red for what is a transient miss.311      expect(s.error).toBeUndefined();312    });313314    it.each([315      ["sync_disabled", "sync_disabled"],316      ["private_relay_unset", "private_relay_unset"],317      ["vault_id_mismatch", "vault_id_mismatch"],318      ["PEER_PROTOCOL_OUTDATED", "peer_protocol_outdated"],319      ["sync_timeout", "sync_timeout"],320      ["VAULT_LOCKED", "vault_locked"],321      ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],322      ["something nobody predicted", "run_failed"],323    ])("classifies runner error %s as %s", async (error, outcome) => {324      setSyncRunner(async () => ({ ok: false, error }));325      await upsertPairedDevice(ANDROID);326      await triggerSyncIfReachable({ reason: "manual", force: true });327      expect(getSyncState().outcome).toBe(outcome);328    });329330    it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => {331      setSyncRunner(async () => {332        await new Promise<void>((r) => setTimeout(r, 30));333        return { ok: true as const, entriesReceived: 0, entriesSent: 0 };334      });335      await upsertPairedDevice(ANDROID);336      const first = triggerSyncIfReachable({ reason: "manual" });337      while (getSyncState().status !== "syncing") await Promise.resolve();338      await triggerSyncIfReachable({ reason: "vault_change" });339      // The dropped trigger must not stamp a terminal outcome over a live run.340      expect(getSyncState().status).toBe("syncing");341      await first;342      expect(getSyncState().outcome).toBe("completed_no_changes");343    });344345    it("restores the last outcome across a SW restart so a failure stays inspectable", async () => {346      setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" }));347      await upsertPairedDevice(ANDROID);348      await triggerSyncIfReachable({ reason: "manual" });349      _resetSyncTriggerForTest();350      expect(getSyncState().outcome).toBeUndefined();351      await hydratePersistedSyncState();352      const s = getSyncState();353      expect(s.outcome).toBe("vault_id_mismatch");354      expect(s.detail).toBe("vault_id_mismatch");355      expect(typeof s.finishedAt).toBe("number");356    });357  });358359  it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {360    // Write a value directly to storage, then hydrate — simulates SW restart.361    await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })362      .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });363    _resetSyncTriggerForTest();364    expect(getSyncState().lastSyncAt).toBeUndefined();365    await hydratePersistedSyncState();366    expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);367  });368});369370describe("triggerSyncIfReachable — the §11 sync generation gate", () => {371  beforeEach(() => {372    makeStorageStub();373    _resetSyncTriggerForTest();374  });375376  function setGeneration(state: string) {377    return (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })378      .chrome.storage.local.set({379        peach_sync_generation_v1: JSON.stringify({380          schemaVersion: 1,381          active: { schemaVersion: 1, generation: 1, state, createdAt: 1 },382        }),383      });384  }385386  it("refuses to dial while this device owes a secure reset", async () => {387    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));388    setSyncRunner(runner);389    await upsertPairedDevice(ANDROID);390    await setGeneration("RESET_REQUIRED");391392    await triggerSyncIfReachable({ reason: "manual" });393394    expect(runner).not.toHaveBeenCalled();395    expect(getSyncState()).toMatchObject({ status: "error", outcome: "reset_required" });396  });397398  it("refuses to dial when the generation cannot be read", async () => {399    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));400    setSyncRunner(runner);401    await upsertPairedDevice(ANDROID);402    await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })403      .chrome.storage.local.set({ peach_sync_generation_v1: "{ not json" });404405    await triggerSyncIfReachable({ reason: "manual" });406407    expect(runner).not.toHaveBeenCalled();408    expect(getSyncState()).toMatchObject({ outcome: "sync_generation_unknown" });409  });410411  it("still syncs an install that has never reset, and one that already has", async () => {412    const seen: string[] = [];413    setSyncRunner(async (device) => {414      seen.push(device.deviceId);415      return { ok: true, entriesReceived: 0, entriesSent: 0 };416    });417    await upsertPairedDevice(ANDROID);418419    // No record yet: the presence of a paired device derives LEGACY_ACTIVE.420    await triggerSyncIfReachable({ reason: "manual" });421    expect(seen).toEqual([ANDROID.deviceId]);422423    await setGeneration("V2_ACTIVE");424    seen.length = 0;425    await triggerSyncIfReachable({ reason: "manual" });426    expect(seen).toEqual([ANDROID.deviceId]);427  });428});