sync-trigger.test.ts

17.2 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  recordInboundSyncCompletion,12} from "./sync-trigger";1314function makeStorageStub() {15  const sync = new Map<string, unknown>();16  const local = new Map<string, unknown>();17  function area(store: Map<string, unknown>) {18    return {19      async get(key?: string | string[] | null) {20        if (key === null || key === undefined) {21          const all: Record<string, unknown> = {};22          for (const [k, v] of store) all[k] = v;23          return all;24        }25        const keys = Array.isArray(key) ? key : [key];26        const out: Record<string, unknown> = {};27        for (const k of keys) if (store.has(k)) out[k] = store.get(k);28        return out;29      },30      async set(values: Record<string, unknown>) {31        for (const [k, v] of Object.entries(values)) store.set(k, v);32      },33      async remove(key: string | string[]) {34        const keys = Array.isArray(key) ? key : [key];35        for (const k of keys) store.delete(k);36      },37    };38  }39  (globalThis as unknown as { chrome: unknown }).chrome = {40    storage: { sync: area(sync), local: area(local) },41    runtime: { sendMessage: () => Promise.resolve() },42  };43}4445const ANDROID: PairedDevice = {46  deviceId: "AAA-android",47  deviceName: "Pixel",48  deviceType: "android",49  longTermPublicKeyHex: "a".repeat(64),50  trustSource: "direct",51  pairedAt: 1700000000000,52};5354const IOS: PairedDevice = {55  deviceId: "BBB-ios",56  deviceName: "iPhone",57  deviceType: "ios",58  longTermPublicKeyHex: "b".repeat(64),59  trustSource: "direct",60  pairedAt: 1700000000000,61};6263const EXT: PairedDevice = {64  deviceId: "CCC-ext",65  deviceName: "Other Chrome",66  deviceType: "extension",67  longTermPublicKeyHex: "c".repeat(64),68  pairedAt: 1700000000000,69};7071describe("triggerSyncIfReachable", () => {72  beforeEach(() => {73    makeStorageStub();74    _resetSyncTriggerForTest();75  });7677  it("does not call the runner when no mobile hosts are paired", async () => {78    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));79    setSyncRunner(runner);80    await upsertPairedDevice(EXT);81    await triggerSyncIfReachable({ reason: "manual" });82    expect(runner).not.toHaveBeenCalled();83  });8485  it("reports a healthy waiting state when this device owns the listener role", async () => {86    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));87    setSyncRunner(runner);88    setSyncDialTargetSelector(async () => []);89    await upsertPairedDevice(ANDROID);9091    await triggerSyncIfReachable({ reason: "manual" });9293    expect(getSyncState()).toMatchObject({94      status: "idle",95      outcome: "waiting_for_peer",96      detail: "Pixel [type=android, trust=direct]",97    });98    recordInboundSyncCompletion(3, 2);99    expect(getSyncState()).toMatchObject({100      status: "idle",101      outcome: "completed_changes",102      entriesReceived: 3,103      entriesSent: 2,104    });105106    expect(runner).not.toHaveBeenCalled();107  });108109  it("calls the runner once per mobile host on a single trigger", async () => {110    const seen: string[] = [];111    setSyncRunner(async (device) => {112      seen.push(device.deviceId);113      return { ok: true, entriesReceived: 1, entriesSent: 0 };114    });115    await upsertPairedDevice(ANDROID);116    await upsertPairedDevice(IOS);117    await upsertPairedDevice(EXT);118    await triggerSyncIfReachable({ reason: "manual" });119    expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);120  });121122  it("marks lastSyncedAt on each host that completes successfully", async () => {123    setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));124    await upsertPairedDevice(ANDROID);125    await upsertPairedDevice(IOS);126    await triggerSyncIfReachable({ reason: "manual" });127    const a = await findPairedDeviceById(ANDROID.deviceId);128    const b = await findPairedDeviceById(IOS.deviceId);129    expect(typeof a?.lastSyncedAt).toBe("number");130    expect(typeof b?.lastSyncedAt).toBe("number");131  });132133  it("does not start a second sync if one is already in flight (non-force)", async () => {134    const runner = vi.fn(async () => {135      await new Promise<void>((r) => setTimeout(r, 50));136      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };137    });138    setSyncRunner(runner);139    await upsertPairedDevice(ANDROID);140    const a = triggerSyncIfReachable({ reason: "vault_change" });141    const b = triggerSyncIfReachable({ reason: "vault_change" });142    await Promise.all([a, b]);143    expect(runner).toHaveBeenCalledTimes(1);144  });145146  it("force:true bypasses the syncInFlight guard for manual triggers", async () => {147    const runner = vi.fn(async () => {148      await new Promise<void>((r) => setTimeout(r, 30));149      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };150    });151    setSyncRunner(runner);152    await upsertPairedDevice(ANDROID);153    const a = triggerSyncIfReachable({ reason: "vault_change" });154    const b = triggerSyncIfReachable({ reason: "manual", force: true });155    await Promise.all([a, b]);156    expect(runner).toHaveBeenCalledTimes(2);157  });158159  it("transitions to error state when no host succeeds", async () => {160    setSyncRunner(async () => {161      throw new Error("unreachable");162    });163    await upsertPairedDevice(ANDROID);164    await triggerSyncIfReachable({ reason: "manual" });165    expect(getSyncState().status).toBe("error");166    expect(getSyncState().error).toBe("unreachable");167  });168169  it("returns to idle state on success with aggregated entry counts", async () => {170    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));171    await upsertPairedDevice(ANDROID);172    await upsertPairedDevice(IOS);173    await triggerSyncIfReachable({ reason: "manual" });174    const s = getSyncState();175    expect(s.status).toBe("idle");176    expect(s.entriesReceived).toBe(6);177    expect(s.entriesSent).toBe(4);178    expect(typeof s.lastSyncAt).toBe("number");179  });180181  it("syncInFlight is cleared in finally even when runner throws", async () => {182    setSyncRunner(async () => { throw new Error("runner exploded"); });183    await upsertPairedDevice(ANDROID);184    await triggerSyncIfReachable({ reason: "manual", force: true });185    // After the trigger resolves, a second non-force trigger must be able to run.186    let ran = false;187    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });188    await triggerSyncIfReachable({ reason: "vault_change" });189    expect(ran).toBe(true);190  });191192  it("syncs a vouched device — mesh introduction is the point of vouching", async () => {193    // A device only reaches the registry as "vouched" after the user confirms194    // it out of the MV-7 pending queue (CONFIRM_VOUCHED_DEVICE). Skipping it195    // here made that confirmation a no-op: the device was listed but never196    // synced. An UNconfirmed device is still never a target — it is absent197    // from the registry entirely, which the vault-adapter specs cover.198    const vouched: PairedDevice = {199      ...ANDROID,200      deviceId: "VVV-vouched",201      trustSource: "vouched",202    };203    const seen: string[] = [];204    setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });205    await upsertPairedDevice(vouched);206    await triggerSyncIfReachable({ reason: "foreground" });207    expect(seen).toEqual(["VVV-vouched"]);208  });209210  it("keeps the legacy no-selector test fallback limited to native hosts", async () => {211    let ran = false;212    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });213    await upsertPairedDevice({214      ...ANDROID,215      deviceId: "EXT-other",216      deviceType: "extension",217      trustSource: "direct",218    });219    await triggerSyncIfReachable({ reason: "foreground" });220    expect(ran).toBe(false);221  });222223  it("lets the production role selector choose an extension dial target", async () => {224    const seen: string[] = [];225    setSyncRunner(async (device) => {226      seen.push(device.deviceId);227      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };228    });229    setSyncDialTargetSelector(async (devices) =>230      devices.filter((device) => device.deviceType === "extension")231    );232    await upsertPairedDevice({233      ...EXT,234      trustSource: "vouched",235    });236237    await triggerSyncIfReachable({ reason: "foreground" });238239    expect(seen).toEqual(["CCC-ext"]);240  });241242  it("persists peach_last_sync_result to storage after a successful sync", async () => {243    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));244    await upsertPairedDevice(ANDROID);245    await triggerSyncIfReachable({ reason: "manual" });246    // Read the raw storage value to confirm persistence.247    const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })248      .chrome.storage.local.get("peach_last_sync_result");249    expect(raw.peach_last_sync_result).toMatchObject({250      outcome: "completed_changes",251      entriesReceived: 3,252      entriesSent: 1,253    });254    expect(typeof (raw.peach_last_sync_result as { at: unknown }).at).toBe("number");255  });256257  // Every terminating path must be distinguishable. Before this, "nobody to258  // sync with", "runner not wired" and "synced, nothing to do" were all a bare259  // `return` — the user and the logs saw the same nothing in all three cases.260  describe("reported outcomes", () => {261    it("reports no_paired_devices when the registry is empty", async () => {262      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));263      await triggerSyncIfReachable({ reason: "manual" });264      const s = getSyncState();265      expect(s.outcome).toBe("no_paired_devices");266      expect(s.status).toBe("idle");267      expect(typeof s.finishedAt).toBe("number");268    });269270    it("reports no_eligible_host naming the deviceType and trustSource found", async () => {271      // This is the diagnostic that matters on real hardware: a device that272      // paired fine but whose type/trust puts it outside the host filter.273      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));274      await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" });275      await upsertPairedDevice({276        ...ANDROID,277        deviceId: "untrusted-android",278        deviceName: "Pixel",279        trustSource: undefined,280      });281      await triggerSyncIfReachable({ reason: "manual" });282      const s = getSyncState();283      expect(s.outcome).toBe("no_eligible_host");284      expect(s.status).toBe("error");285      expect(s.detail).toContain("Other Chrome [type=extension, trust=unset]");286      expect(s.detail).toContain("Pixel [type=android, trust=unset]");287    });288289    it("reports runner_unavailable when the runner slot is never wired", async () => {290      setSyncRunner(null);291      await upsertPairedDevice(ANDROID);292      await triggerSyncIfReachable({ reason: "manual" });293      const s = getSyncState();294      expect(s.outcome).toBe("runner_unavailable");295      expect(s.status).toBe("error");296    });297298    it("distinguishes completed_no_changes from a failure", async () => {299      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));300      await upsertPairedDevice(ANDROID);301      await triggerSyncIfReachable({ reason: "manual" });302      const s = getSyncState();303      expect(s.outcome).toBe("completed_no_changes");304      expect(s.status).toBe("idle");305      expect(s.error).toBeUndefined();306    });307308    it("surfaces a runner throw as its own message rather than swallowing it", async () => {309      setSyncRunner(async () => {310        throw new Error("relay_sync_failed:WebSocket connect timed out");311      });312      await upsertPairedDevice(ANDROID);313      await triggerSyncIfReachable({ reason: "manual" });314      const s = getSyncState();315      expect(s.outcome).toBe("relay_unreachable");316      expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out");317      // relay_unreachable is now a network-class warning, not a failure — the318      // detail carries the diagnostic but `error` stays undefined so the319      // indicator does not turn red for what is a transient miss.320      expect(s.error).toBeUndefined();321    });322323    it.each([324      ["sync_disabled", "sync_disabled"],325      ["private_relay_unset", "private_relay_unset"],326      ["vault_id_mismatch", "vault_id_mismatch"],327      ["PEER_PROTOCOL_OUTDATED", "peer_protocol_outdated"],328      ["sync_timeout", "sync_timeout"],329      ["VAULT_LOCKED", "vault_locked"],330      ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],331      ["something nobody predicted", "run_failed"],332    ])("classifies runner error %s as %s", async (error, outcome) => {333      setSyncRunner(async () => ({ ok: false, error }));334      await upsertPairedDevice(ANDROID);335      await triggerSyncIfReachable({ reason: "manual", force: true });336      expect(getSyncState().outcome).toBe(outcome);337    });338339    it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => {340      setSyncRunner(async () => {341        await new Promise<void>((r) => setTimeout(r, 30));342        return { ok: true as const, entriesReceived: 0, entriesSent: 0 };343      });344      await upsertPairedDevice(ANDROID);345      const first = triggerSyncIfReachable({ reason: "manual" });346      while (getSyncState().status !== "syncing") await Promise.resolve();347      await triggerSyncIfReachable({ reason: "vault_change" });348      // The dropped trigger must not stamp a terminal outcome over a live run.349      expect(getSyncState().status).toBe("syncing");350      await first;351      expect(getSyncState().outcome).toBe("completed_no_changes");352    });353354    it("restores the last outcome across a SW restart so a failure stays inspectable", async () => {355      setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" }));356      await upsertPairedDevice(ANDROID);357      await triggerSyncIfReachable({ reason: "manual" });358      _resetSyncTriggerForTest();359      expect(getSyncState().outcome).toBeUndefined();360      await hydratePersistedSyncState();361      const s = getSyncState();362      expect(s.outcome).toBe("vault_id_mismatch");363      expect(s.detail).toBe("vault_id_mismatch");364      expect(typeof s.finishedAt).toBe("number");365    });366  });367368  it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {369    // Write a value directly to storage, then hydrate — simulates SW restart.370    await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })371      .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });372    _resetSyncTriggerForTest();373    expect(getSyncState().lastSyncAt).toBeUndefined();374    await hydratePersistedSyncState();375    expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);376  });377});378379describe("triggerSyncIfReachable — the §11 sync generation gate", () => {380  beforeEach(() => {381    makeStorageStub();382    _resetSyncTriggerForTest();383  });384385  function setGeneration(state: string) {386    return (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })387      .chrome.storage.local.set({388        peach_sync_generation_v1: JSON.stringify({389          schemaVersion: 1,390          active: { schemaVersion: 1, generation: 1, state, createdAt: 1 },391        }),392      });393  }394395  it("refuses to dial while this device owes a secure reset", async () => {396    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));397    setSyncRunner(runner);398    await upsertPairedDevice(ANDROID);399    await setGeneration("RESET_REQUIRED");400401    await triggerSyncIfReachable({ reason: "manual" });402403    expect(runner).not.toHaveBeenCalled();404    expect(getSyncState()).toMatchObject({ status: "error", outcome: "reset_required" });405  });406407  it("refuses to dial when the generation cannot be read", async () => {408    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));409    setSyncRunner(runner);410    await upsertPairedDevice(ANDROID);411    await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })412      .chrome.storage.local.set({ peach_sync_generation_v1: "{ not json" });413414    await triggerSyncIfReachable({ reason: "manual" });415416    expect(runner).not.toHaveBeenCalled();417    expect(getSyncState()).toMatchObject({ outcome: "sync_generation_unknown" });418  });419420  it("still syncs an install that has never reset, and one that already has", async () => {421    const seen: string[] = [];422    setSyncRunner(async (device) => {423      seen.push(device.deviceId);424      return { ok: true, entriesReceived: 0, entriesSent: 0 };425    });426    await upsertPairedDevice(ANDROID);427428    // No record yet: the presence of a paired device derives LEGACY_ACTIVE.429    await triggerSyncIfReachable({ reason: "manual" });430    expect(seen).toEqual([ANDROID.deviceId]);431432    await setGeneration("V2_ACTIVE");433    seen.length = 0;434    await triggerSyncIfReachable({ reason: "manual" });435    expect(seen).toEqual([ANDROID.deviceId]);436  });437});