import { beforeEach, describe, expect, it, vi } from "vitest"; import { decrypt } from "./aes"; import { deriveKey } from "../crypto/argon2"; import type { StoredVault } from "../shared/types"; type StorageShape = Record; function deferred(): { promise: Promise; resolve: () => void } { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } describe("background vault", () => { let storage: StorageShape; let sessionStorage: StorageShape; let alarms = { created: [] as Array<{ name: string; delayInMinutes: number }>, cleared: [] as string[] }; beforeEach(() => { vi.resetModules(); storage = {}; sessionStorage = {}; alarms = { created: [], cleared: [] }; const chromeMock = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") { return { [key]: storage[key] }; } return key.reduce>((result, entry) => { result[entry] = storage[entry]; return result; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) { delete storage[key]; } }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return sessionStorage; if (typeof key === "string") return { [key]: sessionStorage[key] }; return key.reduce>((result, entry) => { result[entry] = sessionStorage[entry]; return result; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete sessionStorage[key]; }), clear: vi.fn(async () => { for (const key of Object.keys(sessionStorage)) delete sessionStorage[key]; }), }, }, alarms: { clear: vi.fn((name: string) => { alarms.cleared.push(name); }), create: vi.fn((name: string, info: { delayInMinutes: number }) => { alarms.created.push({ name, delayInMinutes: info.delayInMinutes }); }), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; // Prevent fire-and-forget breach-check fetches from leaking into other tests. vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled in tests"); })); }); it("creates a vault and stores encrypted payload", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); expect(storage.peach_vault_a).toBeDefined(); expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy(); expect(vault.getEntries()).toEqual([]); expect(alarms.created).toContainEqual({ name: "autolock", delayInMinutes: 5, }); }); it("writes both slots A and B on vault creation", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); expect(storage.peach_vault_a).toBeDefined(); expect(storage.peach_vault_b).toBeDefined(); expect(storage.peach_duress).toBeUndefined(); expect(storage.peach_vault).toBeUndefined(); expect(storage.peach_decoy_vault).toBeUndefined(); }); it("keeps the PIN wrapper in session storage and invalidates it on password change", async () => { const vault = await import("./vault"); const password = "correct horse battery staple"; await vault.createVault(password); expect(await vault.setupPinUnlock(password, "123")).toBe(false); expect(await vault.setupPinUnlock(password, "1234567890123")).toBe(false); expect(await vault.setupPinUnlock(password, "1234")).toBe(true); expect((sessionStorage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy(); expect(storage.peach_pin_wrap).toBeUndefined(); vault.lock(); expect(await vault.unlockWithPin("000000")).toBe(false); expect(await vault.unlockWithPin("1234")).toBe(true); expect(await vault.changePassword(password, "new correct horse battery")) .toBe(true); vault.lock(); expect(await vault.unlockWithPin("1234")).toBe(false); expect(await vault.unlock("new correct horse battery")).toBe(true); }); it("requires the master password after three incorrect PIN attempts", async () => { const vault = await import("./vault"); const password = "correct horse battery staple"; await vault.createVault(password); expect(await vault.setupPinUnlock(password, "1234")).toBe(true); vault.lock(); expect(await vault.unlockWithPin("0000")).toBe(false); expect(await vault.unlockWithPin("0000")).toBe(false); expect(await vault.unlockWithPin("0000")).toBe(false); expect(await vault.unlockWithPin("1234")).toBe(false); expect(await vault.unlock(password)).toBe(true); vault.lock(); expect(await vault.unlockWithPin("1234")).toBe(true); }); it("cannot unlock from a copied local-storage snapshot after session storage is cleared", async () => { const vault = await import("./vault"); const password = "correct horse battery staple"; await vault.createVault(password); expect(await vault.setupPinUnlock(password, "1234")).toBe(true); const copiedLocalStorage = { ...storage }; await (chrome.storage.session as typeof chrome.storage.session).clear(); vault.lock(); expect(copiedLocalStorage.peach_pin_wrap).toBeUndefined(); await expect(vault.unlockWithPin("1234")).resolves.toBe(false); await expect(vault.getPinUnlockStatus()).resolves.toMatchObject({ configured: true, available: false, attemptsRemaining: 0, }); }); it("slot A and slot B have matching ciphertext size at fresh install", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const a = storage.peach_vault_a as StoredVault; const b = storage.peach_vault_b as StoredVault; expect(a.ciphertext.length).toBe(b.ciphertext.length); }); it("does not expose a plaintext duress flag after setupDuress", async () => { const vault = await import("./vault"); await vault.createVault("real-password-12345"); await vault.setupDuress("decoy-password-67890"); expect(storage.peach_duress).toBeUndefined(); expect(storage.peach_decoy_vault).toBeUndefined(); const keys = Object.keys(storage).filter((k) => k.startsWith("peach_")); // Slot-neutral keys + optional settings + vault history (snapshot of the // pre-setupDuress real-mode blob). No exposed duress flag. for (const key of keys) { expect( [ "peach_vault_a", "peach_vault_b", "peach_settings", "peach_vault_history", "peach_last_modified_at", ] ).toContain(key); } expect(keys).toContain("peach_vault_a"); expect(keys).toContain("peach_vault_b"); }); it("unlocks with real password and loads real entries", async () => { const vault = await import("./vault"); await vault.createVault("real-password-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "realsecret", url: "github.com", }); vault.lock(); const ok = await vault.unlock("real-password-12345"); expect(ok).toBe(true); expect(vault.getEntries()).toHaveLength(1); expect(vault.getEntries()[0].title).toBe("GitHub"); }); it("unlocks with duress password and loads empty entries", async () => { const vault = await import("./vault"); await vault.createVault("real-password-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "realsecret", url: "github.com", }); await vault.setupDuress("decoy-password-67890"); vault.lock(); const ok = await vault.unlock("decoy-password-67890"); expect(ok).toBe(true); expect(vault.getEntries()).toEqual([]); }); it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => { const vault = await import("./vault"); await vault.createVault("real-password-12345"); await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" }); await vault.setupDuress("decoy-password-67890"); // Baseline: the real vault does have a sync identity. expect(vault.getRootKeyHex()).toBeTruthy(); vault.lock(); expect(await vault.unlock("decoy-password-67890")).toBe(true); // A second vault_id would be a duress indicator: a coerced user opening the // decoy would make sync visibly fail on their other devices. The decoy path // never derives a root key, so there is nothing to derive one from. expect(vault.getRootKeyHex()).toBeNull(); // Every sync adapter operation therefore fails closed rather than syncing, // manifesting, or revealing an entry count. const { createSyncVaultAdapter } = await import("./sync-vault-adapter"); const adapter = createSyncVaultAdapter(); await expect(adapter.computeVaultId()).rejects.toThrow(); await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow(); vault.lock(); }); it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => { // The unlock path must derive keys for both slots unconditionally so a // real-mode unlock and a decoy-mode unlock both spend roughly the same // wall-clock time. Verified here by spying on deriveKey and asserting it // is called twice (once per slot) on every unlock attempt. const argon2 = await import("../crypto/argon2"); const deriveSpy = vi.spyOn(argon2, "deriveKey"); const vault = await import("./vault"); await vault.createVault("real-password-12345"); await vault.setupDuress("decoy-password-67890"); vault.lock(); deriveSpy.mockClear(); await vault.unlock("real-password-12345"); expect(deriveSpy).toHaveBeenCalledTimes(2); vault.lock(); deriveSpy.mockClear(); await vault.unlock("decoy-password-67890"); expect(deriveSpy).toHaveBeenCalledTimes(2); vault.lock(); deriveSpy.mockClear(); await vault.unlock("wrong-password-xxxxx"); expect(deriveSpy).toHaveBeenCalledTimes(2); deriveSpy.mockRestore(); }); it("rejects wrong passwords", async () => { const vault = await import("./vault"); await vault.createVault("real-password-12345"); vault.lock(); const ok = await vault.unlock("nope-nope-nope"); expect(ok).toBe(false); }); it("persists duressConfigured across lock/unlock in encrypted payload", async () => { const vault = await import("./vault"); await vault.createVault("real-password-12345"); await vault.setupDuress("decoy-password-67890"); vault.lock(); await vault.unlock("real-password-12345"); expect(await vault.isDuressEnabled()).toBe(true); await vault.disableDuress(); expect(await vault.isDuressEnabled()).toBe(false); vault.lock(); await vault.unlock("real-password-12345"); expect(await vault.isDuressEnabled()).toBe(false); }); it("soft deletes entries while keeping deletedAt in encrypted storage", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "github.com", }); await vault.deleteEntry(entry.id); expect(vault.getEntries()).toEqual([]); const stored = storage.peach_vault_a as StoredVault; const salt = Uint8Array.from( stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16)) ); const key = await deriveKey("correct horse battery staple", salt); const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1"); const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> }; expect(parsed.entries).toHaveLength(1); expect(parsed.entries[0].id).toBe(entry.id); expect(typeof parsed.entries[0].deletedAt).toBe("number"); }); it("accepts imported entries without a username or url", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const entry = await vault.addEntry({ title: "Imported Login", username: "", password: "secret123", url: "", notes: "migrated from another manager", }); expect(entry.username).toBe(""); expect(entry.url).toBe(""); expect(vault.getEntries()).toHaveLength(1); }); it("changePassword happy path — new password unlocks, old is rejected", async () => { const vault = await import("./vault"); await vault.createVault("old-pass-abc123"); await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" }); await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" }); expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true); vault.lock(); expect(await vault.unlock("new-pass-xyz789")).toBe(true); expect(vault.getEntries()).toHaveLength(2); vault.lock(); expect(await vault.unlock("old-pass-abc123")).toBe(false); }); it("stamps peach_last_modified_at on createVault", async () => { const vault = await import("./vault"); const before = Date.now(); await new Promise((resolve) => setTimeout(resolve, 1)); await vault.createVault("stamp-create-pass"); expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before); }); it("rejects a slot A persist that races with lock", async () => { const vault = await import("./vault"); await vault.createVault("persist-epoch-pass"); const slotBefore = JSON.stringify(storage.peach_vault_a); const readStarted = deferred(); const releaseRead = deferred(); const localGet = chrome.storage.local.get as unknown as ReturnType; localGet.mockImplementation(async (key?: string | string[]) => { if (key === "peach_vault_a") { readStarted.resolve(); await releaseRead.promise; } if (!key) return storage; const keys = Array.isArray(key) ? key : [key]; return keys.reduce((result, entry) => { result[entry] = storage[entry]; return result; }, {}); }); const saving = vault.addEntry({ title: "stale", username: "", password: "pw", url: "" }); await readStarted.promise; await vault.lock(); releaseRead.resolve(); await expect(saving).rejects.toThrow("vault_locked"); expect(JSON.stringify(storage.peach_vault_a)).toBe(slotBefore); expect(vault.isUnlocked()).toBe(false); }); it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => { const vault = await import("./vault"); await vault.createVault("stamp-entry-pass"); let previous = Number(storage.peach_last_modified_at); await new Promise((resolve) => setTimeout(resolve, 1)); const entry = await vault.addEntry({ title: "Stamp", username: "", password: "p", url: "", }); expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous); previous = Number(storage.peach_last_modified_at); await new Promise((resolve) => setTimeout(resolve, 1)); await vault.updateEntry(entry.id, { title: "Stamped" }); expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous); previous = Number(storage.peach_last_modified_at); await new Promise((resolve) => setTimeout(resolve, 1)); await vault.deleteEntry(entry.id); expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous); }); it("stamps peach_last_modified_at on setupDuress and changePassword", async () => { const vault = await import("./vault"); await vault.createVault("stamp-real-pass"); let previous = Number(storage.peach_last_modified_at); await new Promise((resolve) => setTimeout(resolve, 1)); await vault.setupDuress("stamp-decoy-pass"); expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous); previous = Number(storage.peach_last_modified_at); await new Promise((resolve) => setTimeout(resolve, 1)); expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true); expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous); }); it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-abcde"); const slotABefore = JSON.stringify(storage.peach_vault_a); expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false); expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore); vault.lock(); expect(await vault.unlock("correct-pass-abcde")).toBe(true); }); it("changePassword with duress configured leaves slot B byte-identical", async () => { const vault = await import("./vault"); await vault.createVault("real-pass-abcde"); await vault.setupDuress("duress-pass-xyzzz"); const slotBBefore = JSON.stringify(storage.peach_vault_b); expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true); expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore); // Duress path still works with the old decoy password. vault.lock(); expect(await vault.unlock("duress-pass-xyzzz")).toBe(true); expect(vault.getEntries()).toEqual([]); }); it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => { const vault = await import("./vault"); await vault.createVault("pass-abcdef"); await vault.addEntry({ title: "One", username: "", password: "1", url: "" }); await vault.addEntry({ title: "Two", username: "", password: "2", url: "" }); await vault.addEntry({ title: "Three", username: "", password: "3", url: "" }); await expect(vault.restoreEntries([], "replace")).rejects.toThrow( /refuse to replace with empty entry set/i ); expect(vault.getEntries()).toHaveLength(3); }); it("changePassword swaps the active key before Slot B is regenerated", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); const setMock = chrome.storage.local.set as unknown as ReturnType; const originalSet = setMock.getMockImplementation(); let injectedDuringSlotBWrite = false; setMock.mockImplementation(async (next: Record) => { Object.assign(storage, next); if (!injectedDuringSlotBWrite && "peach_vault_b" in next) { injectedDuringSlotBWrite = true; await vault.updateEntry(entry.id, { notes: "updated during password change" }); } }); expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true); setMock.mockImplementation(originalSet ?? (async (next: Record) => { Object.assign(storage, next); })); vault.lock(); expect(await vault.unlock("new-pass-67890")).toBe(true); expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change"); vault.lock(); expect(await vault.unlock("correct-pass-12345")).toBe(false); }); it("restoreEntries replace mode replaces existing entries", async () => { const vault = await import("./vault"); await vault.createVault("replace-pass-12345"); await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" }); await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" }); const incoming = [ { id: "entry-c", type: "login" as const, title: "C", username: "june", password: "three", url: "https://c.test", createdAt: 10, modifiedAt: 10, }, { id: "entry-d", type: "login" as const, title: "D", username: "june", password: "four", url: "https://d.test", createdAt: 20, modifiedAt: 20, }, ]; await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({ added: 2, updated: 0, skipped: 0, }); expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]); }); it("restoreEntries merge mode lets newer incoming entries win", async () => { const vault = await import("./vault"); await vault.createVault("merge-pass-12345"); const existing = await vault.addEntry({ title: "GitHub", username: "june", password: "old-secret", url: "https://github.com", }); await expect( vault.restoreEntries( [ { ...existing, password: "new-secret", modifiedAt: existing.modifiedAt + 100, }, ], "merge" ) ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 }); expect(vault.getEntryById(existing.id)?.password).toBe("new-secret"); }); it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => { const vault = await import("./vault"); await vault.createVault("merge-pass-67890"); const existing = await vault.addEntry({ title: "GitHub", username: "june", password: "current-secret", url: "https://github.com", }); await expect( vault.restoreEntries( [ { ...existing, password: "stale-secret", modifiedAt: existing.modifiedAt - 100, }, ], "merge" ) ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 }); expect(vault.getEntryById(existing.id)?.password).toBe("current-secret"); }); it("clearVaultEntries without explicit confirmation throws", async () => { const vault = await import("./vault"); await vault.createVault("clear-pass-12345"); await vault.addEntry({ title: "A", username: "", password: "one", url: "" }); const clearVaultEntries = vault.clearVaultEntries as unknown as ( confirmation?: unknown ) => Promise; await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i); await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i); expect(vault.getEntries()).toHaveLength(1); }); it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => { const vault = await import("./vault"); await vault.createVault("clear-pass-67890"); await vault.addEntry({ title: "A", username: "", password: "one", url: "" }); await vault.addEntry({ title: "B", username: "", password: "two", url: "" }); await vault.addEntry({ title: "C", username: "", password: "three", url: "" }); await vault.clearVaultEntries("REPLACE_ALL"); expect(vault.getEntries()).toEqual([]); expect(vault.getDeletedEntries()).toHaveLength(3); expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true); }); it("deduplicateEntries throws in decoy mode", async () => { const vault = await import("./vault"); await vault.createVault("real-pass-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); await vault.setupDuress("duress-pass-12345"); vault.lock(); expect(await vault.unlock("duress-pass-12345")).toBe(true); await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i); }); it("deduplicateEntries removes older duplicates in real mode", async () => { const vault = await import("./vault"); await vault.createVault("dedupe-pass-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "old-secret", url: "https://github.com", }); await new Promise((resolve) => setTimeout(resolve, 5)); const newer = await vault.addEntry({ title: "GitHub", username: "june", password: "new-secret", url: "https://github.com", }); expect(await vault.deduplicateEntries()).toBe(1); expect(vault.getEntries()).toHaveLength(1); expect(vault.getEntries()[0].id).toBe(newer.id); expect(vault.getDeletedEntries()).toHaveLength(1); }); it("updateEntry with an empty password on a login entry clears the password", async () => { const vault = await import("./vault"); await vault.createVault("update-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined(); expect(vault.getEntryById(entry.id)?.password).toBeUndefined(); }); it("updateEntry with an empty password on a note entry is allowed", async () => { const vault = await import("./vault"); await vault.createVault("note-pass-12345"); const entry = await vault.addEntry({ type: "note", title: "Secure note", notes: "keep me", }); await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined(); expect(vault.getEntryById(entry.id)).toMatchObject({ type: "note", notes: "updated", }); }); it("lock zeroes the derived key and resets unlock state", async () => { const aes = await import("./aes"); const zeroKeySpy = vi.spyOn(aes, "zeroKey"); const vault = await import("./vault"); await vault.createVault("lock-pass-12345"); expect(vault.isUnlocked()).toBe(true); vault.lock(); expect(zeroKeySpy).toHaveBeenCalled(); expect(vault.isUnlocked()).toBe(false); zeroKeySpy.mockRestore(); }); it("lock clears in-memory entries", async () => { const vault = await import("./vault"); await vault.createVault("lock-pass-67890"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); expect(vault.getEntryById(entry.id)?.title).toBe("GitHub"); vault.lock(); expect(vault.getEntryById(entry.id)).toBeUndefined(); }); it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => { const vault = await import("./vault"); await vault.createVault("real-pass-67890"); vault.lock(); expect(await vault.unlock("totally-wrong-password")).toBe(false); expect(vault.isUnlocked()).toBe(false); }); it("isUnlocked tracks the full lock lifecycle", async () => { const vault = await import("./vault"); expect(vault.isUnlocked()).toBe(false); await vault.createVault("unlock-pass-12345"); expect(vault.isUnlocked()).toBe(true); vault.lock(); expect(vault.isUnlocked()).toBe(false); expect(await vault.unlock("unlock-pass-12345")).toBe(true); expect(vault.isUnlocked()).toBe(true); vault.lock(); expect(vault.isUnlocked()).toBe(false); }); it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => { const vault = await import("./vault"); await vault.createVault("quota-pass-12345"); const setMock = chrome.storage.local.set as unknown as ReturnType; setMock.mockImplementation(async (next: Record) => { if ("peach_vault_a" in next || "peach_vault_b" in next) { throw new Error("QUOTA_BYTES quota reached"); } Object.assign(storage, next); }); await expect( vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }) ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED"); }); it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => { const vault = await import("./vault"); await vault.createVault("settings-pass-12345"); const setMock = chrome.storage.local.set as unknown as ReturnType; setMock.mockImplementation(async (next: Record) => { if ("peach_settings" in next) { throw new Error("QUOTA_BYTES quota reached"); } Object.assign(storage, next); }); await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow( "STORAGE_QUOTA_EXCEEDED" ); }); it("card entry round-trips through vault — all fields preserved", async () => { const vault = await import("./vault"); await vault.createVault("card-test-pass-12345"); const entry = await vault.addEntry({ type: "card", title: "Personal Visa", cardholderName: "Jane Smith", cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", cardNickname: "Personal Visa", network: "visa", last4: "1111", }); expect(entry.type).toBe("card"); expect(entry.cardholderName).toBe("Jane Smith"); expect(entry.cardNumber).toBe("4111111111111111"); expect(entry.expMonth).toBe("05"); expect(entry.expYear).toBe("2028"); expect(entry.cvc).toBe("123"); expect(entry.network).toBe("visa"); expect(entry.last4).toBe("1111"); vault.lock(); await vault.unlock("card-test-pass-12345"); const entries = vault.getEntries(); expect(entries).toHaveLength(1); expect(entries[0].cardNumber).toBe("4111111111111111"); expect(entries[0].cvc).toBe("123"); }); it("updateEntry — cannot clear card number on card entry", async () => { const vault = await import("./vault"); await vault.createVault("card-guard-pass-12345"); const entry = await vault.addEntry({ type: "card", title: "My Card", cardholderName: "Jane Smith", cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", }); await expect( vault.updateEntry(entry.id, { cardNumber: "" }) ).rejects.toThrow("card number is required for card entries"); const entries = vault.getEntries(); expect(entries[0].cardNumber).toBe("4111111111111111"); }); it("declineCardSaveOffer — stores fingerprint in settings", async () => { const vault = await import("./vault"); await vault.createVault("decline-pass-12345"); await vault.declineCardSaveOffer("1111", "05", "2028"); const settings = vault.getSettings(); expect(settings.declinedCardFingerprints).toHaveLength(1); }); it("isCardDeclined — returns true for previously declined card", async () => { const vault = await import("./vault"); await vault.createVault("declined-check-pass-12345"); await vault.declineCardSaveOffer("1111", "05", "2028"); expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true); }); it("isCardDeclined — returns false for card with same last4 but different expiry", async () => { const vault = await import("./vault"); await vault.createVault("declined-diff-pass-12345"); await vault.declineCardSaveOffer("1111", "05", "2028"); expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false); }); it("queryDomain matches an entry against its additionalUrls", async () => { const vault = await import("./vault"); await vault.createVault("multi-domain-pass-12345"); const entry = await vault.addEntry({ type: "login", title: "Foo", username: "june", password: "secret", url: "https://foo.com", additionalUrls: ["https://bar.io", "https://baz.org"], }); // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only // add new buckets when the eTLD+1 is genuinely different from the primary. expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id); expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id); expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id); expect(vault.queryDomain("unrelated.com")).toEqual([]); }); it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => { const vault = await import("./vault"); await vault.createVault("dedupe-additional-pass-12345"); const entry = await vault.addEntry({ type: "login", title: "Foo", username: "june", password: "secret", url: "https://foo.com", }); await vault.updateEntry(entry.id, { additionalUrls: [ " https://login.foo.com ", "https://login.foo.com", "", " ", "https://accounts.foo.com", ], }); const stored = vault.getEntryById(entry.id); expect(stored?.additionalUrls).toEqual([ "https://login.foo.com", "https://accounts.foo.com", ]); await vault.updateEntry(entry.id, { additionalUrls: ["", " "] }); expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined(); }); it("preserves email field through lock/unlock cycle", async () => { const vault = await import("./vault"); await vault.createVault("preserve-email-pass-12345"); await vault.addEntry({ type: "login", title: "GitHub", username: "june", email: "ada@proton.me", password: "secret", url: "https://github.com", }); vault.lock(); const ok = await vault.unlock("preserve-email-pass-12345"); expect(ok).toBe(true); const entry = vault.getEntries().find((e) => e.title === "GitHub"); expect(entry?.email).toBe("ada@proton.me"); }); it("preserves additionalUrls through lock/unlock cycle", async () => { const vault = await import("./vault"); await vault.createVault("preserve-urls-pass-12345"); await vault.addEntry({ type: "login", title: "Example", username: "june", password: "secret", url: "https://example.com", additionalUrls: [ "https://beta.example.com", "https://staging.example.com", ], }); vault.lock(); const ok = await vault.unlock("preserve-urls-pass-12345"); expect(ok).toBe(true); const entry = vault.getEntries().find((e) => e.title === "Example"); expect(entry?.additionalUrls).toEqual([ "https://beta.example.com", "https://staging.example.com", ]); }); it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => { const vault = await import("./vault"); await vault.createVault("pwchanged-preserve-pass"); const entry = await vault.addEntry({ title: "Aged", username: "june", password: "secret", url: "https://aged.test", }); // addEntry stamps passwordChangedAt at creation; capture it and prove it // survives the encode/decode round-trip unchanged (was dropped pre-fix). const ts = entry.passwordChangedAt; expect(typeof ts).toBe("number"); vault.lock(); expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true); const restored = vault.getEntries().find((e) => e.id === entry.id); expect(restored?.passwordChangedAt).toBe(ts); }); it("normalizeEntry handles entries without passwordChangedAt", async () => { const vault = await import("./vault"); await vault.createVault("pwchanged-absent-pass"); // A note carries no password and therefore no passwordChangedAt stamp. const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" }); vault.lock(); expect(await vault.unlock("pwchanged-absent-pass")).toBe(true); const restored = vault.getEntries().find((e) => e.id === note.id); expect(restored?.passwordChangedAt).toBeUndefined(); }); it("applyMergedRemoteEntries does not advance the vault version", async () => { const vault = await import("./vault"); await vault.createVault("syncver-pass-aaaaa"); await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" }); const before = await vault.getVaultVersion(); await new Promise((r) => setTimeout(r, 2)); await vault.applyMergedRemoteEntries( [ { id: "remote-1", type: "login", title: "Remote", username: "r", password: "x", url: "https://r.test", createdAt: Date.now(), modifiedAt: Date.now(), }, ], {}, ); expect(await vault.getVaultVersion()).toBe(before); }); it("addEntry advances the vault version", async () => { const vault = await import("./vault"); await vault.createVault("syncver-pass-bbbbb"); const before = await vault.getVaultVersion(); await new Promise((r) => setTimeout(r, 2)); await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" }); expect(await vault.getVaultVersion()).toBeGreaterThan(before); }); it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => { const vault = await import("./vault"); await vault.createVault("monotonic-pass-aaaaa"); const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" }); const before = vault.getEntryById(e1.id)!.modifiedAt; // Simulate the clock jumping 10s backwards (NTP step / manual change). const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000); await vault.updateEntry(e1.id, { password: "p2" }); const after = vault.getEntryById(e1.id)!.modifiedAt; expect(after).toBeGreaterThan(before); spy.mockRestore(); }); it("deletedAt is strictly greater than the entry's modifiedAt", async () => { const vault = await import("./vault"); await vault.createVault("monotonic-pass-bbbbb"); const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" }); const modAt = vault.getEntryById(e.id)!.modifiedAt; await vault.deleteEntry(e.id); const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!; expect(tomb.deletedAt!).toBeGreaterThan(modAt); }); it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => { const vault = await import("./vault"); await vault.createVault("rollback-pass-aaaaa"); const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" }); const setSpy = chrome.storage.local.set as unknown as ReturnType; // Fail only the slot-A write (the history snapshot write is swallowed by design). setSpy.mockImplementation(async (next: Record) => { if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded"); Object.assign(storage, next); }); await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow(); setSpy.mockImplementation(async (next: Record) => { Object.assign(storage, next); }); // In-memory entry must still hold the original password, not the failed edit. expect(vault.getEntryById(e.id)!.password).toBe("original"); }); it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => { const vault = await import("./vault"); await vault.createVault("rootkey-pass-aaaaa"); const before = vault.getRootKeyHex(); expect(before).toMatch(/^[0-9a-f]{64}$/); expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true); expect(vault.getRootKeyHex()).toBe(before); // …and it survives a lock/unlock with the new password (rehydrated from pw_root_*). vault.lock(); expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true); expect(vault.getRootKeyHex()).toBe(before); }); it("getRootKeyHex returns null when the vault is locked", async () => { const vault = await import("./vault"); await vault.createVault("rootkey-pass-ccccc"); vault.lock(); expect(vault.getRootKeyHex()).toBeNull(); }); it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => { const vault = await import("./vault"); await vault.createVault("biometric-pass-12345"); const rootBefore = vault.getRootKeyHex(); const keyHex = vault.getDerivedKeyHex(); expect(rootBefore).not.toBeNull(); vault.lock(); // The biometric path passes the pre-derived AES key; rootKey must rehydrate. const r = await vault.unlockWithKey(keyHex); expect(r.ok).toBe(true); expect(vault.getRootKeyHex()).toBe(rootBefore); }); it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => { const vault = await import("./vault"); await vault.createVault("duress-real-pass-aaaa"); await vault.setupDuress("duress-decoy-pass-bbbb"); const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length; const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length; // Equal immediately after setup… expect(lenA()).toBe(lenB()); // …and STILL equal after real-vault edits (pre-fix, slot A grew per 1 KiB // bucket while the frozen decoy stayed put, leaking that duress exists). await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" }); await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" }); expect(lenA()).toBe(lenB()); }); it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => { const vault = await import("./vault"); await vault.createVault("rollback-pass-bbbbb"); const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" }); const setSpy = chrome.storage.local.set as unknown as ReturnType; setSpy.mockImplementation(async (next: Record) => { if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded"); Object.assign(storage, next); }); await expect(vault.deleteEntry(e.id)).rejects.toThrow(); setSpy.mockImplementation(async (next: Record) => { Object.assign(storage, next); }); // Entry must still be live (not tombstoned) since the delete never persisted. expect(vault.getEntryById(e.id)).toBeDefined(); expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined(); }); describe("password history", () => { it("captures the old password on change, newest first", async () => { const vault = await import("./vault"); await vault.createVault("history-pass-12345"); const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" }); expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined(); await vault.updateEntry(e.id, { password: "second" }); await vault.updateEntry(e.id, { password: "third" }); const hist = vault.getEntryById(e.id)?.passwordHistory ?? []; expect(hist.map((h) => h.password)).toEqual(["second", "first"]); expect(vault.getEntryById(e.id)?.password).toBe("third"); }); it("does not record history on a metadata-only update", async () => { const vault = await import("./vault"); await vault.createVault("history-meta-12345"); const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" }); await vault.updateEntry(e.id, { title: "Renamed" }); await vault.updateEntry(e.id, { username: "newuser" }); expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined(); }); it("records each history item with the age the password carried", async () => { const vault = await import("./vault"); await vault.createVault("history-stamp-12345"); const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" }); const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt; expect(firstChangedAt).toBeGreaterThan(0); await new Promise((resolve) => setTimeout(resolve, 2)); await vault.updateEntry(e.id, { password: "v2" }); const hist = vault.getEntryById(e.id)!.passwordHistory!; // The retired "v1" entry carries the timestamp it held while current. expect(hist[0].changedAt).toBe(firstChangedAt); }); it("skips capture when re-saving the identical password", async () => { const vault = await import("./vault"); await vault.createVault("history-noop-12345"); const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" }); await vault.updateEntry(e.id, { password: "same" }); expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined(); }); it("does not capture an empty prior password (clear then set)", async () => { const vault = await import("./vault"); await vault.createVault("history-empty-12345"); const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" }); await vault.updateEntry(e.id, { password: "now-has-one" }); // Nothing worth keeping when the prior value was empty. expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0); }); it("restoring an old password re-records the swapped-out current one", async () => { const vault = await import("./vault"); await vault.createVault("history-restore-12345"); const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" }); await vault.updateEntry(e.id, { password: "new" }); // Restore "old" — the editor just saves the old value as the current one. await vault.updateEntry(e.id, { password: "old" }); const hist = vault.getEntryById(e.id)!.passwordHistory!; expect(vault.getEntryById(e.id)?.password).toBe("old"); expect(hist.map((h) => h.password)).toEqual(["new", "old"]); }); it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => { const vault = await import("./vault"); await vault.createVault("history-persist-12345"); const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" }); await vault.updateEntry(e.id, { password: "p2" }); vault.lock(); expect(await vault.unlock("history-persist-12345")).toBe(true); const hist = vault.getEntryById(e.id)?.passwordHistory ?? []; expect(hist.map((h) => h.password)).toEqual(["p1"]); }); it("caps history at 50 entries, dropping the oldest", async () => { const vault = await import("./vault"); await vault.createVault("history-cap-12345"); const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" }); for (let i = 1; i <= 55; i++) { await vault.updateEntry(e.id, { password: `pw-${i}` }); } const hist = vault.getEntryById(e.id)!.passwordHistory!; expect(hist).toHaveLength(50); // Newest retired password is pw-54 (pw-55 is now current); oldest kept is pw-5. expect(hist[0].password).toBe("pw-54"); expect(hist[hist.length - 1].password).toBe("pw-5"); }); }); describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => { it("drops unknown/attacker keys from a restored entry", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); await vault.restoreEntries( [ { id: "e1", type: "login", title: "Bank", username: "june", password: "pw", createdAt: 1, modifiedAt: 2, evil: "payload", isAdmin: true, nested: { junk: 1 }, } as unknown as import("../shared/types").VaultEntry, ], "replace", ); const restored = vault.getEntries().find((e) => e.id === "e1")!; expect(restored.password).toBe("pw"); expect((restored as unknown as Record).evil).toBeUndefined(); expect((restored as unknown as Record).isAdmin).toBeUndefined(); expect((restored as unknown as Record).nested).toBeUndefined(); }); it("strips a non-HTTPS canary webhook but keeps the entry", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); await vault.restoreEntries( [ { id: "e2", type: "login", title: "Bank", password: "pw", createdAt: 1, modifiedAt: 2, canary: { webhookUrl: "http://attacker.example.com/x" }, } as unknown as import("../shared/types").VaultEntry, ], "replace", ); const restored = vault.getEntries().find((e) => e.id === "e2")!; expect(restored).toBeDefined(); expect(restored.canary).toBeUndefined(); }); it("preserves a well-formed HTTPS canary webhook", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); await vault.restoreEntries( [ { id: "e3", type: "login", title: "Bank", password: "pw", createdAt: 1, modifiedAt: 2, canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" }, } as unknown as import("../shared/types").VaultEntry, ], "replace", ); const restored = vault.getEntries().find((e) => e.id === "e3")!; expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x"); expect(restored.canary?.label).toBe("watch"); }); it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); await vault.restoreEntries( [ { id: "e4", type: "login", title: "Has bad passkey", createdAt: 1, modifiedAt: 2, passkey: { rpId: "example.com", publicKeyCose: "x" }, } as unknown as import("../shared/types").VaultEntry, ], "replace", ); expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false); }); }); describe("MV-8 rotateSyncIdentity", () => { it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" }); const { upsertPairedDevice, listPairedDevices } = await import( "./paired-devices" ); await upsertPairedDevice({ deviceId: "d1", deviceName: "Phone", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: "b".repeat(64), pairedAt: 1, trustSource: "direct", }); const rootBefore = vault.getRootKeyHex(); const res = await vault.rotateSyncIdentity(); expect(res.ok).toBe(true); expect(vault.getRootKeyHex()).not.toBe(rootBefore); expect(await listPairedDevices()).toHaveLength(0); // Entries survive — they are sealed under the password key, not the root. expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank"); vault.lock(); expect(await vault.unlock("correct horse battery staple")).toBe(true); expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank"); }); it("invalidates the old recovery words and flags recovery-pending", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const wordsBefore = (await vault.getRecoveryWords()).join(" "); await vault.rotateSyncIdentity(); const wordsAfter = (await vault.getRecoveryWords()).join(" "); expect(wordsAfter).not.toBe(wordsBefore); const { isRecoveryConfirmPending } = await import("./recovery-pending"); expect(await isRecoveryConfirmPending()).toBe(true); }); it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const { createSyncVaultAdapter } = await import( "./sync-vault-adapter" ); const adapter = createSyncVaultAdapter(); const vidBefore = await adapter.computeVaultId(); await vault.rotateSyncIdentity(); const vidAfter = await adapter.computeVaultId(); expect(vidAfter).not.toBe(vidBefore); }); }); describe("LV-1 NFC master password normalization", () => { const CAFE_BASE = "café"; const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`; const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`; it("a vault created with the NFD form unlocks with the NFC form", async () => { const vault = await import("./vault"); expect(PW_NFD).not.toBe(PW_NFC); await vault.createVault(PW_NFD); vault.lock(); expect(await vault.unlock(PW_NFC)).toBe(true); }); }); describe("LV-7 updateSettings proto-pollution filter", () => { it("strips __proto__/constructor/prototype keys while persisting real settings", async () => { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); await vault.updateSettings({ theme: "dark", ["__proto__"]: { polluted: true }, constructor: { polluted: true }, } as never); expect(({} as Record).polluted).toBeUndefined(); expect((Object.prototype as Record).polluted).toBeUndefined(); expect(vault.getSettings().theme).toBe("dark"); }); }); // PB-26-011: URL scheme allow-list at the storage boundary — imported, // added, or patched URLs with a non-http(s) scheme are dropped. Schemeless // values (e.g. "github.com") are kept: they are inert and pre-date this // guard in existing data. describe("PB-26-011 url scheme allow-list", () => { let vault: typeof import("./vault"); beforeEach(async () => { vault = await import("./vault"); await vault.createVault("correct horse battery staple"); }); it("drops a javascript: url on addEntry", async () => { const entry = await vault.addEntry({ title: "Evil", username: "june", password: "secret1", url: "javascript:alert(1)", }); const stored = vault.getEntryById(entry.id); expect(stored?.url).toBe(""); }); it("drops non-http(s) schemes from additionalUrls", async () => { const entry = await vault.addEntry({ title: "Evil", username: "june", password: "secret1", url: "https://ok.test", additionalUrls: [ "javascript:alert(1)", "data:text/html,x", "https://also-ok.test", ], }); const stored = vault.getEntryById(entry.id); expect(stored?.additionalUrls).toEqual(["https://also-ok.test"]); }); it("drops a data: url on UPDATE_ENTRY patch", async () => { const entry = await vault.addEntry({ title: "Ok", username: "june", password: "secret1", url: "https://ok.test", }); await vault.updateEntry(entry.id, { url: "data:text/html,x" }); const stored = vault.getEntryById(entry.id); expect(stored?.url).toBe(""); }); it("keeps https and schemeless urls", async () => { const entry = await vault.addEntry({ title: "Ok", username: "june", password: "secret1", url: "https://github.com", additionalUrls: ["github.com"], }); const stored = vault.getEntryById(entry.id); expect(stored?.url).toBe("https://github.com"); expect(stored?.additionalUrls).toEqual(["github.com"]); }); }); });