/** * Vault history tests. * * These cover the new `peach_vault_history` system that snapshots the * about-to-be-overwritten encrypted blob BEFORE every real-mode vault write. * The old `peach_vault_snapshots` system was retired in v2.14.0 — see * Projects.md for the rationale. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { VaultHistory } from "../shared/types"; type StorageShape = Record; describe("vault history", () => { let storage: StorageShape; 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>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k]; }), }, }, 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"); })); }); function readHistory(): VaultHistory { const raw = storage.peach_vault_history as VaultHistory | undefined; return raw ?? { snapshots: [] }; } it("first vault write produces no snapshot — nothing to lose yet", async () => { const vault = await import("./vault"); await vault.createVault("history-pass-12345"); expect(readHistory().snapshots).toHaveLength(0); }); it("second write snapshots the first state with reason 'local_save'", async () => { const vault = await import("./vault"); await vault.createVault("history-pass-12345"); await vault.addEntry({ title: "A", username: "", password: "p1", url: "" }); const history = readHistory(); expect(history.snapshots).toHaveLength(1); expect(history.snapshots[0]!.reason).toBe("local_save"); expect(history.snapshots[0]!.id).toBeTruthy(); expect(history.snapshots[0]!.savedAt).toBeGreaterThan(0); }); it("snapshots are newest-first", async () => { const vault = await import("./vault"); await vault.createVault("history-pass-67890"); await vault.addEntry({ title: "First", username: "", password: "p1", url: "" }); await vault.addEntry({ title: "Second", username: "", password: "p2", url: "" }); const snaps = readHistory().snapshots; expect(snaps).toHaveLength(2); expect(snaps[0]!.savedAt).toBeGreaterThanOrEqual(snaps[1]!.savedAt); }); it("history is capped at 10 — oldest dropped", async () => { const vault = await import("./vault"); await vault.createVault("cap-pass-abcde"); for (let i = 0; i < 15; i += 1) { await vault.addEntry({ title: `E${i}`, username: "", password: `p${i}`, url: "" }); } expect(readHistory().snapshots).toHaveLength(10); }); it("entryCount reflects current in-memory active count at snapshot time", async () => { const vault = await import("./vault"); await vault.createVault("count-pass-12345"); await vault.addEntry({ title: "A", username: "", password: "p1", url: "" }); // Most recent snapshot was taken before the addEntry write fired its // persist, capturing the pre-add state of slotA but counting the // post-add in-memory entries — see spec Part 1.1. await vault.addEntry({ title: "B", username: "", password: "p2", url: "" }); const snaps = readHistory().snapshots; expect(snaps).toHaveLength(2); // Newest snapshot was taken at the persistSlotA call inside the second // addEntry, when in-memory has 2 active entries. expect(snaps[0]!.entryCount).toBe(2); expect(snaps[1]!.entryCount).toBe(1); }); it("dedup: takeVaultSnapshot with no intervening slot write is a no-op the second time", async () => { const vault = await import("./vault"); await vault.createVault("dedup-pass-12345"); await vault.addEntry({ title: "A", username: "", password: "p1", url: "" }); // The first explicit takeVaultSnapshot captures the post-addEntry blob // (which differs from the snapshot already in history, which captured // the pre-addEntry blob). The SECOND call sees an identical blob to the // one we just captured and should dedup. await vault.takeVaultSnapshot("local_save"); const afterFirst = readHistory().snapshots.length; await vault.takeVaultSnapshot("local_save"); expect(readHistory().snapshots.length).toBe(afterFirst); }); it("getVaultHistory returns the stored snapshots in array form", async () => { const vault = await import("./vault"); await vault.createVault("get-pass-12345"); await vault.addEntry({ title: "X", username: "", password: "p1", url: "" }); const list = await vault.getVaultHistory(); expect(Array.isArray(list)).toBe(true); expect(list).toHaveLength(1); }); it("restoreVaultSnapshot rolls slot A back to a saved blob and locks the vault", async () => { const vault = await import("./vault"); await vault.createVault("restore-pass-12345"); await vault.addEntry({ title: "first", username: "", password: "p1", url: "" }); const slotABeforeSecond = JSON.stringify(storage.peach_vault_a); await vault.addEntry({ title: "second", username: "", password: "p2", url: "" }); const list = await vault.getVaultHistory(); // Most recent snapshot captures the slotA right before the "second" write. const target = list[0]!; expect(target.vault_a).toBe(slotABeforeSecond); expect(vault.isUnlocked()).toBe(true); const result = await vault.restoreVaultSnapshot(target.id); expect(result).toEqual({ ok: true }); expect(vault.isUnlocked()).toBe(false); expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABeforeSecond); }); it("restore is itself snapshotted with reason 'restore' so it is reversible", async () => { const vault = await import("./vault"); await vault.createVault("undo-pass-12345"); await vault.addEntry({ title: "first", username: "", password: "p1", url: "" }); await vault.addEntry({ title: "second", username: "", password: "p2", url: "" }); const list = await vault.getVaultHistory(); const target = list[0]!; const slotABeforeRestore = JSON.stringify(storage.peach_vault_a); await vault.restoreVaultSnapshot(target.id); const after = await vault.getVaultHistory(); const restoreEntry = after.find((s) => s.reason === "restore"); expect(restoreEntry).toBeTruthy(); expect(restoreEntry!.vault_a).toBe(slotABeforeRestore); }); it("restoreVaultSnapshot returns snapshot_not_found for unknown id", async () => { const vault = await import("./vault"); await vault.createVault("unknown-pass-12345"); await vault.addEntry({ title: "X", username: "", password: "p1", url: "" }); const result = await vault.restoreVaultSnapshot("does-not-exist"); expect(result).toEqual({ ok: false, error: "snapshot_not_found" }); }); it("clearVaultHistory wipes the array", async () => { const vault = await import("./vault"); await vault.createVault("clear-pass-12345"); await vault.addEntry({ title: "X", username: "", password: "p1", url: "" }); expect(readHistory().snapshots).toHaveLength(1); await vault.clearVaultHistory(); expect(readHistory().snapshots).toHaveLength(0); }); it("import flow uses reason 'import' on its snapshot", async () => { const vault = await import("./vault"); await vault.createVault("import-pass-12345"); await vault.addEntry({ title: "existing", username: "", password: "p1", url: "" }); await vault.restoreEntries( [ { id: "imported-1", type: "login" as const, title: "imported", username: "u", password: "ip", url: "https://imported.test", createdAt: 100, modifiedAt: 100, }, ], "merge", ); const list = await vault.getVaultHistory(); expect(list[0]!.reason).toBe("import"); }); });