vault-history.test.ts

8.4 KB · raw

1/**2 * Vault history tests.3 *4 * These cover the new `peach_vault_history` system that snapshots the5 * about-to-be-overwritten encrypted blob BEFORE every real-mode vault write.6 * The old `peach_vault_snapshots` system was retired in v2.14.0 — see7 * Projects.md for the rationale.8 */910import { beforeEach, describe, expect, it, vi } from "vitest";11import type { VaultHistory } from "../shared/types";1213type StorageShape = Record<string, unknown>;1415describe("vault history", () => {16  let storage: StorageShape;1718  beforeEach(() => {19    vi.resetModules();20    storage = {};2122    const chromeMock = {23      storage: {24        local: {25          get: vi.fn(async (key?: string | string[]) => {26            if (!key) return { ...storage };27            if (typeof key === "string") return { [key]: storage[key] };28            return key.reduce<Record<string, unknown>>((acc, k) => {29              acc[k] = storage[k];30              return acc;31            }, {});32          }),33          set: vi.fn(async (next: Record<string, unknown>) => {34            Object.assign(storage, next);35          }),36          remove: vi.fn(async (keys: string | string[]) => {37            for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];38          }),39        },40      },41      alarms: {42        clear: vi.fn(),43        create: vi.fn(),44        onAlarm: { addListener: vi.fn() },45      },46      idle: {47        setDetectionInterval: vi.fn(),48        onStateChanged: { addListener: vi.fn() },49      },50    } as unknown as typeof chrome;51    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;5253    vi.stubGlobal("fetch", vi.fn(async () => {54      throw new Error("network disabled in tests");55    }));56  });5758  function readHistory(): VaultHistory {59    const raw = storage.peach_vault_history as VaultHistory | undefined;60    return raw ?? { snapshots: [] };61  }6263  it("first vault write produces no snapshot — nothing to lose yet", async () => {64    const vault = await import("./vault");65    await vault.createVault("history-pass-12345");66    expect(readHistory().snapshots).toHaveLength(0);67  });6869  it("second write snapshots the first state with reason 'local_save'", async () => {70    const vault = await import("./vault");71    await vault.createVault("history-pass-12345");72    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });7374    const history = readHistory();75    expect(history.snapshots).toHaveLength(1);76    expect(history.snapshots[0]!.reason).toBe("local_save");77    expect(history.snapshots[0]!.id).toBeTruthy();78    expect(history.snapshots[0]!.savedAt).toBeGreaterThan(0);79  });8081  it("snapshots are newest-first", async () => {82    const vault = await import("./vault");83    await vault.createVault("history-pass-67890");84    await vault.addEntry({ title: "First", username: "", password: "p1", url: "" });85    await vault.addEntry({ title: "Second", username: "", password: "p2", url: "" });8687    const snaps = readHistory().snapshots;88    expect(snaps).toHaveLength(2);89    expect(snaps[0]!.savedAt).toBeGreaterThanOrEqual(snaps[1]!.savedAt);90  });9192  it("history is capped at 5 — oldest dropped", async () => {93    const vault = await import("./vault");94    await vault.createVault("cap-pass-abcde");95    for (let i = 0; i < 15; i += 1) {96      await vault.addEntry({ title: `E${i}`, username: "", password: `p${i}`, url: "" });97    }98    expect(readHistory().snapshots).toHaveLength(5);99  });100101  it("entryCount reflects current in-memory active count at snapshot time", async () => {102    const vault = await import("./vault");103    await vault.createVault("count-pass-12345");104    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });105    // Most recent snapshot was taken before the addEntry write fired its106    // persist, capturing the pre-add state of slotA but counting the107    // post-add in-memory entries — see spec Part 1.1.108    await vault.addEntry({ title: "B", username: "", password: "p2", url: "" });109110    const snaps = readHistory().snapshots;111    expect(snaps).toHaveLength(2);112    // Newest snapshot was taken at the persistSlotA call inside the second113    // addEntry, when in-memory has 2 active entries.114    expect(snaps[0]!.entryCount).toBe(2);115    expect(snaps[1]!.entryCount).toBe(1);116  });117118  it("dedup: takeVaultSnapshot with no intervening slot write is a no-op the second time", async () => {119    const vault = await import("./vault");120    await vault.createVault("dedup-pass-12345");121    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });122123    // The first explicit takeVaultSnapshot captures the post-addEntry blob124    // (which differs from the snapshot already in history, which captured125    // the pre-addEntry blob). The SECOND call sees an identical blob to the126    // one we just captured and should dedup.127    await vault.takeVaultSnapshot("local_save");128    const afterFirst = readHistory().snapshots.length;129    await vault.takeVaultSnapshot("local_save");130    expect(readHistory().snapshots.length).toBe(afterFirst);131  });132133  it("getVaultHistory returns the stored snapshots in array form", async () => {134    const vault = await import("./vault");135    await vault.createVault("get-pass-12345");136    await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });137    const list = await vault.getVaultHistory();138    expect(Array.isArray(list)).toBe(true);139    expect(list).toHaveLength(1);140  });141142  it("restoreVaultSnapshot rolls slot A back to a saved blob and locks the vault", async () => {143    const vault = await import("./vault");144    await vault.createVault("restore-pass-12345");145    await vault.addEntry({ title: "first", username: "", password: "p1", url: "" });146    const slotABeforeSecond = JSON.stringify(storage.peach_vault_a);147    await vault.addEntry({ title: "second", username: "", password: "p2", url: "" });148149    const list = await vault.getVaultHistory();150    // Most recent snapshot captures the slotA right before the "second" write.151    const target = list[0]!;152    expect(target.vault_a).toBe(slotABeforeSecond);153154    expect(vault.isUnlocked()).toBe(true);155    const result = await vault.restoreVaultSnapshot(target.id);156    expect(result).toEqual({ ok: true });157    expect(vault.isUnlocked()).toBe(false);158    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABeforeSecond);159  });160161  it("restore is itself snapshotted with reason 'restore' so it is reversible", async () => {162    const vault = await import("./vault");163    await vault.createVault("undo-pass-12345");164    await vault.addEntry({ title: "first", username: "", password: "p1", url: "" });165    await vault.addEntry({ title: "second", username: "", password: "p2", url: "" });166167    const list = await vault.getVaultHistory();168    const target = list[0]!;169170    const slotABeforeRestore = JSON.stringify(storage.peach_vault_a);171    await vault.restoreVaultSnapshot(target.id);172173    const after = await vault.getVaultHistory();174    const restoreEntry = after.find((s) => s.reason === "restore");175    expect(restoreEntry).toBeTruthy();176    expect(restoreEntry!.vault_a).toBe(slotABeforeRestore);177  });178179  it("restoreVaultSnapshot returns snapshot_not_found for unknown id", async () => {180    const vault = await import("./vault");181    await vault.createVault("unknown-pass-12345");182    await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });183    const result = await vault.restoreVaultSnapshot("does-not-exist");184    expect(result).toEqual({ ok: false, error: "snapshot_not_found" });185  });186187  it("clearVaultHistory wipes the array", async () => {188    const vault = await import("./vault");189    await vault.createVault("clear-pass-12345");190    await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });191    expect(readHistory().snapshots).toHaveLength(1);192193    await vault.clearVaultHistory();194    expect(readHistory().snapshots).toHaveLength(0);195  });196197  it("import flow uses reason 'import' on its snapshot", async () => {198    const vault = await import("./vault");199    await vault.createVault("import-pass-12345");200    await vault.addEntry({ title: "existing", username: "", password: "p1", url: "" });201202    await vault.restoreEntries(203      [204        {205          id: "imported-1",206          type: "login" as const,207          title: "imported",208          username: "u",209          password: "ip",210          url: "https://imported.test",211          createdAt: 100,212          modifiedAt: 100,213        },214      ],215      "merge",216    );217218    const list = await vault.getVaultHistory();219    expect(list[0]!.reason).toBe("import");220  });221});