vault.test.ts

26.6 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        ["peach_vault_a", "peach_vault_b", "peach_settings", "peach_vault_history"]112      ).toContain(key);113    }114    expect(keys).toContain("peach_vault_a");115    expect(keys).toContain("peach_vault_b");116  });117118  it("unlocks with real password and loads real entries", async () => {119    const vault = await import("./vault");120    await vault.createVault("real-password-12345");121    await vault.addEntry({122      title: "GitHub",123      username: "june",124      password: "realsecret",125      url: "github.com",126    });127    vault.lock();128129    const ok = await vault.unlock("real-password-12345");130    expect(ok).toBe(true);131    expect(vault.getEntries()).toHaveLength(1);132    expect(vault.getEntries()[0].title).toBe("GitHub");133  });134135  it("unlocks with duress password and loads empty entries", async () => {136    const vault = await import("./vault");137    await vault.createVault("real-password-12345");138    await vault.addEntry({139      title: "GitHub",140      username: "june",141      password: "realsecret",142      url: "github.com",143    });144    await vault.setupDuress("decoy-password-67890");145    vault.lock();146147    const ok = await vault.unlock("decoy-password-67890");148    expect(ok).toBe(true);149    expect(vault.getEntries()).toEqual([]);150  });151152  it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {153    // The unlock path must derive keys for both slots unconditionally so a154    // real-mode unlock and a decoy-mode unlock both spend roughly the same155    // wall-clock time. Verified here by spying on deriveKey and asserting it156    // is called twice (once per slot) on every unlock attempt.157    const argon2 = await import("../crypto/argon2");158    const deriveSpy = vi.spyOn(argon2, "deriveKey");159160    const vault = await import("./vault");161    await vault.createVault("real-password-12345");162    await vault.setupDuress("decoy-password-67890");163    vault.lock();164165    deriveSpy.mockClear();166    await vault.unlock("real-password-12345");167    expect(deriveSpy).toHaveBeenCalledTimes(2);168    vault.lock();169170    deriveSpy.mockClear();171    await vault.unlock("decoy-password-67890");172    expect(deriveSpy).toHaveBeenCalledTimes(2);173    vault.lock();174175    deriveSpy.mockClear();176    await vault.unlock("wrong-password-xxxxx");177    expect(deriveSpy).toHaveBeenCalledTimes(2);178179    deriveSpy.mockRestore();180  });181182  it("rejects wrong passwords", async () => {183    const vault = await import("./vault");184    await vault.createVault("real-password-12345");185    vault.lock();186187    const ok = await vault.unlock("nope-nope-nope");188    expect(ok).toBe(false);189  });190191  it("persists duressConfigured across lock/unlock in encrypted payload", async () => {192    const vault = await import("./vault");193    await vault.createVault("real-password-12345");194    await vault.setupDuress("decoy-password-67890");195    vault.lock();196197    await vault.unlock("real-password-12345");198    expect(await vault.isDuressEnabled()).toBe(true);199200    await vault.disableDuress();201    expect(await vault.isDuressEnabled()).toBe(false);202    vault.lock();203204    await vault.unlock("real-password-12345");205    expect(await vault.isDuressEnabled()).toBe(false);206  });207208  it("migrates legacy peach_vault / peach_decoy_vault / peach_duress schema", async () => {209    // Arrange: set up storage as if from pre-A-2 (v1.4.24 and earlier).210    const firstVault = await import("./vault");211    await firstVault.createVault("legacy-password-abcde");212    const legacySlotA = storage.peach_vault_a as StoredVault;213    storage.peach_vault = legacySlotA;214    delete storage.peach_vault_a;215    delete storage.peach_vault_b;216    storage.peach_duress = { enabled: true };217    storage.peach_decoy_vault = legacySlotA; // reuse shape for test218    vi.resetModules();219220    // Act: fresh module load triggers migration on next vault access.221    const vault = await import("./vault");222    await vault.hasVault();223224    // Assert: legacy keys removed, new keys present.225    expect(storage.peach_vault).toBeUndefined();226    expect(storage.peach_decoy_vault).toBeUndefined();227    expect(storage.peach_duress).toBeUndefined();228    expect(storage.peach_vault_a).toBeDefined();229    expect(storage.peach_vault_b).toBeDefined();230  });231232  it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {233    const vault = await import("./vault");234235    await vault.createVault("correct horse battery staple");236    const entry = await vault.addEntry({237      title: "GitHub",238      username: "june",239      password: "secret123",240      url: "github.com",241    });242243    await vault.deleteEntry(entry.id);244245    expect(vault.getEntries()).toEqual([]);246247    const stored = storage.peach_vault_a as StoredVault;248    const salt = Uint8Array.from(249      stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))250    );251    const key = await deriveKey("correct horse battery staple", salt);252    const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag);253    const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };254255    expect(parsed.entries).toHaveLength(1);256    expect(parsed.entries[0].id).toBe(entry.id);257    expect(typeof parsed.entries[0].deletedAt).toBe("number");258  });259260  it("accepts imported entries without a username or url", async () => {261    const vault = await import("./vault");262263    await vault.createVault("correct horse battery staple");264    const entry = await vault.addEntry({265      title: "Imported Login",266      username: "",267      password: "secret123",268      url: "",269      notes: "migrated from another manager",270    });271272    expect(entry.username).toBe("");273    expect(entry.url).toBe("");274    expect(vault.getEntries()).toHaveLength(1);275  });276277  it("changePassword happy path — new password unlocks, old is rejected", async () => {278    const vault = await import("./vault");279280    await vault.createVault("old-pass-abc123");281    await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });282    await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });283284    expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);285286    vault.lock();287    expect(await vault.unlock("new-pass-xyz789")).toBe(true);288    expect(vault.getEntries()).toHaveLength(2);289290    vault.lock();291    expect(await vault.unlock("old-pass-abc123")).toBe(false);292  });293294  it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {295    const vault = await import("./vault");296297    await vault.createVault("correct-pass-abcde");298    const slotABefore = JSON.stringify(storage.peach_vault_a);299300    expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);301    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);302303    vault.lock();304    expect(await vault.unlock("correct-pass-abcde")).toBe(true);305  });306307  it("changePassword with duress configured leaves slot B byte-identical", async () => {308    const vault = await import("./vault");309310    await vault.createVault("real-pass-abcde");311    await vault.setupDuress("duress-pass-xyzzz");312313    const slotBBefore = JSON.stringify(storage.peach_vault_b);314    expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);315    expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);316317    // Duress path still works with the old decoy password.318    vault.lock();319    expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);320    expect(vault.getEntries()).toEqual([]);321  });322323  it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {324    const vault = await import("./vault");325326    await vault.createVault("pass-abcdef");327    await vault.addEntry({ title: "One", username: "", password: "1", url: "" });328    await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });329    await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });330331    await expect(vault.restoreEntries([], "replace")).rejects.toThrow(332      /refuse to replace with empty entry set/i333    );334    expect(vault.getEntries()).toHaveLength(3);335  });336337  it("changePassword swaps the active key before Slot B is regenerated", async () => {338    const vault = await import("./vault");339340    await vault.createVault("correct-pass-12345");341    const entry = await vault.addEntry({342      title: "GitHub",343      username: "june",344      password: "secret123",345      url: "https://github.com",346    });347348    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;349    const originalSet = setMock.getMockImplementation();350    let injectedDuringSlotBWrite = false;351352    setMock.mockImplementation(async (next: Record<string, unknown>) => {353      Object.assign(storage, next);354355      if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {356        injectedDuringSlotBWrite = true;357        await vault.updateEntry(entry.id, { notes: "updated during password change" });358      }359    });360361    expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);362363    setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {364      Object.assign(storage, next);365    }));366367    vault.lock();368    expect(await vault.unlock("new-pass-67890")).toBe(true);369    expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");370371    vault.lock();372    expect(await vault.unlock("correct-pass-12345")).toBe(false);373  });374375  it("restoreEntries replace mode replaces existing entries", async () => {376    const vault = await import("./vault");377378    await vault.createVault("replace-pass-12345");379    await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });380    await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });381382    const incoming = [383      {384        id: "entry-c",385        type: "login" as const,386        title: "C",387        username: "june",388        password: "three",389        url: "https://c.test",390        createdAt: 10,391        modifiedAt: 10,392      },393      {394        id: "entry-d",395        type: "login" as const,396        title: "D",397        username: "june",398        password: "four",399        url: "https://d.test",400        createdAt: 20,401        modifiedAt: 20,402      },403    ];404405    await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({406      added: 2,407      updated: 0,408      skipped: 0,409    });410411    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);412  });413414  it("restoreEntries merge mode lets newer incoming entries win", async () => {415    const vault = await import("./vault");416417    await vault.createVault("merge-pass-12345");418    const existing = await vault.addEntry({419      title: "GitHub",420      username: "june",421      password: "old-secret",422      url: "https://github.com",423    });424425    await expect(426      vault.restoreEntries(427        [428          {429            ...existing,430            password: "new-secret",431            modifiedAt: existing.modifiedAt + 100,432          },433        ],434        "merge"435      )436    ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });437438    expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");439  });440441  it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {442    const vault = await import("./vault");443444    await vault.createVault("merge-pass-67890");445    const existing = await vault.addEntry({446      title: "GitHub",447      username: "june",448      password: "current-secret",449      url: "https://github.com",450    });451452    await expect(453      vault.restoreEntries(454        [455          {456            ...existing,457            password: "stale-secret",458            modifiedAt: existing.modifiedAt - 100,459          },460        ],461        "merge"462      )463    ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });464465    expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");466  });467468  it("clearVaultEntries without explicit confirmation throws", async () => {469    const vault = await import("./vault");470471    await vault.createVault("clear-pass-12345");472    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });473474    const clearVaultEntries = vault.clearVaultEntries as unknown as (475      confirmation?: unknown476    ) => Promise<void>;477478    await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);479    await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);480    expect(vault.getEntries()).toHaveLength(1);481  });482483  it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {484    const vault = await import("./vault");485486    await vault.createVault("clear-pass-67890");487    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });488    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });489    await vault.addEntry({ title: "C", username: "", password: "three", url: "" });490491    await vault.clearVaultEntries("REPLACE_ALL");492493    expect(vault.getEntries()).toEqual([]);494    expect(vault.getDeletedEntries()).toHaveLength(3);495    expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);496  });497498  it("deduplicateEntries throws in decoy mode", async () => {499    const vault = await import("./vault");500501    await vault.createVault("real-pass-12345");502    await vault.addEntry({503      title: "GitHub",504      username: "june",505      password: "secret123",506      url: "https://github.com",507    });508    await vault.setupDuress("duress-pass-12345");509    vault.lock();510511    expect(await vault.unlock("duress-pass-12345")).toBe(true);512    await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);513  });514515  it("deduplicateEntries removes older duplicates in real mode", async () => {516    const vault = await import("./vault");517518    await vault.createVault("dedupe-pass-12345");519    await vault.addEntry({520      title: "GitHub",521      username: "june",522      password: "old-secret",523      url: "https://github.com",524    });525    await new Promise<void>((resolve) => setTimeout(resolve, 5));526    const newer = await vault.addEntry({527      title: "GitHub",528      username: "june",529      password: "new-secret",530      url: "https://github.com",531    });532533    expect(await vault.deduplicateEntries()).toBe(1);534    expect(vault.getEntries()).toHaveLength(1);535    expect(vault.getEntries()[0].id).toBe(newer.id);536    expect(vault.getDeletedEntries()).toHaveLength(1);537  });538539  it("updateEntry with an empty password on a login entry throws and rolls back", async () => {540    const vault = await import("./vault");541542    await vault.createVault("update-pass-12345");543    const entry = await vault.addEntry({544      title: "GitHub",545      username: "june",546      password: "secret123",547      url: "https://github.com",548    });549550    await expect(vault.updateEntry(entry.id, { password: "" })).rejects.toThrow(551      /password or passkey required/i552    );553    expect(vault.getEntryById(entry.id)?.password).toBe("secret123");554  });555556  it("updateEntry with an empty password on a note entry is allowed", async () => {557    const vault = await import("./vault");558559    await vault.createVault("note-pass-12345");560    const entry = await vault.addEntry({561      type: "note",562      title: "Secure note",563      notes: "keep me",564    });565566    await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();567    expect(vault.getEntryById(entry.id)).toMatchObject({568      type: "note",569      notes: "updated",570    });571  });572573  it("lock zeroes the derived key and resets unlock state", async () => {574    const aes = await import("./aes");575    const zeroKeySpy = vi.spyOn(aes, "zeroKey");576    const vault = await import("./vault");577578    await vault.createVault("lock-pass-12345");579    expect(vault.isUnlocked()).toBe(true);580581    vault.lock();582583    expect(zeroKeySpy).toHaveBeenCalled();584    expect(vault.isUnlocked()).toBe(false);585    zeroKeySpy.mockRestore();586  });587588  it("lock clears in-memory entries", async () => {589    const vault = await import("./vault");590591    await vault.createVault("lock-pass-67890");592    const entry = await vault.addEntry({593      title: "GitHub",594      username: "june",595      password: "secret123",596      url: "https://github.com",597    });598599    expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");600    vault.lock();601    expect(vault.getEntryById(entry.id)).toBeUndefined();602  });603604  it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {605    const vault = await import("./vault");606607    await vault.createVault("real-pass-67890");608    vault.lock();609610    expect(await vault.unlock("totally-wrong-password")).toBe(false);611    expect(vault.isUnlocked()).toBe(false);612  });613614  it("isUnlocked tracks the full lock lifecycle", async () => {615    const vault = await import("./vault");616617    expect(vault.isUnlocked()).toBe(false);618    await vault.createVault("unlock-pass-12345");619    expect(vault.isUnlocked()).toBe(true);620    vault.lock();621    expect(vault.isUnlocked()).toBe(false);622    expect(await vault.unlock("unlock-pass-12345")).toBe(true);623    expect(vault.isUnlocked()).toBe(true);624    vault.lock();625    expect(vault.isUnlocked()).toBe(false);626  });627628  it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {629    const vault = await import("./vault");630631    await vault.createVault("quota-pass-12345");632633    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;634    setMock.mockImplementation(async (next: Record<string, unknown>) => {635      if ("peach_vault_a" in next || "peach_vault_b" in next) {636        throw new Error("QUOTA_BYTES quota reached");637      }638      Object.assign(storage, next);639    });640641    await expect(642      vault.addEntry({643        title: "GitHub",644        username: "june",645        password: "secret123",646        url: "https://github.com",647      })648    ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");649  });650651  it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {652    const vault = await import("./vault");653654    await vault.createVault("settings-pass-12345");655656    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;657    setMock.mockImplementation(async (next: Record<string, unknown>) => {658      if ("peach_settings" in next) {659        throw new Error("QUOTA_BYTES quota reached");660      }661      Object.assign(storage, next);662    });663664    await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(665      "STORAGE_QUOTA_EXCEEDED"666    );667  });668669  it("card entry round-trips through vault — all fields preserved", async () => {670    const vault = await import("./vault");671672    await vault.createVault("card-test-pass-12345");673    const entry = await vault.addEntry({674      type: "card",675      title: "Personal Visa",676      cardholderName: "Jane Smith",677      cardNumber: "4111111111111111",678      expMonth: "05",679      expYear: "2028",680      cvc: "123",681      cardNickname: "Personal Visa",682      network: "visa",683      last4: "1111",684    });685686    expect(entry.type).toBe("card");687    expect(entry.cardholderName).toBe("Jane Smith");688    expect(entry.cardNumber).toBe("4111111111111111");689    expect(entry.expMonth).toBe("05");690    expect(entry.expYear).toBe("2028");691    expect(entry.cvc).toBe("123");692    expect(entry.network).toBe("visa");693    expect(entry.last4).toBe("1111");694695    vault.lock();696    await vault.unlock("card-test-pass-12345");697    const entries = vault.getEntries();698    expect(entries).toHaveLength(1);699    expect(entries[0].cardNumber).toBe("4111111111111111");700    expect(entries[0].cvc).toBe("123");701  });702703  it("updateEntry — cannot clear card number on card entry", async () => {704    const vault = await import("./vault");705706    await vault.createVault("card-guard-pass-12345");707    const entry = await vault.addEntry({708      type: "card",709      title: "My Card",710      cardholderName: "Jane Smith",711      cardNumber: "4111111111111111",712      expMonth: "05",713      expYear: "2028",714      cvc: "123",715    });716717    await expect(718      vault.updateEntry(entry.id, { cardNumber: "" })719    ).rejects.toThrow("card number is required for card entries");720721    const entries = vault.getEntries();722    expect(entries[0].cardNumber).toBe("4111111111111111");723  });724725  it("declineCardSaveOffer — stores fingerprint in settings", async () => {726    const vault = await import("./vault");727728    await vault.createVault("decline-pass-12345");729    await vault.declineCardSaveOffer("1111", "05", "2028");730731    const settings = vault.getSettings();732    expect(settings.declinedCardFingerprints).toHaveLength(1);733  });734735  it("isCardDeclined — returns true for previously declined card", async () => {736    const vault = await import("./vault");737738    await vault.createVault("declined-check-pass-12345");739    await vault.declineCardSaveOffer("1111", "05", "2028");740741    expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);742  });743744  it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {745    const vault = await import("./vault");746747    await vault.createVault("declined-diff-pass-12345");748    await vault.declineCardSaveOffer("1111", "05", "2028");749750    expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);751  });752753  it("queryDomain matches an entry against its additionalUrls", async () => {754    const vault = await import("./vault");755756    await vault.createVault("multi-domain-pass-12345");757    const entry = await vault.addEntry({758      type: "login",759      title: "Foo",760      username: "june",761      password: "secret",762      url: "https://foo.com",763      additionalUrls: ["https://bar.io", "https://baz.org"],764    });765766    // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only767    // add new buckets when the eTLD+1 is genuinely different from the primary.768    expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);769    expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);770    expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);771    expect(vault.queryDomain("unrelated.com")).toEqual([]);772  });773774  it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {775    const vault = await import("./vault");776777    await vault.createVault("dedupe-additional-pass-12345");778    const entry = await vault.addEntry({779      type: "login",780      title: "Foo",781      username: "june",782      password: "secret",783      url: "https://foo.com",784    });785786    await vault.updateEntry(entry.id, {787      additionalUrls: [788        "  https://login.foo.com  ",789        "https://login.foo.com",790        "",791        "  ",792        "https://accounts.foo.com",793      ],794    });795796    const stored = vault.getEntryById(entry.id);797    expect(stored?.additionalUrls).toEqual([798      "https://login.foo.com",799      "https://accounts.foo.com",800    ]);801802    await vault.updateEntry(entry.id, { additionalUrls: ["", "   "] });803    expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();804  });805});