/** * 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 5 — 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(5); }); it("atomic persist: a slot-B write failure restores slot A — no phantom entry (v3.6.137)", async () => { const vault = await import("./vault"); await vault.createVault("atomic-pass-12345"); await vault.addEntry({ title: "A", username: "", password: "p1", url: "" }); // Slot A as committed by the first, fully-successful save. const slotABefore = JSON.stringify(storage.peach_vault_a); expect(slotABefore).toBeTruthy(); // Fail ONLY the slot-B write — this mimics the mid-persist storage error // (quota overflow) that left a committed slot A behind and produced // duplicate entries before v3.6.137. beforeEach rebuilds chrome per test, // so this override does not leak. (chrome.storage.local as unknown as { set: (n: Record) => Promise }).set = async (next) => { if ("peach_vault_b" in next) throw new Error("QUOTA_BYTES quota exceeded"); Object.assign(storage, next); }; await expect( vault.addEntry({ title: "B", username: "", password: "p2", url: "" }), ).rejects.toThrow(); // Slot A on disk must be byte-identical to before: the failed entry B was // rolled back on disk too, so it can never resurface as a phantom/duplicate. expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore); }); it("entryCount is coarsened to a size bucket, not the exact count (PCH-01-023)", async () => { const vault = await import("./vault"); await vault.createVault("count-pass-12345"); await vault.addEntry({ title: "A", username: "", password: "p1", url: "" }); await vault.addEntry({ title: "B", username: "", password: "p2", url: "" }); const snaps = readHistory().snapshots; expect(snaps).toHaveLength(2); // PCH-01-023: 1 and 2 active entries both fall in the smallest bucket (10), // so the exact count and the per-write delta are no longer leaked. expect(snaps[0]!.entryCount).toBe(10); expect(snaps[1]!.entryCount).toBe(10); }); it("entryCount bucket ceiling rounds up across a boundary (PCH-01-023)", async () => { const vault = await import("./vault"); await vault.createVault("bucket-pass-12345"); // 11 active entries → just over the 10-bucket → reported as 25, never 11. for (let i = 0; i < 11; i++) { await vault.addEntry({ title: `E${i}`, username: "", password: `p${i}`, url: "" }); } const snaps = readHistory().snapshots; expect(snaps[0]!.entryCount).toBe(25); }); 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"); }); });