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 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 134 135 136 await vault.addEntry({ title: "B", username: "", password: "p2", url: "" });137138 const snaps = readHistory().snapshots;139 expect(snaps).toHaveLength(2);140 141 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 152 153 154 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 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});