12345678910import { 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 107 const slotABefore = JSON.stringify(storage.peach_vault_a);108 expect(slotABefore).toBeTruthy();109110 111 112 113 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 125 126 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);127 });128129 it("entryCount is coarsened to a size bucket, not the exact count (PCH-01-023)", 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 await vault.addEntry({ title: "B", username: "", password: "p2", url: "" });134135 const snaps = readHistory().snapshots;136 expect(snaps).toHaveLength(2);137 138 139 expect(snaps[0]!.entryCount).toBe(10);140 expect(snaps[1]!.entryCount).toBe(10);141 });142143 it("entryCount bucket ceiling rounds up across a boundary (PCH-01-023)", async () => {144 const vault = await import("./vault");145 await vault.createVault("bucket-pass-12345");146 147 for (let i = 0; i < 11; i++) {148 await vault.addEntry({ title: `E${i}`, username: "", password: `p${i}`, url: "" });149 }150 const snaps = readHistory().snapshots;151 expect(snaps[0]!.entryCount).toBe(25);152 });153154 it("dedup: takeVaultSnapshot with no intervening slot write is a no-op the second time", async () => {155 const vault = await import("./vault");156 await vault.createVault("dedup-pass-12345");157 await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });158159 160 161 162 163 await vault.takeVaultSnapshot("local_save");164 const afterFirst = readHistory().snapshots.length;165 await vault.takeVaultSnapshot("local_save");166 expect(readHistory().snapshots.length).toBe(afterFirst);167 });168169 it("getVaultHistory returns the stored snapshots in array form", async () => {170 const vault = await import("./vault");171 await vault.createVault("get-pass-12345");172 await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });173 const list = await vault.getVaultHistory();174 expect(Array.isArray(list)).toBe(true);175 expect(list).toHaveLength(1);176 });177178 it("restoreVaultSnapshot rolls slot A back to a saved blob and locks the vault", async () => {179 const vault = await import("./vault");180 await vault.createVault("restore-pass-12345");181 await vault.addEntry({ title: "first", username: "", password: "p1", url: "" });182 const slotABeforeSecond = JSON.stringify(storage.peach_vault_a);183 await vault.addEntry({ title: "second", username: "", password: "p2", url: "" });184185 const list = await vault.getVaultHistory();186 187 const target = list[0]!;188 expect(target.vault_a).toBe(slotABeforeSecond);189190 expect(vault.isUnlocked()).toBe(true);191 const result = await vault.restoreVaultSnapshot(target.id);192 expect(result).toEqual({ ok: true });193 expect(vault.isUnlocked()).toBe(false);194 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABeforeSecond);195 });196197 it("restore is itself snapshotted with reason 'restore' so it is reversible", async () => {198 const vault = await import("./vault");199 await vault.createVault("undo-pass-12345");200 await vault.addEntry({ title: "first", username: "", password: "p1", url: "" });201 await vault.addEntry({ title: "second", username: "", password: "p2", url: "" });202203 const list = await vault.getVaultHistory();204 const target = list[0]!;205206 const slotABeforeRestore = JSON.stringify(storage.peach_vault_a);207 await vault.restoreVaultSnapshot(target.id);208209 const after = await vault.getVaultHistory();210 const restoreEntry = after.find((s) => s.reason === "restore");211 expect(restoreEntry).toBeTruthy();212 expect(restoreEntry!.vault_a).toBe(slotABeforeRestore);213 });214215 it("restoreVaultSnapshot returns snapshot_not_found for unknown id", async () => {216 const vault = await import("./vault");217 await vault.createVault("unknown-pass-12345");218 await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });219 const result = await vault.restoreVaultSnapshot("does-not-exist");220 expect(result).toEqual({ ok: false, error: "snapshot_not_found" });221 });222223 it("PB-26-915: a decoy session cannot read or roll back the real vault history", async () => {224 const vault = await import("./vault");225 await vault.createVault("decoy-history-pass-12345");226 await vault.addEntry({ title: "real", username: "", password: "p1", url: "" });227 await vault.setupDuress("decoy-history-duress-67890");228229 const realHistory = await vault.getVaultHistory();230 expect(realHistory.length).toBeGreaterThan(0);231 const realSlots = JSON.stringify(storage.peach_vault_a);232233 await vault.lock();234 expect(await vault.unlock("decoy-history-duress-67890")).toBe(true);235236 237 238 await expect(vault.getVaultHistory()).resolves.toEqual([]);239240 241 await expect(vault.restoreVaultSnapshot(realHistory[0]!.id)).resolves.toEqual({242 ok: false,243 error: "vault_locked",244 });245246 247 expect(JSON.stringify(storage.peach_vault_a)).toBe(realSlots);248 await vault.lock();249 expect(await vault.unlock("decoy-history-pass-12345")).toBe(true);250 expect((await vault.getVaultHistory()).length).toBe(realHistory.length);251 });252253 it("clearVaultHistory wipes the array", async () => {254 const vault = await import("./vault");255 await vault.createVault("clear-pass-12345");256 await vault.addEntry({ title: "X", username: "", password: "p1", url: "" });257 expect(readHistory().snapshots).toHaveLength(1);258259 await vault.clearVaultHistory();260 expect(readHistory().snapshots).toHaveLength(0);261 });262263 it("import flow uses reason 'import' on its snapshot", async () => {264 const vault = await import("./vault");265 await vault.createVault("import-pass-12345");266 await vault.addEntry({ title: "existing", username: "", password: "p1", url: "" });267268 await vault.restoreEntries(269 [270 {271 id: "imported-1",272 type: "login" as const,273 title: "imported",274 username: "u",275 password: "ip",276 url: "https://imported.test",277 createdAt: 100,278 modifiedAt: 100,279 },280 ],281 "merge",282 );283284 const list = await vault.getVaultHistory();285 expect(list[0]!.reason).toBe("import");286 });287});