1import { beforeEach, describe, expect, it, vi } from "vitest";2import { decrypt } from "./aes";3import { deriveKey } from "../crypto/argon2";4import type { StoredVault, VaultEntry } from "../shared/types";56type StorageShape = Record<string, unknown>;78function makeChromeStorageMock(storage: StorageShape) {9 return {10 storage,11 chrome: {12 storage: {13 local: {14 get: vi.fn(async (key?: string | string[]) => {15 if (!key) return { ...storage };16 if (typeof key === "string") return { [key]: storage[key] };17 return key.reduce<StorageShape>((acc, k) => {18 acc[k] = storage[k];19 return acc;20 }, {});21 }),22 set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),23 remove: vi.fn(async (keys: string | string[]) => {24 for (const key of Array.isArray(keys) ? keys : [keys]) delete storage[key];25 }),26 },27 },28 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },29 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },30 } as unknown as typeof globalThis.chrome,31 };32}3334describe("vault tombstone ledger", () => {35 let storage: StorageShape;36 let vault: typeof import("./vault");37 const password = "tombstone-ledger-pass";3839 beforeEach(async () => {40 vi.resetModules();41 storage = {};42 (globalThis as Record<string, unknown>).chrome = makeChromeStorageMock(storage).chrome;43 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled in tests"); }));44 vault = await import("./vault");45 await vault.createVault(password);46 });4748 async function readPayload(): Promise<{ entries: VaultEntry[]; tombstones: Record<string, number> }> {49 const stored = storage.peach_vault_a as StoredVault;50 const salt = Uint8Array.from(stored.salt.match(/.{1,2}/g)!.map((value) => parseInt(value, 16)));51 const key = await deriveKey(password, salt);52 const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");53 return JSON.parse(plain) as { entries: VaultEntry[]; tombstones: Record<string, number> };54 }5556 it("retains a purged tombstone in encrypted state and rejects the stale remote entry", async () => {57 const entry = await vault.addEntry({ type: "login", title: "Old", password: "pw" });58 const stale = { ...entry };59 await vault.deleteEntry(entry.id);60 const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;6162 await vault.purgeEntry(entry.id);63 expect(vault.getEntries().some((item) => item.id === entry.id)).toBe(false);64 expect(vault.getTombstoneManifest()).toEqual({ [entry.id]: deletedAt });6566 const result = await vault.applyMergedRemoteEntries([stale], {});67 expect(result.applied).toBe(0);68 expect(vault.getEntries().some((item) => item.id === entry.id)).toBe(false);6970 const payload = await readPayload();71 expect(payload.entries).toEqual([]);72 expect(payload.tombstones).toEqual({ [entry.id]: deletedAt });7374 vault.lock();75 await vault.unlock(password);76 expect(vault.getTombstoneManifest()).toEqual({ [entry.id]: deletedAt });77 });7879 it("retains a retention-purged tombstone and blocks a stale remote entry", async () => {80 const entry = await vault.addEntry({ type: "login", title: "Expired", password: "pw" });81 const stale = { ...entry };82 await vault.deleteEntry(entry.id);83 const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;84 const retentionMs = 7 * 24 * 60 * 60 * 1000;85 const now = vi.spyOn(Date, "now").mockReturnValue(deletedAt + retentionMs + 1);86 try {87 expect(await vault.purgeExpiredTrash(retentionMs)).toBe(1);88 } finally {89 now.mockRestore();90 }9192 expect(vault.getTombstoneManifest()).toEqual({ [entry.id]: deletedAt });93 const result = await vault.applyMergedRemoteEntries([stale], {});94 expect(result.applied).toBe(0);95 expect(vault.getEntries().some((item) => item.id === entry.id)).toBe(false);96 });9798 it("records unknown remote tombstones and lets an explicit restore supersede one", async () => {99 const id = crypto.randomUUID();100 const deletedAt = Date.now();101 const tombstoneResult = await vault.applyMergedRemoteEntries([], { [id]: deletedAt });102 expect(tombstoneResult).toMatchObject({ received: 1, applied: 1 });103 expect(vault.getTombstoneManifest()).toEqual({ [id]: deletedAt });104105 const stale: VaultEntry = {106 id,107 type: "login",108 title: "Stale",109 password: "pw",110 createdAt: deletedAt - 100,111 modifiedAt: deletedAt,112 };113 expect((await vault.applyMergedRemoteEntries([stale], {})).applied).toBe(0);114115 await vault.restoreEntries([{ ...stale, modifiedAt: deletedAt - 1 }], "merge");116 expect(vault.getEntryById(id)?.title).toBe("Stale");117 expect(vault.getTombstoneManifest()[id]).toBeUndefined();118 });119});