vault.sync-clamp.test.ts

5.9 KB · raw

1// Unit tests for HV-1 — sync timestamp clamp + remote-mutation alerts.2// Uses the same chrome storage mock harness as src/background/vault.test.ts:3// the mock is installed in beforeEach and vault/sync-alerts are imported4// dynamically afterwards, because vault.ts -> alarms.ts touches `chrome` at5// module-evaluation time.67import { beforeEach, describe, expect, it, vi } from "vitest";8import { MAX_CLOCK_SKEW_MS, clampSyncTimestamp } from "../shared/sync-limits";910describe("clampSyncTimestamp", () => {11  it("clamps a far-future timestamp to now + MAX_CLOCK_SKEW_MS", () => {12    const now = 1_750_000_000_000;13    const farFuture = now + 10_000_000;14    expect(clampSyncTimestamp(farFuture, now)).toBe(now + MAX_CLOCK_SKEW_MS);15  });1617  it("passes through timestamps within the skew window", () => {18    const now = 1_750_000_000_000;19    expect(clampSyncTimestamp(now - 1000, now)).toBe(now - 1000);20    expect(clampSyncTimestamp(now + MAX_CLOCK_SKEW_MS - 1, now)).toBe(21      now + MAX_CLOCK_SKEW_MS - 1,22    );23  });24});2526describe("applyMergedRemoteEntries — HV-1", () => {27  let storage: Record<string, unknown>;2829  beforeEach(() => {30    vi.resetModules();31    storage = {};3233    const chromeMock = {34      storage: {35        local: {36          get: vi.fn(async (key?: string | string[]) => {37            if (!key) return storage;38            if (typeof key === "string") return { [key]: storage[key] };39            return key.reduce<Record<string, unknown>>((result, entry) => {40              result[entry] = storage[entry];41              return result;42            }, {});43          }),44          set: vi.fn(async (next: Record<string, unknown>) => {45            Object.assign(storage, next);46          }),47          remove: vi.fn(async (keys: string | string[]) => {48            const list = Array.isArray(keys) ? keys : [keys];49            for (const key of list) delete storage[key];50          }),51        },52      },53      alarms: {54        clear: vi.fn(),55        create: vi.fn(),56        onAlarm: { addListener: vi.fn() },57      },58      idle: {59        setDetectionInterval: vi.fn(),60        onStateChanged: { addListener: vi.fn() },61      },62    } as unknown as typeof chrome;6364    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;6566    vi.stubGlobal(67      "fetch",68      vi.fn(async () => {69        throw new Error("network disabled in tests");70      }),71    );72  });7374  it("clamps a far-future remote modifiedAt so a local edit can win", async () => {75    const vault = await import("./vault");76    await vault.createVault("correct horse battery staple");7778    const local = await vault.addEntry({79      title: "Bank",80      username: "june",81      password: "strong-local-password",82      url: "https://bank.example.com",83      type: "login",84    });8586    // Freeze Date.now() (only Date — real timers stay live) so the two merges87    // share one `now`. The security property is: a clamped far-future value88    // cannot pin the future against the monotonic local clock. With wall-clock89    // drift between merges the attacker's re-clamped value would creep forward90    // within the skew window; freezing time isolates the invariant under test.91    vi.useFakeTimers({ toFake: ["Date"] });92    vi.setSystemTime(new Date(local.modifiedAt + 1000));93    try {94      const farFuture = Date.now() + 10_000_000;95      await vault.applyMergedRemoteEntries(96        [{ ...local, password: "attacker-password", modifiedAt: farFuture }],97        {},98      );99100      const merged = vault.getEntryById(local.id)!;101      // The remote value wins NOW (it is newer than the local edit), but its102      // timestamp is clamped — it cannot pin the far future.103      expect(merged.modifiedAt).toBeLessThanOrEqual(104        Date.now() + MAX_CLOCK_SKEW_MS,105      );106107      // A subsequent local edit (monotonic clock) advances past the clamped108      // value.109      await vault.updateEntry(local.id, { password: "honest-newer-password" });110      // Re-sync the stale attacker value — it must NOT win again.111      await vault.applyMergedRemoteEntries(112        [{ ...merged, password: "attacker-password", modifiedAt: farFuture }],113        {},114      );115      expect(vault.getEntryById(local.id)!.password).toBe(116        "honest-newer-password",117      );118    } finally {119      vi.useRealTimers();120    }121  });122123  it("clamps a far-future tombstone ts before applying", async () => {124    const vault = await import("./vault");125    await vault.createVault("correct horse battery staple");126127    const local = await vault.addEntry({128      title: "Forum",129      username: "june",130      password: "pw",131      url: "https://forum.example.com",132      type: "login",133    });134    const farFuture = Date.now() + 10_000_000;135    await vault.applyMergedRemoteEntries([], { [local.id]: farFuture });136137    const tombstoned = vault138      .getDeletedEntries()139      .find((e) => e.id === local.id)!;140    expect(tombstoned.deletedAt).toBeLessThanOrEqual(141      Date.now() + MAX_CLOCK_SKEW_MS,142    );143  });144145  it("applies remote entry changes quietly without queuing activity alerts", async () => {146    const vault = await import("./vault");147    const { getPendingSyncAlerts, clearPendingSyncAlerts } = await import(148      "./sync-alerts"149    );150    await vault.createVault("correct horse battery staple");151    await clearPendingSyncAlerts();152153    const local = await vault.addEntry({154      title: "Email",155      username: "june",156      password: "old-password",157      url: "https://mail.example.com",158      type: "login",159    });160    await vault.applyMergedRemoteEntries(161      [162        {163          ...local,164          password: "replaced-by-peer",165          modifiedAt: Date.now() + 1000,166        },167      ],168      {},169      { deviceName: "Pixel 9" },170    );171    await vault.applyMergedRemoteEntries(172      [],173      { [local.id]: Date.now() + 1000 },174      { deviceName: "Pixel 9" },175    );176    const alerts = await getPendingSyncAlerts();177    expect(alerts).toEqual([]);178  });179});