vault.test.ts

29.2 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    // Slot-neutral keys + optional settings + vault history (snapshot of the108    // pre-setupDuress real-mode blob). No exposed duress flag.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    // The unlock path must derive keys for both slots unconditionally so a160    // real-mode unlock and a decoy-mode unlock both spend roughly the same161    // wall-clock time. Verified here by spying on deriveKey and asserting it162    // is called twice (once per slot) on every unlock attempt.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    // Arrange: set up storage as if from pre-A-2 (v1.4.24 and earlier).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; // reuse shape for test224    vi.resetModules();225226    // Act: fresh module load triggers migration on next vault access.227    const vault = await import("./vault");228    await vault.hasVault();229230    // Assert: legacy keys removed, new keys present.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    // Duress path still works with the old decoy password.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    // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only838    // add new buckets when the eTLD+1 is genuinely different from the primary.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});