vault-history.test.ts

9.7 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("atomic persist: a slot-B write failure restores slot A — no phantom entry (v3.6.137)", async () => {102    const vault = await import("./vault");103    await vault.createVault("atomic-pass-12345");104    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });105106    // Slot A as committed by the first, fully-successful save.107    const slotABefore = JSON.stringify(storage.peach_vault_a);108    expect(slotABefore).toBeTruthy();109110    // Fail ONLY the slot-B write — this mimics the mid-persist storage error111    // (quota overflow) that left a committed slot A behind and produced112    // duplicate entries before v3.6.137. beforeEach rebuilds chrome per test,113    // so this override does not leak.114    (chrome.storage.local as unknown as { set: (n: Record<string, unknown>) => Promise<void> }).set =115      async (next) => {116        if ("peach_vault_b" in next) throw new Error("QUOTA_BYTES quota exceeded");117        Object.assign(storage, next);118      };119120    await expect(121      vault.addEntry({ title: "B", username: "", password: "p2", url: "" }),122    ).rejects.toThrow();123124    // Slot A on disk must be byte-identical to before: the failed entry B was125    // rolled back on disk too, so it can never resurface as a phantom/duplicate.126    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);127  });128129  it("entryCount reflects current in-memory active count at snapshot time", async () => {130    const vault = await import("./vault");131    await vault.createVault("count-pass-12345");132    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });133    // Most recent snapshot was taken before the addEntry write fired its134    // persist, capturing the pre-add state of slotA but counting the135    // post-add in-memory entries — see spec Part 1.1.136    await vault.addEntry({ title: "B", username: "", password: "p2", url: "" });137138    const snaps = readHistory().snapshots;139    expect(snaps).toHaveLength(2);140    // Newest snapshot was taken at the persistSlotA call inside the second141    // addEntry, when in-memory has 2 active entries.142    expect(snaps[0]!.entryCount).toBe(2);143    expect(snaps[1]!.entryCount).toBe(1);144  });145146  it("dedup: takeVaultSnapshot with no intervening slot write is a no-op the second time", async () => {147    const vault = await import("./vault");148    await vault.createVault("dedup-pass-12345");149    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });150151    // The first explicit takeVaultSnapshot captures the post-addEntry blob152    // (which differs from the snapshot already in history, which captured153    // the pre-addEntry blob). The SECOND call sees an identical blob to the154    // one we just captured and should dedup.155    await vault.takeVaultSnapshot("local_save");156    const afterFirst = readHistory().snapshots.length;157    await vault.takeVaultSnapshot("local_save");158    expect(readHistory().snapshots.length).toBe(afterFirst);159  });160161  it("getVaultHistory returns the stored snapshots in array form", async () => {162    const vault = await import("./vault");163    await vault.createVault("get-pass-12345");164    await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });165    const list = await vault.getVaultHistory();166    expect(Array.isArray(list)).toBe(true);167    expect(list).toHaveLength(1);168  });169170  it("restoreVaultSnapshot rolls slot A back to a saved blob and locks the vault", async () => {171    const vault = await import("./vault");172    await vault.createVault("restore-pass-12345");173    await vault.addEntry({ title: "first", username: "", password: "p1", url: "" });174    const slotABeforeSecond = JSON.stringify(storage.peach_vault_a);175    await vault.addEntry({ title: "second", username: "", password: "p2", url: "" });176177    const list = await vault.getVaultHistory();178    // Most recent snapshot captures the slotA right before the "second" write.179    const target = list[0]!;180    expect(target.vault_a).toBe(slotABeforeSecond);181182    expect(vault.isUnlocked()).toBe(true);183    const result = await vault.restoreVaultSnapshot(target.id);184    expect(result).toEqual({ ok: true });185    expect(vault.isUnlocked()).toBe(false);186    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABeforeSecond);187  });188189  it("restore is itself snapshotted with reason 'restore' so it is reversible", async () => {190    const vault = await import("./vault");191    await vault.createVault("undo-pass-12345");192    await vault.addEntry({ title: "first", username: "", password: "p1", url: "" });193    await vault.addEntry({ title: "second", username: "", password: "p2", url: "" });194195    const list = await vault.getVaultHistory();196    const target = list[0]!;197198    const slotABeforeRestore = JSON.stringify(storage.peach_vault_a);199    await vault.restoreVaultSnapshot(target.id);200201    const after = await vault.getVaultHistory();202    const restoreEntry = after.find((s) => s.reason === "restore");203    expect(restoreEntry).toBeTruthy();204    expect(restoreEntry!.vault_a).toBe(slotABeforeRestore);205  });206207  it("restoreVaultSnapshot returns snapshot_not_found for unknown id", async () => {208    const vault = await import("./vault");209    await vault.createVault("unknown-pass-12345");210    await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });211    const result = await vault.restoreVaultSnapshot("does-not-exist");212    expect(result).toEqual({ ok: false, error: "snapshot_not_found" });213  });214215  it("clearVaultHistory wipes the array", async () => {216    const vault = await import("./vault");217    await vault.createVault("clear-pass-12345");218    await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });219    expect(readHistory().snapshots).toHaveLength(1);220221    await vault.clearVaultHistory();222    expect(readHistory().snapshots).toHaveLength(0);223  });224225  it("import flow uses reason 'import' on its snapshot", async () => {226    const vault = await import("./vault");227    await vault.createVault("import-pass-12345");228    await vault.addEntry({ title: "existing", username: "", password: "p1", url: "" });229230    await vault.restoreEntries(231      [232        {233          id: "imported-1",234          type: "login" as const,235          title: "imported",236          username: "u",237          password: "ip",238          url: "https://imported.test",239          createdAt: 100,240          modifiedAt: 100,241        },242      ],243      "merge",244    );245246    const list = await vault.getVaultHistory();247    expect(list[0]!.reason).toBe("import");248  });249});