vault.test.ts

25.5 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import { decrypt } from "./aes";3import { deriveKey } from "../crypto/argon2";4import type { StoredVault } from "../shared/types";56type StorageShape = Record<string, unknown>;78describe("background vault", () => {9  let storage: StorageShape;10  let alarms = { created: [] as Array<{ name: string; delayInMinutes: number }>, cleared: [] as string[] };1112  beforeEach(() => {13    vi.resetModules();14    storage = {};15    alarms = { created: [], cleared: [] };1617    const chromeMock = {18      storage: {19        local: {20          get: vi.fn(async (key?: string | string[]) => {21            if (!key) return storage;22            if (typeof key === "string") {23              return { [key]: storage[key] };24            }2526            return key.reduce<Record<string, unknown>>((result, entry) => {27              result[entry] = storage[entry];28              return result;29            }, {});30          }),31          set: vi.fn(async (next: Record<string, unknown>) => {32            Object.assign(storage, next);33          }),34          remove: vi.fn(async (keys: string | string[]) => {35            const list = Array.isArray(keys) ? keys : [keys];36            for (const key of list) {37              delete storage[key];38            }39          }),40        },41      },42      alarms: {43        clear: vi.fn((name: string) => {44          alarms.cleared.push(name);45        }),46        create: vi.fn((name: string, info: { delayInMinutes: number }) => {47          alarms.created.push({ name, delayInMinutes: info.delayInMinutes });48        }),49        onAlarm: { addListener: vi.fn() },50      },51      idle: {52        setDetectionInterval: vi.fn(),53        onStateChanged: { addListener: vi.fn() },54      },55    } as unknown as typeof chrome;5657    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;5859    // Prevent fire-and-forget breach-check fetches from leaking into other tests.60    vi.stubGlobal("fetch", vi.fn(async () => {61      throw new Error("network disabled in tests");62    }));63  });6465  it("creates a vault and stores encrypted payload", async () => {66    const vault = await import("./vault");6768    await vault.createVault("correct horse battery staple");6970    expect(storage.peach_vault_a).toBeDefined();71    expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy();72    expect(vault.getEntries()).toEqual([]);73    expect(alarms.created).toContainEqual({74      name: "autolock",75      delayInMinutes: 5,76    });77  });7879  it("writes both slots A and B on vault creation", async () => {80    const vault = await import("./vault");81    await vault.createVault("correct horse battery staple");8283    expect(storage.peach_vault_a).toBeDefined();84    expect(storage.peach_vault_b).toBeDefined();85    expect(storage.peach_duress).toBeUndefined();86    expect(storage.peach_vault).toBeUndefined();87    expect(storage.peach_decoy_vault).toBeUndefined();88  });8990  it("slot A and slot B have matching ciphertext size at fresh install", async () => {91    const vault = await import("./vault");92    await vault.createVault("correct horse battery staple");9394    const a = storage.peach_vault_a as StoredVault;95    const b = storage.peach_vault_b as StoredVault;96    expect(a.ciphertext.length).toBe(b.ciphertext.length);97  });9899  it("does not expose a plaintext duress flag after setupDuress", async () => {100    const vault = await import("./vault");101    await vault.createVault("real-password-12345");102    await vault.setupDuress("decoy-password-67890");103104    expect(storage.peach_duress).toBeUndefined();105    expect(storage.peach_decoy_vault).toBeUndefined();106    const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));107    // Only the two slot-neutral keys (and optionally peach_settings) may exist.108    for (const key of keys) {109      expect(110        ["peach_vault_a", "peach_vault_b", "peach_settings"]111      ).toContain(key);112    }113    expect(keys).toContain("peach_vault_a");114    expect(keys).toContain("peach_vault_b");115  });116117  it("unlocks with real password and loads real entries", async () => {118    const vault = await import("./vault");119    await vault.createVault("real-password-12345");120    await vault.addEntry({121      title: "GitHub",122      username: "june",123      password: "realsecret",124      url: "github.com",125    });126    vault.lock();127128    const ok = await vault.unlock("real-password-12345");129    expect(ok).toBe(true);130    expect(vault.getEntries()).toHaveLength(1);131    expect(vault.getEntries()[0].title).toBe("GitHub");132  });133134  it("unlocks with duress password and loads empty entries", async () => {135    const vault = await import("./vault");136    await vault.createVault("real-password-12345");137    await vault.addEntry({138      title: "GitHub",139      username: "june",140      password: "realsecret",141      url: "github.com",142    });143    await vault.setupDuress("decoy-password-67890");144    vault.lock();145146    const ok = await vault.unlock("decoy-password-67890");147    expect(ok).toBe(true);148    expect(vault.getEntries()).toEqual([]);149  });150151  it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {152    // The unlock path must derive keys for both slots unconditionally so a153    // real-mode unlock and a decoy-mode unlock both spend roughly the same154    // wall-clock time. Verified here by spying on deriveKey and asserting it155    // is called twice (once per slot) on every unlock attempt.156    const argon2 = await import("../crypto/argon2");157    const deriveSpy = vi.spyOn(argon2, "deriveKey");158159    const vault = await import("./vault");160    await vault.createVault("real-password-12345");161    await vault.setupDuress("decoy-password-67890");162    vault.lock();163164    deriveSpy.mockClear();165    await vault.unlock("real-password-12345");166    expect(deriveSpy).toHaveBeenCalledTimes(2);167    vault.lock();168169    deriveSpy.mockClear();170    await vault.unlock("decoy-password-67890");171    expect(deriveSpy).toHaveBeenCalledTimes(2);172    vault.lock();173174    deriveSpy.mockClear();175    await vault.unlock("wrong-password-xxxxx");176    expect(deriveSpy).toHaveBeenCalledTimes(2);177178    deriveSpy.mockRestore();179  });180181  it("rejects wrong passwords", async () => {182    const vault = await import("./vault");183    await vault.createVault("real-password-12345");184    vault.lock();185186    const ok = await vault.unlock("nope-nope-nope");187    expect(ok).toBe(false);188  });189190  it("persists duressConfigured across lock/unlock in encrypted payload", async () => {191    const vault = await import("./vault");192    await vault.createVault("real-password-12345");193    await vault.setupDuress("decoy-password-67890");194    vault.lock();195196    await vault.unlock("real-password-12345");197    expect(await vault.isDuressEnabled()).toBe(true);198199    await vault.disableDuress();200    expect(await vault.isDuressEnabled()).toBe(false);201    vault.lock();202203    await vault.unlock("real-password-12345");204    expect(await vault.isDuressEnabled()).toBe(false);205  });206207  it("migrates legacy peach_vault / peach_decoy_vault / peach_duress schema", async () => {208    // Arrange: set up storage as if from pre-A-2 (v1.4.24 and earlier).209    const firstVault = await import("./vault");210    await firstVault.createVault("legacy-password-abcde");211    const legacySlotA = storage.peach_vault_a as StoredVault;212    storage.peach_vault = legacySlotA;213    delete storage.peach_vault_a;214    delete storage.peach_vault_b;215    storage.peach_duress = { enabled: true };216    storage.peach_decoy_vault = legacySlotA; // reuse shape for test217    vi.resetModules();218219    // Act: fresh module load triggers migration on next vault access.220    const vault = await import("./vault");221    await vault.hasVault();222223    // Assert: legacy keys removed, new keys present.224    expect(storage.peach_vault).toBeUndefined();225    expect(storage.peach_decoy_vault).toBeUndefined();226    expect(storage.peach_duress).toBeUndefined();227    expect(storage.peach_vault_a).toBeDefined();228    expect(storage.peach_vault_b).toBeDefined();229  });230231  it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {232    const vault = await import("./vault");233234    await vault.createVault("correct horse battery staple");235    const entry = await vault.addEntry({236      title: "GitHub",237      username: "june",238      password: "secret123",239      url: "github.com",240    });241242    await vault.deleteEntry(entry.id);243244    expect(vault.getEntries()).toEqual([]);245246    const stored = storage.peach_vault_a as StoredVault;247    const salt = Uint8Array.from(248      stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))249    );250    const key = await deriveKey("correct horse battery staple", salt);251    const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag);252    const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };253254    expect(parsed.entries).toHaveLength(1);255    expect(parsed.entries[0].id).toBe(entry.id);256    expect(typeof parsed.entries[0].deletedAt).toBe("number");257  });258259  it("accepts imported entries without a username or url", async () => {260    const vault = await import("./vault");261262    await vault.createVault("correct horse battery staple");263    const entry = await vault.addEntry({264      title: "Imported Login",265      username: "",266      password: "secret123",267      url: "",268      notes: "migrated from another manager",269    });270271    expect(entry.username).toBe("");272    expect(entry.url).toBe("");273    expect(vault.getEntries()).toHaveLength(1);274  });275276  it("changePassword happy path — new password unlocks, old is rejected", async () => {277    const vault = await import("./vault");278279    await vault.createVault("old-pass-abc123");280    await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });281    await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });282283    expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);284285    vault.lock();286    expect(await vault.unlock("new-pass-xyz789")).toBe(true);287    expect(vault.getEntries()).toHaveLength(2);288289    vault.lock();290    expect(await vault.unlock("old-pass-abc123")).toBe(false);291  });292293  it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {294    const vault = await import("./vault");295296    await vault.createVault("correct-pass-abcde");297    const slotABefore = JSON.stringify(storage.peach_vault_a);298299    expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);300    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);301302    vault.lock();303    expect(await vault.unlock("correct-pass-abcde")).toBe(true);304  });305306  it("changePassword with duress configured leaves slot B byte-identical", async () => {307    const vault = await import("./vault");308309    await vault.createVault("real-pass-abcde");310    await vault.setupDuress("duress-pass-xyzzz");311312    const slotBBefore = JSON.stringify(storage.peach_vault_b);313    expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);314    expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);315316    // Duress path still works with the old decoy password.317    vault.lock();318    expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);319    expect(vault.getEntries()).toEqual([]);320  });321322  it("restoreFromSnapshot with correct password restores the snapshotted entries", async () => {323    const vault = await import("./vault");324325    await vault.createVault("restore-pass-abcde");326    await vault.addEntry({ title: "A", username: "", password: "p1", url: "" });327    await vault.addEntry({ title: "B", username: "", password: "p2", url: "" });328    await vault.addEntry({ title: "C", username: "", password: "p3", url: "" });329    await new Promise<void>((r) => setTimeout(r, 0));330331    await vault.addEntry({ title: "D", username: "", password: "p4", url: "" });332    await vault.addEntry({ title: "E", username: "", password: "p5", url: "" });333    await new Promise<void>((r) => setTimeout(r, 0));334    expect(vault.getEntries()).toHaveLength(5);335336    const snapshots = await vault.listSnapshots();337    const targetIndex = snapshots.findIndex((s) => s.entryCount === 3);338    expect(targetIndex).toBeGreaterThanOrEqual(0);339340    expect(await vault.restoreFromSnapshot(targetIndex, "restore-pass-abcde")).toBe(true);341    expect(vault.getEntries()).toHaveLength(3);342  });343344  it("restoreFromSnapshot with wrong password returns false and writes no slot", async () => {345    const vault = await import("./vault");346347    await vault.createVault("right-pass-abcde");348    await vault.addEntry({ title: "X", username: "", password: "px", url: "" });349    await new Promise<void>((r) => setTimeout(r, 0));350351    const setMock = (chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>);352    setMock.mockClear();353354    expect(await vault.restoreFromSnapshot(0, "wrong-pass-fghij")).toBe(false);355356    const slotWrites = setMock.mock.calls.filter((args) => {357      const payload = args[0] as Record<string, unknown>;358      return "peach_vault_a" in payload || "peach_vault_b" in payload;359    });360    expect(slotWrites).toHaveLength(0);361  });362363  it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {364    const vault = await import("./vault");365366    await vault.createVault("pass-abcdef");367    await vault.addEntry({ title: "One", username: "", password: "1", url: "" });368    await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });369    await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });370371    await expect(vault.restoreEntries([], "replace")).rejects.toThrow(372      /refuse to replace with empty entry set/i373    );374    expect(vault.getEntries()).toHaveLength(3);375  });376377  it("changePassword swaps the active key before Slot B is regenerated", async () => {378    const vault = await import("./vault");379380    await vault.createVault("correct-pass-12345");381    const entry = await vault.addEntry({382      title: "GitHub",383      username: "june",384      password: "secret123",385      url: "https://github.com",386    });387388    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;389    const originalSet = setMock.getMockImplementation();390    let injectedDuringSlotBWrite = false;391392    setMock.mockImplementation(async (next: Record<string, unknown>) => {393      Object.assign(storage, next);394395      if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {396        injectedDuringSlotBWrite = true;397        await vault.updateEntry(entry.id, { notes: "updated during password change" });398      }399    });400401    expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);402403    setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {404      Object.assign(storage, next);405    }));406407    vault.lock();408    expect(await vault.unlock("new-pass-67890")).toBe(true);409    expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");410411    vault.lock();412    expect(await vault.unlock("correct-pass-12345")).toBe(false);413  });414415  it("changePassword takes a snapshot", async () => {416    const vault = await import("./vault");417418    await vault.createVault("snapshot-pass-12345");419    await vault.addEntry({420      title: "GitHub",421      username: "june",422      password: "secret123",423      url: "https://github.com",424    });425    await new Promise<void>((resolve) => setTimeout(resolve, 0));426427    const before = await vault.listSnapshots();428    expect(await vault.changePassword("snapshot-pass-12345", "snapshot-pass-67890")).toBe(true);429    await new Promise<void>((resolve) => setTimeout(resolve, 0));430431    const after = await vault.listSnapshots();432    expect(after).toHaveLength(before.length + 1);433  });434435  it("restoreEntries replace mode replaces existing entries", async () => {436    const vault = await import("./vault");437438    await vault.createVault("replace-pass-12345");439    await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });440    await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });441442    const incoming = [443      {444        id: "entry-c",445        type: "login" as const,446        title: "C",447        username: "june",448        password: "three",449        url: "https://c.test",450        createdAt: 10,451        modifiedAt: 10,452      },453      {454        id: "entry-d",455        type: "login" as const,456        title: "D",457        username: "june",458        password: "four",459        url: "https://d.test",460        createdAt: 20,461        modifiedAt: 20,462      },463    ];464465    await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({466      added: 2,467      updated: 0,468      skipped: 0,469    });470471    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);472  });473474  it("restoreEntries merge mode lets newer incoming entries win", async () => {475    const vault = await import("./vault");476477    await vault.createVault("merge-pass-12345");478    const existing = await vault.addEntry({479      title: "GitHub",480      username: "june",481      password: "old-secret",482      url: "https://github.com",483    });484485    await expect(486      vault.restoreEntries(487        [488          {489            ...existing,490            password: "new-secret",491            modifiedAt: existing.modifiedAt + 100,492          },493        ],494        "merge"495      )496    ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });497498    expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");499  });500501  it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {502    const vault = await import("./vault");503504    await vault.createVault("merge-pass-67890");505    const existing = await vault.addEntry({506      title: "GitHub",507      username: "june",508      password: "current-secret",509      url: "https://github.com",510    });511512    await expect(513      vault.restoreEntries(514        [515          {516            ...existing,517            password: "stale-secret",518            modifiedAt: existing.modifiedAt - 100,519          },520        ],521        "merge"522      )523    ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });524525    expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");526  });527528  it("clearVaultEntries without explicit confirmation throws", async () => {529    const vault = await import("./vault");530531    await vault.createVault("clear-pass-12345");532    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });533534    const clearVaultEntries = vault.clearVaultEntries as unknown as (535      confirmation?: unknown536    ) => Promise<void>;537538    await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);539    await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);540    expect(vault.getEntries()).toHaveLength(1);541  });542543  it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {544    const vault = await import("./vault");545546    await vault.createVault("clear-pass-67890");547    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });548    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });549    await vault.addEntry({ title: "C", username: "", password: "three", url: "" });550551    await vault.clearVaultEntries("REPLACE_ALL");552553    expect(vault.getEntries()).toEqual([]);554    expect(vault.getDeletedEntries()).toHaveLength(3);555    expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);556  });557558  it("restoreFromSnapshot after changePassword requires the new password", async () => {559    const vault = await import("./vault");560561    await vault.createVault("old-snapshot-pass");562    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });563    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });564    await new Promise<void>((resolve) => setTimeout(resolve, 0));565566    const snapshots = await vault.listSnapshots();567    const snapshotIndex = snapshots.findIndex((snapshot) => snapshot.entryCount === 2);568    expect(snapshotIndex).toBeGreaterThanOrEqual(0);569570    expect(await vault.changePassword("old-snapshot-pass", "new-snapshot-pass")).toBe(true);571572    expect(await vault.restoreFromSnapshot(snapshotIndex, "old-snapshot-pass")).toBe(false);573    expect(await vault.restoreFromSnapshot(snapshotIndex, "new-snapshot-pass")).toBe(true);574    expect(vault.getEntries()).toHaveLength(2);575  });576577  it("deduplicateEntries throws in decoy mode", async () => {578    const vault = await import("./vault");579580    await vault.createVault("real-pass-12345");581    await vault.addEntry({582      title: "GitHub",583      username: "june",584      password: "secret123",585      url: "https://github.com",586    });587    await vault.setupDuress("duress-pass-12345");588    vault.lock();589590    expect(await vault.unlock("duress-pass-12345")).toBe(true);591    await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);592  });593594  it("deduplicateEntries removes older duplicates in real mode", async () => {595    const vault = await import("./vault");596597    await vault.createVault("dedupe-pass-12345");598    await vault.addEntry({599      title: "GitHub",600      username: "june",601      password: "old-secret",602      url: "https://github.com",603    });604    await new Promise<void>((resolve) => setTimeout(resolve, 5));605    const newer = await vault.addEntry({606      title: "GitHub",607      username: "june",608      password: "new-secret",609      url: "https://github.com",610    });611612    expect(await vault.deduplicateEntries()).toBe(1);613    expect(vault.getEntries()).toHaveLength(1);614    expect(vault.getEntries()[0].id).toBe(newer.id);615    expect(vault.getDeletedEntries()).toHaveLength(1);616  });617618  it("updateEntry with an empty password on a login entry throws and rolls back", async () => {619    const vault = await import("./vault");620621    await vault.createVault("update-pass-12345");622    const entry = await vault.addEntry({623      title: "GitHub",624      username: "june",625      password: "secret123",626      url: "https://github.com",627    });628629    await expect(vault.updateEntry(entry.id, { password: "" })).rejects.toThrow(630      /password is required/i631    );632    expect(vault.getEntryById(entry.id)?.password).toBe("secret123");633  });634635  it("updateEntry with an empty password on a note entry is allowed", async () => {636    const vault = await import("./vault");637638    await vault.createVault("note-pass-12345");639    const entry = await vault.addEntry({640      type: "note",641      title: "Secure note",642      notes: "keep me",643    });644645    await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();646    expect(vault.getEntryById(entry.id)).toMatchObject({647      type: "note",648      notes: "updated",649    });650  });651652  it("lock zeroes the derived key and resets unlock state", async () => {653    const aes = await import("./aes");654    const zeroKeySpy = vi.spyOn(aes, "zeroKey");655    const vault = await import("./vault");656657    await vault.createVault("lock-pass-12345");658    expect(vault.isUnlocked()).toBe(true);659660    vault.lock();661662    expect(zeroKeySpy).toHaveBeenCalled();663    expect(vault.isUnlocked()).toBe(false);664    zeroKeySpy.mockRestore();665  });666667  it("lock clears in-memory entries", async () => {668    const vault = await import("./vault");669670    await vault.createVault("lock-pass-67890");671    const entry = await vault.addEntry({672      title: "GitHub",673      username: "june",674      password: "secret123",675      url: "https://github.com",676    });677678    expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");679    vault.lock();680    expect(vault.getEntryById(entry.id)).toBeUndefined();681  });682683  it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {684    const vault = await import("./vault");685686    await vault.createVault("real-pass-67890");687    vault.lock();688689    expect(await vault.unlock("totally-wrong-password")).toBe(false);690    expect(vault.isUnlocked()).toBe(false);691  });692693  it("isUnlocked tracks the full lock lifecycle", async () => {694    const vault = await import("./vault");695696    expect(vault.isUnlocked()).toBe(false);697    await vault.createVault("unlock-pass-12345");698    expect(vault.isUnlocked()).toBe(true);699    vault.lock();700    expect(vault.isUnlocked()).toBe(false);701    expect(await vault.unlock("unlock-pass-12345")).toBe(true);702    expect(vault.isUnlocked()).toBe(true);703    vault.lock();704    expect(vault.isUnlocked()).toBe(false);705  });706707  it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {708    const vault = await import("./vault");709710    await vault.createVault("quota-pass-12345");711712    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;713    setMock.mockImplementation(async (next: Record<string, unknown>) => {714      if ("peach_vault_a" in next || "peach_vault_b" in next) {715        throw new Error("QUOTA_BYTES quota reached");716      }717      Object.assign(storage, next);718    });719720    await expect(721      vault.addEntry({722        title: "GitHub",723        username: "june",724        password: "secret123",725        url: "https://github.com",726      })727    ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");728  });729730  it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {731    const vault = await import("./vault");732733    await vault.createVault("settings-pass-12345");734735    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;736    setMock.mockImplementation(async (next: Record<string, unknown>) => {737      if ("peach_settings" in next) {738        throw new Error("QUOTA_BYTES quota reached");739      }740      Object.assign(storage, next);741    });742743    await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(744      "STORAGE_QUOTA_EXCEEDED"745    );746  });747});