vault.sync-clamp.test.ts

4.6 KB · raw

1// Draft unit tests for HV-1 — sync timestamp clamp + remote-mutation alerts.2// Target location after integration: src/background/vault.sync-clamp.test.ts3// (or merged into the existing vault sync tests). Uses the same chrome4// storage mock harness as src/background/vault.test.ts.56import { beforeEach, describe, expect, it } from "vitest";7import {8  MAX_CLOCK_SKEW_MS,9  clampSyncTimestamp,10} from "../shared/sync-limits";11import {12  applyMergedRemoteEntries,13  addEntry,14  createVault,15  getEntries,16  getEntryById,17} from "./vault";18import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";1920describe("clampSyncTimestamp", () => {21  it("clamps a far-future timestamp to now + MAX_CLOCK_SKEW_MS", () => {22    const now = 1_750_000_000_000;23    const farFuture = now + 10_000_000;24    expect(clampSyncTimestamp(farFuture, now)).toBe(now + MAX_CLOCK_SKEW_MS);25  });2627  it("passes through timestamps within the skew window", () => {28    const now = 1_750_000_000_000;29    expect(clampSyncTimestamp(now - 1000, now)).toBe(now - 1000);30    expect(clampSyncTimestamp(now + MAX_CLOCK_SKEW_MS - 1, now)).toBe(31      now + MAX_CLOCK_SKEW_MS - 1,32    );33  });34});3536describe("applyMergedRemoteEntries — HV-1", () => {37  beforeEach(async () => {38    // chrome.storage mock reset per the existing vault.test.ts harness.39    await createVault("correct horse battery staple");40    await clearPendingSyncAlerts();41  });4243  it("clamps a far-future remote modifiedAt so a local edit can win", async () => {44    const local = await addEntry({45      title: "Bank",46      username: "june",47      password: "strong-local-password",48      url: "https://bank.example.com",49      type: "login",50    });5152    const farFuture = Date.now() + 10_000_000;53    await applyMergedRemoteEntries(54      [{ ...local, password: "attacker-password", modifiedAt: farFuture }],55      {},56    );5758    const merged = getEntryById(local.id)!;59    // The remote value wins NOW (it is newer than the local edit), but its60    // timestamp is clamped — it cannot pin the far future.61    expect(merged.modifiedAt).toBeLessThanOrEqual(Date.now() + MAX_CLOCK_SKEW_MS);6263    // A subsequent local edit (monotonic clock) beats the clamped value.64    const { updateEntry } = await import("./vault");65    await updateEntry(local.id, { password: "honest-newer-password" });66    // Re-sync the stale attacker value — it must NOT win again.67    await applyMergedRemoteEntries(68      [{ ...merged, password: "attacker-password", modifiedAt: farFuture }],69      {},70    );71    expect(getEntryById(local.id)!.password).toBe("honest-newer-password");72  });7374  it("clamps a far-future tombstone ts before applying", async () => {75    const local = await addEntry({76      title: "Forum",77      username: "june",78      password: "pw",79      url: "https://forum.example.com",80      type: "login",81    });82    const farFuture = Date.now() + 10_000_000;83    await applyMergedRemoteEntries([], { [local.id]: farFuture });8485    const deleted = getEntries().length === 086      ? undefined87      : undefined; // entry is soft-deleted — read via getDeletedEntries88    void deleted;89    const { getDeletedEntries } = await import("./vault");90    const tombstoned = getDeletedEntries().find((e) => e.id === local.id)!;91    expect(tombstoned.deletedAt).toBeLessThanOrEqual(92      Date.now() + MAX_CLOCK_SKEW_MS,93    );94  });9596  it("records a REMOTE_PASSWORD_CHANGE alert when a remote replaces a local password", async () => {97    const local = await addEntry({98      title: "Email",99      username: "june",100      password: "old-password",101      url: "https://mail.example.com",102      type: "login",103    });104    await applyMergedRemoteEntries(105      [{ ...local, password: "replaced-by-peer", modifiedAt: Date.now() + 1000 }],106      {},107      { deviceName: "Pixel 9" },108    );109    const alerts = await getPendingSyncAlerts();110    expect(alerts).toHaveLength(1);111    expect(alerts[0]).toMatchObject({112      type: "REMOTE_PASSWORD_CHANGE",113      entryId: local.id,114      entryTitle: "Email",115      deviceName: "Pixel 9",116    });117  });118119  it("records a REMOTE_DELETE alert when a remote tombstone deletes a live entry", async () => {120    const local = await addEntry({121      title: "Shop",122      username: "june",123      password: "pw",124      url: "https://shop.example.com",125      type: "login",126    });127    await applyMergedRemoteEntries([], { [local.id]: Date.now() + 1000 }, {128      deviceName: "Pixel 9",129    });130    const alerts = await getPendingSyncAlerts();131    expect(alerts).toHaveLength(1);132    expect(alerts[0]).toMatchObject({133      type: "REMOTE_DELETE",134      entryId: local.id,135      entryTitle: "Shop",136      deviceName: "Pixel 9",137    });138  });139});