import { beforeEach, describe, expect, it, vi } from "vitest"; import { decrypt } from "./aes"; import { deriveKey } from "../crypto/argon2"; import type { StoredVault, VaultEntry } from "../shared/types"; type StorageShape = Record; function makeChromeStorageMock(storage: StorageShape) { return { storage, chrome: { 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: StorageShape) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { for (const key of Array.isArray(keys) ? keys : [keys]) delete storage[key]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, } as unknown as typeof globalThis.chrome, }; } describe("vault tombstone ledger", () => { let storage: StorageShape; let vault: typeof import("./vault"); const password = "tombstone-ledger-pass"; beforeEach(async () => { vi.resetModules(); storage = {}; (globalThis as Record).chrome = makeChromeStorageMock(storage).chrome; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled in tests"); })); vault = await import("./vault"); await vault.createVault(password); }); async function readPayload(): Promise<{ entries: VaultEntry[]; tombstones: Record }> { const stored = storage.peach_vault_a as StoredVault; const salt = Uint8Array.from(stored.salt.match(/.{1,2}/g)!.map((value) => parseInt(value, 16))); const key = await deriveKey(password, salt); const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1"); return JSON.parse(plain) as { entries: VaultEntry[]; tombstones: Record }; } it("retains a purged tombstone in encrypted state and rejects the stale remote entry", async () => { const entry = await vault.addEntry({ type: "login", title: "Old", password: "pw" }); const stale = { ...entry }; await vault.deleteEntry(entry.id); const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!; await vault.purgeEntry(entry.id); expect(vault.getEntries().some((item) => item.id === entry.id)).toBe(false); expect(vault.getTombstoneManifest()).toEqual({ [entry.id]: deletedAt }); const result = await vault.applyMergedRemoteEntries([stale], {}); expect(result.applied).toBe(0); expect(vault.getEntries().some((item) => item.id === entry.id)).toBe(false); const payload = await readPayload(); expect(payload.entries).toEqual([]); expect(payload.tombstones).toEqual({ [entry.id]: deletedAt }); vault.lock(); await vault.unlock(password); expect(vault.getTombstoneManifest()).toEqual({ [entry.id]: deletedAt }); }); it("retains a retention-purged tombstone and blocks a stale remote entry", async () => { const entry = await vault.addEntry({ type: "login", title: "Expired", password: "pw" }); const stale = { ...entry }; await vault.deleteEntry(entry.id); const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!; const retentionMs = 7 * 24 * 60 * 60 * 1000; const now = vi.spyOn(Date, "now").mockReturnValue(deletedAt + retentionMs + 1); try { expect(await vault.purgeExpiredTrash(retentionMs)).toBe(1); } finally { now.mockRestore(); } expect(vault.getTombstoneManifest()).toEqual({ [entry.id]: deletedAt }); const result = await vault.applyMergedRemoteEntries([stale], {}); expect(result.applied).toBe(0); expect(vault.getEntries().some((item) => item.id === entry.id)).toBe(false); }); it("retains purged tombstones across replace restore and rejects stale remote entries", async () => { const purged = await vault.addEntry({ type: "login", title: "Purged", password: "pw" }); const stale = { ...purged }; await vault.deleteEntry(purged.id); const deletedAt = vault.getDeletedEntries().find((item) => item.id === purged.id)!.deletedAt!; await vault.purgeEntry(purged.id); await vault.restoreEntries([ { id: crypto.randomUUID(), type: "login", title: "Replacement", password: "new", createdAt: deletedAt + 1, modifiedAt: deletedAt + 1, }, ], "replace"); expect(vault.getTombstoneManifest()).toEqual({ [purged.id]: deletedAt }); expect(await vault.applyMergedRemoteEntries([stale], {})).toMatchObject({ applied: 0 }); expect(vault.getEntryById(purged.id)).toBeUndefined(); }); it("records unknown remote tombstones and lets an explicit restore supersede one", async () => { const id = crypto.randomUUID(); const deletedAt = Date.now(); const tombstoneResult = await vault.applyMergedRemoteEntries([], { [id]: deletedAt }); expect(tombstoneResult).toMatchObject({ received: 1, applied: 1 }); expect(vault.getTombstoneManifest()).toEqual({ [id]: deletedAt }); const stale: VaultEntry = { id, type: "login", title: "Stale", password: "pw", createdAt: deletedAt - 100, modifiedAt: deletedAt, }; expect((await vault.applyMergedRemoteEntries([stale], {})).applied).toBe(0); await vault.restoreEntries([{ ...stale, modifiedAt: deletedAt - 1 }], "merge"); expect(vault.getEntryById(id)?.title).toBe("Stale"); expect(vault.getTombstoneManifest()[id]).toBeUndefined(); }); });