// Unit tests for HV-1 — sync timestamp clamp + remote-mutation alerts. // Uses the same chrome storage mock harness as src/background/vault.test.ts: // the mock is installed in beforeEach and vault/sync-alerts are imported // dynamically afterwards, because vault.ts -> alarms.ts touches `chrome` at // module-evaluation time. import { beforeEach, describe, expect, it, vi } from "vitest"; import { MAX_CLOCK_SKEW_MS, clampSyncTimestamp } from "../shared/sync-limits"; describe("clampSyncTimestamp", () => { it("clamps a far-future timestamp to now + MAX_CLOCK_SKEW_MS", () => { const now = 1_750_000_000_000; const farFuture = now + 10_000_000; expect(clampSyncTimestamp(farFuture, now)).toBe(now + MAX_CLOCK_SKEW_MS); }); it("passes through timestamps within the skew window", () => { const now = 1_750_000_000_000; expect(clampSyncTimestamp(now - 1000, now)).toBe(now - 1000); expect(clampSyncTimestamp(now + MAX_CLOCK_SKEW_MS - 1, now)).toBe( now + MAX_CLOCK_SKEW_MS - 1, ); }); }); describe("applyMergedRemoteEntries — HV-1", () => { let storage: Record; beforeEach(() => { vi.resetModules(); storage = {}; const chromeMock = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") return { [key]: storage[key] }; return key.reduce>((result, entry) => { result[entry] = storage[entry]; return result; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete storage[key]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; vi.stubGlobal( "fetch", vi.fn(async () => { throw new Error("network disabled in tests"); }), ); }); it("clamps a far-future remote modifiedAt so a local edit can win", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const local = await vault.addEntry({ title: "Bank", username: "june", password: "strong-local-password", url: "https://bank.example.com", type: "login", }); // Freeze Date.now() (only Date — real timers stay live) so the two merges // share one `now`. The security property is: a clamped far-future value // cannot pin the future against the monotonic local clock. With wall-clock // drift between merges the attacker's re-clamped value would creep forward // within the skew window; freezing time isolates the invariant under test. vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date(local.modifiedAt + 1000)); try { const farFuture = Date.now() + 10_000_000; await vault.applyMergedRemoteEntries( [{ ...local, password: "attacker-password", modifiedAt: farFuture }], {}, ); const merged = vault.getEntryById(local.id)!; // The remote value wins NOW (it is newer than the local edit), but its // timestamp is clamped — it cannot pin the far future. expect(merged.modifiedAt).toBeLessThanOrEqual( Date.now() + MAX_CLOCK_SKEW_MS, ); // A subsequent local edit (monotonic clock) advances past the clamped // value. await vault.updateEntry(local.id, { password: "honest-newer-password" }); // Re-sync the stale attacker value — it must NOT win again. await vault.applyMergedRemoteEntries( [{ ...merged, password: "attacker-password", modifiedAt: farFuture }], {}, ); expect(vault.getEntryById(local.id)!.password).toBe( "honest-newer-password", ); } finally { vi.useRealTimers(); } }); it("clamps a far-future tombstone ts before applying", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const local = await vault.addEntry({ title: "Forum", username: "june", password: "pw", url: "https://forum.example.com", type: "login", }); const farFuture = Date.now() + 10_000_000; await vault.applyMergedRemoteEntries([], { [local.id]: farFuture }); const tombstoned = vault .getDeletedEntries() .find((e) => e.id === local.id)!; expect(tombstoned.deletedAt).toBeLessThanOrEqual( Date.now() + MAX_CLOCK_SKEW_MS, ); }); it("applies remote entry changes quietly without queuing activity alerts", async () => { const vault = await import("./vault"); const { getPendingSyncAlerts, clearPendingSyncAlerts } = await import( "./sync-alerts" ); await vault.createVault("correct horse battery staple"); await clearPendingSyncAlerts(); const local = await vault.addEntry({ title: "Email", username: "june", password: "old-password", url: "https://mail.example.com", type: "login", }); await vault.applyMergedRemoteEntries( [ { ...local, password: "replaced-by-peer", modifiedAt: Date.now() + 1000, }, ], {}, ); await vault.applyMergedRemoteEntries( [], { [local.id]: Date.now() + 1000 }, ); const alerts = await getPendingSyncAlerts(); expect(alerts).toEqual([]); }); });