import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; describe("background alarms", () => { let storage: StorageShape; let sessionStorage: StorageShape; let alarmListener: ((alarm: { name: string }) => void | Promise) | undefined; let idleListener: ((state: string) => void | Promise) | undefined; let sentRuntimeMessages: Array; let sentTabMessages: Array<{ tabId: number; message: unknown }>; beforeEach(() => { vi.resetModules(); storage = {}; sessionStorage = {}; sentRuntimeMessages = []; sentTabMessages = []; 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; const keys = Array.isArray(key) ? key : [key]; return keys.reduce((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {}); }), set: vi.fn(async (next: StorageShape) => { 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]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn((fn: (alarm: { name: string }) => void | Promise) => { alarmListener = fn; }), }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn((fn: (state: string) => void | Promise) => { idleListener = fn; }), }, }, runtime: { id: "test-extension-id", getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`), sendMessage: vi.fn(async (message: unknown) => { sentRuntimeMessages.push(message); return { ok: true }; }), }, tabs: { query: vi.fn((_query: unknown, callback: (tabs: Array<{ id?: number }>) => void) => { callback([{ id: 1 }, { id: 2 }]); }), sendMessage: vi.fn(async (tabId: number, message: unknown) => { sentTabMessages.push({ tabId, message }); return { ok: true }; }), }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled in tests"); })); vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false); }); it("touchAutoLock creates an alarm using autoLockMinutes from settings", async () => { const vault = await import("./vault"); const alarms = await import("./alarms"); await vault.createVault("correct-pass-12345"); await vault.updateSettings({ autoLockMinutes: 5 }); alarms.touchAutoLock(); expect(chrome.alarms.clear).toHaveBeenCalledWith("autolock"); expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", { delayInMinutes: 5, }); }); it("touchAutoLock does not create an alarm when autoLockMinutes is 0", async () => { const vault = await import("./vault"); const alarms = await import("./alarms"); await vault.createVault("correct-pass-12345"); await vault.updateSettings({ autoLockMinutes: 0 }); (chrome.alarms.create as unknown as ReturnType).mockClear(); alarms.touchAutoLock(); expect(chrome.alarms.create).not.toHaveBeenCalled(); }); it("setupAutoLock configures idle detection using the settings period", async () => { const vault = await import("./vault"); const alarms = await import("./alarms"); await vault.createVault("correct-pass-12345"); await vault.updateSettings({ autoLockMinutes: 2 }); await alarms.setupAutoLock(); expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(120); }); it("setupAutoLock reads the persisted setting before applying idle detection", async () => { storage.peach_settings = { autoLockMinutes: 30 }; const alarms = await import("./alarms"); await alarms.setupAutoLock(); expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800); }); it("alarm firing locks the vault and broadcasts VAULT_LOCKED", async () => { const vault = await import("./vault"); await import("./alarms"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); await alarmListener?.({ name: "autolock" }); expect(vault.isUnlocked()).toBe(false); expect(sentRuntimeMessages).toContainEqual({ type: "VAULT_LOCKED" }); expect(sentTabMessages).toEqual([ { tabId: 1, message: { type: "VAULT_LOCKED" } }, { tabId: 2, message: { type: "VAULT_LOCKED" } }, ]); }); it("idle state change to idle locks the vault and active does not", async () => { const vault = await import("./vault"); await import("./alarms"); await vault.createVault("correct-pass-12345"); await vault.updateSettings({ autoLockMinutes: 5 }); await idleListener?.("active"); expect(vault.isUnlocked()).toBe(true); await idleListener?.("idle"); expect(vault.isUnlocked()).toBe(false); expect(sentRuntimeMessages).toContainEqual({ type: "VAULT_LOCKED" }); }); // PB-26-102: the recovery-confirmation window deliberately suppresses every // lock path so the words screen does not re-prompt for a password the user // just typed. That suppression is only safe while it is time-boxed — an // abandoned words screen used to leave the vault unlocked with no auto-lock // and the recovery words retrievable for the rest of the browser session. describe("recovery confirmation deadline (PB-26-102)", () => { const extensionSender = { url: "chrome-extension://test-extension-id/src/popup/index.html", } as unknown as chrome.runtime.MessageSender; async function bootUnlocked() { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); return vault; } it("an alarm before the deadline does not lock the vault", async () => { vi.useFakeTimers(); try { const vault = await bootUnlocked(); await import("./alarms"); const { setRecoveryConfirmPending } = await import("./recovery-pending"); await setRecoveryConfirmPending(); await vi.advanceTimersByTimeAsync(60_000); await alarmListener?.({ name: "autolock" }); expect(vault.isUnlocked()).toBe(true); } finally { vi.useRealTimers(); } }); it("an alarm after the deadline locks the vault", async () => { vi.useFakeTimers(); try { const vault = await bootUnlocked(); await import("./alarms"); const { setRecoveryConfirmPending } = await import("./recovery-pending"); await setRecoveryConfirmPending(); await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1); await alarmListener?.({ name: "autolock" }); expect(vault.isUnlocked()).toBe(false); } finally { vi.useRealTimers(); } }); it("an idle event after the deadline locks the vault", async () => { vi.useFakeTimers(); try { const vault = await bootUnlocked(); await import("./alarms"); const { setRecoveryConfirmPending } = await import("./recovery-pending"); await setRecoveryConfirmPending(); await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1); await idleListener?.("idle"); expect(vault.isUnlocked()).toBe(false); } finally { vi.useRealTimers(); } }); // The full sequence: the exemption expires, the vault locks on the next // alarm, and the words go back behind the password proof. it("the recovery words require a fresh password proof once the window expires", async () => { vi.useFakeTimers(); try { const vault = await bootUnlocked(); await import("./alarms"); const pending = await import("./recovery-pending"); const messages = await import("./messages"); await pending.setRecoveryConfirmPending(); // Inside the window: the words screen needs no password. const exempt = await messages.handleMessage( { type: "GET_RECOVERY_WORDS", payload: {} }, extensionSender, ); // The vault was created under fake timers, so recovery words may not be // configured in this harness; either outcome proves the exemption was // consulted rather than the password gate. expect(exempt.error).not.toBe("password_required"); await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1); await alarmListener?.({ name: "autolock" }); expect(vault.isUnlocked()).toBe(false); expect(await pending.isRecoveryConfirmPending()).toBe(false); // Locked is enough on its own, but re-unlocking must not resurrect the // exemption: the words are gated again until a password is proved. expect(await vault.unlock("correct-pass-12345")).toBe(true); await expect( messages.handleMessage( { type: "GET_RECOVERY_WORDS", payload: {} }, extensionSender, ), ).resolves.toMatchObject({ ok: false, error: "password_required" }); // And the correct password is what opens it. await expect( messages.handleMessage( { type: "GET_RECOVERY_WORDS", payload: { password: "correct-pass-12345" } }, extensionSender, ), ).resolves.toMatchObject({ ok: true }); } finally { vi.useRealTimers(); } }); }); });