vault-history.test.ts

11.4 KB · raw

1/**2 * Vault history tests.3 *4 * These cover the new `peach_vault_history` system that snapshots the5 * about-to-be-overwritten encrypted blob BEFORE every real-mode vault write.6 * The old `peach_vault_snapshots` system was retired in v2.14.0 — see7 * Projects.md for the rationale.8 */910import { 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    // Slot A as committed by the first, fully-successful save.107    const slotABefore = JSON.stringify(storage.peach_vault_a);108    expect(slotABefore).toBeTruthy();109110    // Fail ONLY the slot-B write — this mimics the mid-persist storage error111    // (quota overflow) that left a committed slot A behind and produced112    // duplicate entries before v3.6.137. beforeEach rebuilds chrome per test,113    // so this override does not leak.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    // Slot A on disk must be byte-identical to before: the failed entry B was125    // rolled back on disk too, so it can never resurface as a phantom/duplicate.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    // PCH-01-023: 1 and 2 active entries both fall in the smallest bucket (10),138    // so the exact count and the per-write delta are no longer leaked.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    // 11 active entries → just over the 10-bucket → reported as 25, never 11.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    // The first explicit takeVaultSnapshot captures the post-addEntry blob160    // (which differs from the snapshot already in history, which captured161    // the pre-addEntry blob). The SECOND call sees an identical blob to the162    // one we just captured and should dedup.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    // Most recent snapshot captures the slotA right before the "second" write.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    // Read side: the history store is real-mode state, so the decoy sees none237    // of it — not the real vault's activity cadence and not its size bucket.238    await expect(vault.getVaultHistory()).resolves.toEqual([]);239240    // Write side: a coerced session cannot roll the real vault backwards.241    await expect(vault.restoreVaultSnapshot(realHistory[0]!.id)).resolves.toEqual({242      ok: false,243      error: "vault_locked",244    });245246    // Nothing was touched, and the real history survives intact.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});