vault.tombstone-ledger.test.ts

4.9 KB · raw

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});