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 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 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 153 154 155 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 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; 217 vi.resetModules();218219 220 const vault = await import("./vault");221 await vault.hasVault();222223 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 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 });747748 it("card entry round-trips through vault — all fields preserved", async () => {749 const vault = await import("./vault");750751 await vault.createVault("card-test-pass-12345");752 const entry = await vault.addEntry({753 type: "card",754 title: "Personal Visa",755 cardholderName: "Jane Smith",756 cardNumber: "4111111111111111",757 expMonth: "05",758 expYear: "2028",759 cvc: "123",760 cardNickname: "Personal Visa",761 network: "visa",762 last4: "1111",763 });764765 expect(entry.type).toBe("card");766 expect(entry.cardholderName).toBe("Jane Smith");767 expect(entry.cardNumber).toBe("4111111111111111");768 expect(entry.expMonth).toBe("05");769 expect(entry.expYear).toBe("2028");770 expect(entry.cvc).toBe("123");771 expect(entry.network).toBe("visa");772 expect(entry.last4).toBe("1111");773774 vault.lock();775 await vault.unlock("card-test-pass-12345");776 const entries = vault.getEntries();777 expect(entries).toHaveLength(1);778 expect(entries[0].cardNumber).toBe("4111111111111111");779 expect(entries[0].cvc).toBe("123");780 });781782 it("updateEntry — cannot clear card number on card entry", async () => {783 const vault = await import("./vault");784785 await vault.createVault("card-guard-pass-12345");786 const entry = await vault.addEntry({787 type: "card",788 title: "My Card",789 cardholderName: "Jane Smith",790 cardNumber: "4111111111111111",791 expMonth: "05",792 expYear: "2028",793 cvc: "123",794 });795796 await expect(797 vault.updateEntry(entry.id, { cardNumber: "" })798 ).rejects.toThrow("card number is required for card entries");799800 const entries = vault.getEntries();801 expect(entries[0].cardNumber).toBe("4111111111111111");802 });803804 it("declineCardSaveOffer — stores fingerprint in settings", async () => {805 const vault = await import("./vault");806807 await vault.createVault("decline-pass-12345");808 await vault.declineCardSaveOffer("1111", "05", "2028");809810 const settings = vault.getSettings();811 expect(settings.declinedCardFingerprints).toHaveLength(1);812 });813814 it("isCardDeclined — returns true for previously declined card", async () => {815 const vault = await import("./vault");816817 await vault.createVault("declined-check-pass-12345");818 await vault.declineCardSaveOffer("1111", "05", "2028");819820 expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);821 });822823 it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {824 const vault = await import("./vault");825826 await vault.createVault("declined-diff-pass-12345");827 await vault.declineCardSaveOffer("1111", "05", "2028");828829 expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);830 });831832 it("queryDomain matches an entry against its additionalUrls", async () => {833 const vault = await import("./vault");834835 await vault.createVault("multi-domain-pass-12345");836 const entry = await vault.addEntry({837 type: "login",838 title: "Foo",839 username: "june",840 password: "secret",841 url: "https://foo.com",842 additionalUrls: ["https://bar.io", "https://baz.org"],843 });844845 846 847 expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);848 expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);849 expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);850 expect(vault.queryDomain("unrelated.com")).toEqual([]);851 });852853 it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {854 const vault = await import("./vault");855856 await vault.createVault("dedupe-additional-pass-12345");857 const entry = await vault.addEntry({858 type: "login",859 title: "Foo",860 username: "june",861 password: "secret",862 url: "https://foo.com",863 });864865 await vault.updateEntry(entry.id, {866 additionalUrls: [867 " https://login.foo.com ",868 "https://login.foo.com",869 "",870 " ",871 "https://accounts.foo.com",872 ],873 });874875 const stored = vault.getEntryById(entry.id);876 expect(stored?.additionalUrls).toEqual([877 "https://login.foo.com",878 "https://accounts.foo.com",879 ]);880881 await vault.updateEntry(entry.id, { additionalUrls: ["", " "] });882 expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();883 });884});