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 109 for (const key of keys) {110 expect(111 [112 "peach_vault_a",113 "peach_vault_b",114 "peach_settings",115 "peach_vault_history",116 "peach_last_modified_at",117 ]118 ).toContain(key);119 }120 expect(keys).toContain("peach_vault_a");121 expect(keys).toContain("peach_vault_b");122 });123124 it("unlocks with real password and loads real entries", async () => {125 const vault = await import("./vault");126 await vault.createVault("real-password-12345");127 await vault.addEntry({128 title: "GitHub",129 username: "june",130 password: "realsecret",131 url: "github.com",132 });133 vault.lock();134135 const ok = await vault.unlock("real-password-12345");136 expect(ok).toBe(true);137 expect(vault.getEntries()).toHaveLength(1);138 expect(vault.getEntries()[0].title).toBe("GitHub");139 });140141 it("unlocks with duress password and loads empty entries", async () => {142 const vault = await import("./vault");143 await vault.createVault("real-password-12345");144 await vault.addEntry({145 title: "GitHub",146 username: "june",147 password: "realsecret",148 url: "github.com",149 });150 await vault.setupDuress("decoy-password-67890");151 vault.lock();152153 const ok = await vault.unlock("decoy-password-67890");154 expect(ok).toBe(true);155 expect(vault.getEntries()).toEqual([]);156 });157158 it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {159 160 161 162 163 const argon2 = await import("../crypto/argon2");164 const deriveSpy = vi.spyOn(argon2, "deriveKey");165166 const vault = await import("./vault");167 await vault.createVault("real-password-12345");168 await vault.setupDuress("decoy-password-67890");169 vault.lock();170171 deriveSpy.mockClear();172 await vault.unlock("real-password-12345");173 expect(deriveSpy).toHaveBeenCalledTimes(2);174 vault.lock();175176 deriveSpy.mockClear();177 await vault.unlock("decoy-password-67890");178 expect(deriveSpy).toHaveBeenCalledTimes(2);179 vault.lock();180181 deriveSpy.mockClear();182 await vault.unlock("wrong-password-xxxxx");183 expect(deriveSpy).toHaveBeenCalledTimes(2);184185 deriveSpy.mockRestore();186 });187188 it("rejects wrong passwords", async () => {189 const vault = await import("./vault");190 await vault.createVault("real-password-12345");191 vault.lock();192193 const ok = await vault.unlock("nope-nope-nope");194 expect(ok).toBe(false);195 });196197 it("persists duressConfigured across lock/unlock in encrypted payload", async () => {198 const vault = await import("./vault");199 await vault.createVault("real-password-12345");200 await vault.setupDuress("decoy-password-67890");201 vault.lock();202203 await vault.unlock("real-password-12345");204 expect(await vault.isDuressEnabled()).toBe(true);205206 await vault.disableDuress();207 expect(await vault.isDuressEnabled()).toBe(false);208 vault.lock();209210 await vault.unlock("real-password-12345");211 expect(await vault.isDuressEnabled()).toBe(false);212 });213214 it("migrates legacy peach_vault / peach_decoy_vault / peach_duress schema", async () => {215 216 const firstVault = await import("./vault");217 await firstVault.createVault("legacy-password-abcde");218 const legacySlotA = storage.peach_vault_a as StoredVault;219 storage.peach_vault = legacySlotA;220 delete storage.peach_vault_a;221 delete storage.peach_vault_b;222 storage.peach_duress = { enabled: true };223 storage.peach_decoy_vault = legacySlotA; 224 vi.resetModules();225226 227 const vault = await import("./vault");228 await vault.hasVault();229230 231 expect(storage.peach_vault).toBeUndefined();232 expect(storage.peach_decoy_vault).toBeUndefined();233 expect(storage.peach_duress).toBeUndefined();234 expect(storage.peach_vault_a).toBeDefined();235 expect(storage.peach_vault_b).toBeDefined();236 });237238 it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {239 const vault = await import("./vault");240241 await vault.createVault("correct horse battery staple");242 const entry = await vault.addEntry({243 title: "GitHub",244 username: "june",245 password: "secret123",246 url: "github.com",247 });248249 await vault.deleteEntry(entry.id);250251 expect(vault.getEntries()).toEqual([]);252253 const stored = storage.peach_vault_a as StoredVault;254 const salt = Uint8Array.from(255 stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))256 );257 const key = await deriveKey("correct horse battery staple", salt);258 const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");259 const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };260261 expect(parsed.entries).toHaveLength(1);262 expect(parsed.entries[0].id).toBe(entry.id);263 expect(typeof parsed.entries[0].deletedAt).toBe("number");264 });265266 it("accepts imported entries without a username or url", async () => {267 const vault = await import("./vault");268269 await vault.createVault("correct horse battery staple");270 const entry = await vault.addEntry({271 title: "Imported Login",272 username: "",273 password: "secret123",274 url: "",275 notes: "migrated from another manager",276 });277278 expect(entry.username).toBe("");279 expect(entry.url).toBe("");280 expect(vault.getEntries()).toHaveLength(1);281 });282283 it("changePassword happy path — new password unlocks, old is rejected", async () => {284 const vault = await import("./vault");285286 await vault.createVault("old-pass-abc123");287 await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });288 await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });289290 expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);291292 vault.lock();293 expect(await vault.unlock("new-pass-xyz789")).toBe(true);294 expect(vault.getEntries()).toHaveLength(2);295296 vault.lock();297 expect(await vault.unlock("old-pass-abc123")).toBe(false);298 });299300 it("stamps peach_last_modified_at on createVault", async () => {301 const vault = await import("./vault");302 const before = Date.now();303 await new Promise((resolve) => setTimeout(resolve, 1));304305 await vault.createVault("stamp-create-pass");306307 expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);308 });309310 it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {311 const vault = await import("./vault");312 await vault.createVault("stamp-entry-pass");313314 let previous = Number(storage.peach_last_modified_at);315 await new Promise((resolve) => setTimeout(resolve, 1));316 const entry = await vault.addEntry({317 title: "Stamp",318 username: "",319 password: "p",320 url: "",321 });322 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);323324 previous = Number(storage.peach_last_modified_at);325 await new Promise((resolve) => setTimeout(resolve, 1));326 await vault.updateEntry(entry.id, { title: "Stamped" });327 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);328329 previous = Number(storage.peach_last_modified_at);330 await new Promise((resolve) => setTimeout(resolve, 1));331 await vault.deleteEntry(entry.id);332 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);333 });334335 it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {336 const vault = await import("./vault");337 await vault.createVault("stamp-real-pass");338339 let previous = Number(storage.peach_last_modified_at);340 await new Promise((resolve) => setTimeout(resolve, 1));341 await vault.setupDuress("stamp-decoy-pass");342 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);343344 previous = Number(storage.peach_last_modified_at);345 await new Promise((resolve) => setTimeout(resolve, 1));346 expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);347 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);348 });349350 it("applyIncomingVault preserves the remote last-modified timestamp", async () => {351 const vault = await import("./vault");352 await vault.createVault("remote-pass");353 const blob = JSON.stringify({354 a: storage.peach_vault_a,355 b: storage.peach_vault_b,356 });357 storage.peach_last_modified_at = "1000";358 const remoteTimestamp = 2000;359360 expect(await vault.applyIncomingVault(blob, remoteTimestamp)).toBe(true);361362 expect(storage.peach_last_modified_at).toBe(String(remoteTimestamp));363 });364365 it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {366 const vault = await import("./vault");367368 await vault.createVault("correct-pass-abcde");369 const slotABefore = JSON.stringify(storage.peach_vault_a);370371 expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);372 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);373374 vault.lock();375 expect(await vault.unlock("correct-pass-abcde")).toBe(true);376 });377378 it("changePassword with duress configured leaves slot B byte-identical", async () => {379 const vault = await import("./vault");380381 await vault.createVault("real-pass-abcde");382 await vault.setupDuress("duress-pass-xyzzz");383384 const slotBBefore = JSON.stringify(storage.peach_vault_b);385 expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);386 expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);387388 389 vault.lock();390 expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);391 expect(vault.getEntries()).toEqual([]);392 });393394 it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {395 const vault = await import("./vault");396397 await vault.createVault("pass-abcdef");398 await vault.addEntry({ title: "One", username: "", password: "1", url: "" });399 await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });400 await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });401402 await expect(vault.restoreEntries([], "replace")).rejects.toThrow(403 /refuse to replace with empty entry set/i404 );405 expect(vault.getEntries()).toHaveLength(3);406 });407408 it("changePassword swaps the active key before Slot B is regenerated", async () => {409 const vault = await import("./vault");410411 await vault.createVault("correct-pass-12345");412 const entry = await vault.addEntry({413 title: "GitHub",414 username: "june",415 password: "secret123",416 url: "https://github.com",417 });418419 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;420 const originalSet = setMock.getMockImplementation();421 let injectedDuringSlotBWrite = false;422423 setMock.mockImplementation(async (next: Record<string, unknown>) => {424 Object.assign(storage, next);425426 if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {427 injectedDuringSlotBWrite = true;428 await vault.updateEntry(entry.id, { notes: "updated during password change" });429 }430 });431432 expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);433434 setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {435 Object.assign(storage, next);436 }));437438 vault.lock();439 expect(await vault.unlock("new-pass-67890")).toBe(true);440 expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");441442 vault.lock();443 expect(await vault.unlock("correct-pass-12345")).toBe(false);444 });445446 it("restoreEntries replace mode replaces existing entries", async () => {447 const vault = await import("./vault");448449 await vault.createVault("replace-pass-12345");450 await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });451 await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });452453 const incoming = [454 {455 id: "entry-c",456 type: "login" as const,457 title: "C",458 username: "june",459 password: "three",460 url: "https://c.test",461 createdAt: 10,462 modifiedAt: 10,463 },464 {465 id: "entry-d",466 type: "login" as const,467 title: "D",468 username: "june",469 password: "four",470 url: "https://d.test",471 createdAt: 20,472 modifiedAt: 20,473 },474 ];475476 await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({477 added: 2,478 updated: 0,479 skipped: 0,480 });481482 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);483 });484485 it("restoreEntries merge mode lets newer incoming entries win", async () => {486 const vault = await import("./vault");487488 await vault.createVault("merge-pass-12345");489 const existing = await vault.addEntry({490 title: "GitHub",491 username: "june",492 password: "old-secret",493 url: "https://github.com",494 });495496 await expect(497 vault.restoreEntries(498 [499 {500 ...existing,501 password: "new-secret",502 modifiedAt: existing.modifiedAt + 100,503 },504 ],505 "merge"506 )507 ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });508509 expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");510 });511512 it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {513 const vault = await import("./vault");514515 await vault.createVault("merge-pass-67890");516 const existing = await vault.addEntry({517 title: "GitHub",518 username: "june",519 password: "current-secret",520 url: "https://github.com",521 });522523 await expect(524 vault.restoreEntries(525 [526 {527 ...existing,528 password: "stale-secret",529 modifiedAt: existing.modifiedAt - 100,530 },531 ],532 "merge"533 )534 ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });535536 expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");537 });538539 it("clearVaultEntries without explicit confirmation throws", async () => {540 const vault = await import("./vault");541542 await vault.createVault("clear-pass-12345");543 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });544545 const clearVaultEntries = vault.clearVaultEntries as unknown as (546 confirmation?: unknown547 ) => Promise<void>;548549 await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);550 await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);551 expect(vault.getEntries()).toHaveLength(1);552 });553554 it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {555 const vault = await import("./vault");556557 await vault.createVault("clear-pass-67890");558 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });559 await vault.addEntry({ title: "B", username: "", password: "two", url: "" });560 await vault.addEntry({ title: "C", username: "", password: "three", url: "" });561562 await vault.clearVaultEntries("REPLACE_ALL");563564 expect(vault.getEntries()).toEqual([]);565 expect(vault.getDeletedEntries()).toHaveLength(3);566 expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);567 });568569 it("deduplicateEntries throws in decoy mode", async () => {570 const vault = await import("./vault");571572 await vault.createVault("real-pass-12345");573 await vault.addEntry({574 title: "GitHub",575 username: "june",576 password: "secret123",577 url: "https://github.com",578 });579 await vault.setupDuress("duress-pass-12345");580 vault.lock();581582 expect(await vault.unlock("duress-pass-12345")).toBe(true);583 await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);584 });585586 it("deduplicateEntries removes older duplicates in real mode", async () => {587 const vault = await import("./vault");588589 await vault.createVault("dedupe-pass-12345");590 await vault.addEntry({591 title: "GitHub",592 username: "june",593 password: "old-secret",594 url: "https://github.com",595 });596 await new Promise<void>((resolve) => setTimeout(resolve, 5));597 const newer = await vault.addEntry({598 title: "GitHub",599 username: "june",600 password: "new-secret",601 url: "https://github.com",602 });603604 expect(await vault.deduplicateEntries()).toBe(1);605 expect(vault.getEntries()).toHaveLength(1);606 expect(vault.getEntries()[0].id).toBe(newer.id);607 expect(vault.getDeletedEntries()).toHaveLength(1);608 });609610 it("updateEntry with an empty password on a login entry throws and rolls back", async () => {611 const vault = await import("./vault");612613 await vault.createVault("update-pass-12345");614 const entry = await vault.addEntry({615 title: "GitHub",616 username: "june",617 password: "secret123",618 url: "https://github.com",619 });620621 await expect(vault.updateEntry(entry.id, { password: "" })).rejects.toThrow(622 /password or passkey required/i623 );624 expect(vault.getEntryById(entry.id)?.password).toBe("secret123");625 });626627 it("updateEntry with an empty password on a note entry is allowed", async () => {628 const vault = await import("./vault");629630 await vault.createVault("note-pass-12345");631 const entry = await vault.addEntry({632 type: "note",633 title: "Secure note",634 notes: "keep me",635 });636637 await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();638 expect(vault.getEntryById(entry.id)).toMatchObject({639 type: "note",640 notes: "updated",641 });642 });643644 it("lock zeroes the derived key and resets unlock state", async () => {645 const aes = await import("./aes");646 const zeroKeySpy = vi.spyOn(aes, "zeroKey");647 const vault = await import("./vault");648649 await vault.createVault("lock-pass-12345");650 expect(vault.isUnlocked()).toBe(true);651652 vault.lock();653654 expect(zeroKeySpy).toHaveBeenCalled();655 expect(vault.isUnlocked()).toBe(false);656 zeroKeySpy.mockRestore();657 });658659 it("lock clears in-memory entries", async () => {660 const vault = await import("./vault");661662 await vault.createVault("lock-pass-67890");663 const entry = await vault.addEntry({664 title: "GitHub",665 username: "june",666 password: "secret123",667 url: "https://github.com",668 });669670 expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");671 vault.lock();672 expect(vault.getEntryById(entry.id)).toBeUndefined();673 });674675 it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {676 const vault = await import("./vault");677678 await vault.createVault("real-pass-67890");679 vault.lock();680681 expect(await vault.unlock("totally-wrong-password")).toBe(false);682 expect(vault.isUnlocked()).toBe(false);683 });684685 it("isUnlocked tracks the full lock lifecycle", async () => {686 const vault = await import("./vault");687688 expect(vault.isUnlocked()).toBe(false);689 await vault.createVault("unlock-pass-12345");690 expect(vault.isUnlocked()).toBe(true);691 vault.lock();692 expect(vault.isUnlocked()).toBe(false);693 expect(await vault.unlock("unlock-pass-12345")).toBe(true);694 expect(vault.isUnlocked()).toBe(true);695 vault.lock();696 expect(vault.isUnlocked()).toBe(false);697 });698699 it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {700 const vault = await import("./vault");701702 await vault.createVault("quota-pass-12345");703704 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;705 setMock.mockImplementation(async (next: Record<string, unknown>) => {706 if ("peach_vault_a" in next || "peach_vault_b" in next) {707 throw new Error("QUOTA_BYTES quota reached");708 }709 Object.assign(storage, next);710 });711712 await expect(713 vault.addEntry({714 title: "GitHub",715 username: "june",716 password: "secret123",717 url: "https://github.com",718 })719 ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");720 });721722 it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {723 const vault = await import("./vault");724725 await vault.createVault("settings-pass-12345");726727 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;728 setMock.mockImplementation(async (next: Record<string, unknown>) => {729 if ("peach_settings" in next) {730 throw new Error("QUOTA_BYTES quota reached");731 }732 Object.assign(storage, next);733 });734735 await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(736 "STORAGE_QUOTA_EXCEEDED"737 );738 });739740 it("card entry round-trips through vault — all fields preserved", async () => {741 const vault = await import("./vault");742743 await vault.createVault("card-test-pass-12345");744 const entry = await vault.addEntry({745 type: "card",746 title: "Personal Visa",747 cardholderName: "Jane Smith",748 cardNumber: "4111111111111111",749 expMonth: "05",750 expYear: "2028",751 cvc: "123",752 cardNickname: "Personal Visa",753 network: "visa",754 last4: "1111",755 });756757 expect(entry.type).toBe("card");758 expect(entry.cardholderName).toBe("Jane Smith");759 expect(entry.cardNumber).toBe("4111111111111111");760 expect(entry.expMonth).toBe("05");761 expect(entry.expYear).toBe("2028");762 expect(entry.cvc).toBe("123");763 expect(entry.network).toBe("visa");764 expect(entry.last4).toBe("1111");765766 vault.lock();767 await vault.unlock("card-test-pass-12345");768 const entries = vault.getEntries();769 expect(entries).toHaveLength(1);770 expect(entries[0].cardNumber).toBe("4111111111111111");771 expect(entries[0].cvc).toBe("123");772 });773774 it("updateEntry — cannot clear card number on card entry", async () => {775 const vault = await import("./vault");776777 await vault.createVault("card-guard-pass-12345");778 const entry = await vault.addEntry({779 type: "card",780 title: "My Card",781 cardholderName: "Jane Smith",782 cardNumber: "4111111111111111",783 expMonth: "05",784 expYear: "2028",785 cvc: "123",786 });787788 await expect(789 vault.updateEntry(entry.id, { cardNumber: "" })790 ).rejects.toThrow("card number is required for card entries");791792 const entries = vault.getEntries();793 expect(entries[0].cardNumber).toBe("4111111111111111");794 });795796 it("declineCardSaveOffer — stores fingerprint in settings", async () => {797 const vault = await import("./vault");798799 await vault.createVault("decline-pass-12345");800 await vault.declineCardSaveOffer("1111", "05", "2028");801802 const settings = vault.getSettings();803 expect(settings.declinedCardFingerprints).toHaveLength(1);804 });805806 it("isCardDeclined — returns true for previously declined card", async () => {807 const vault = await import("./vault");808809 await vault.createVault("declined-check-pass-12345");810 await vault.declineCardSaveOffer("1111", "05", "2028");811812 expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);813 });814815 it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {816 const vault = await import("./vault");817818 await vault.createVault("declined-diff-pass-12345");819 await vault.declineCardSaveOffer("1111", "05", "2028");820821 expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);822 });823824 it("queryDomain matches an entry against its additionalUrls", async () => {825 const vault = await import("./vault");826827 await vault.createVault("multi-domain-pass-12345");828 const entry = await vault.addEntry({829 type: "login",830 title: "Foo",831 username: "june",832 password: "secret",833 url: "https://foo.com",834 additionalUrls: ["https://bar.io", "https://baz.org"],835 });836837 838 839 expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);840 expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);841 expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);842 expect(vault.queryDomain("unrelated.com")).toEqual([]);843 });844845 it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {846 const vault = await import("./vault");847848 await vault.createVault("dedupe-additional-pass-12345");849 const entry = await vault.addEntry({850 type: "login",851 title: "Foo",852 username: "june",853 password: "secret",854 url: "https://foo.com",855 });856857 await vault.updateEntry(entry.id, {858 additionalUrls: [859 " https://login.foo.com ",860 "https://login.foo.com",861 "",862 " ",863 "https://accounts.foo.com",864 ],865 });866867 const stored = vault.getEntryById(entry.id);868 expect(stored?.additionalUrls).toEqual([869 "https://login.foo.com",870 "https://accounts.foo.com",871 ]);872873 await vault.updateEntry(entry.id, { additionalUrls: ["", " "] });874 expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();875 });876});