alarms.test.ts

10.5 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;45describe("background alarms", () => {6  let storage: StorageShape;7  let sessionStorage: StorageShape;8  let alarmListener: ((alarm: { name: string }) => void | Promise<void>) | undefined;9  let idleListener: ((state: string) => void | Promise<void>) | undefined;10  let sentRuntimeMessages: Array<unknown>;11  let sentTabMessages: Array<{ tabId: number; message: unknown }>;1213  beforeEach(() => {14    vi.resetModules();15    storage = {};16    sessionStorage = {};17    sentRuntimeMessages = [];18    sentTabMessages = [];1920    const chromeMock = {21      storage: {22        local: {23          get: vi.fn(async (key?: string | string[]) => {24            if (!key) return storage;25            if (typeof key === "string") {26              return { [key]: storage[key] };27            }2829            return key.reduce<Record<string, unknown>>((result, entry) => {30              result[entry] = storage[entry];31              return result;32            }, {});33          }),34          set: vi.fn(async (next: Record<string, unknown>) => {35            Object.assign(storage, next);36          }),37          remove: vi.fn(async (keys: string | string[]) => {38            const list = Array.isArray(keys) ? keys : [keys];39            for (const key of list) {40              delete storage[key];41            }42          }),43        },44        session: {45          get: vi.fn(async (key?: string | string[]) => {46            if (!key) return sessionStorage;47            const keys = Array.isArray(key) ? key : [key];48            return keys.reduce<StorageShape>((acc, k) => {49              acc[k] = sessionStorage[k];50              return acc;51            }, {});52          }),53          set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),54          remove: vi.fn(async (keys: string | string[]) => {55            const list = Array.isArray(keys) ? keys : [keys];56            for (const key of list) delete sessionStorage[key];57          }),58        },59      },60      alarms: {61        clear: vi.fn(),62        create: vi.fn(),63        onAlarm: {64          addListener: vi.fn((fn: (alarm: { name: string }) => void | Promise<void>) => {65            alarmListener = fn;66          }),67        },68      },69      idle: {70        setDetectionInterval: vi.fn(),71        onStateChanged: {72          addListener: vi.fn((fn: (state: string) => void | Promise<void>) => {73            idleListener = fn;74          }),75        },76      },77      runtime: {78        id: "test-extension-id",79        getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),80        sendMessage: vi.fn(async (message: unknown) => {81          sentRuntimeMessages.push(message);82          return { ok: true };83        }),84      },85      tabs: {86        query: vi.fn((_query: unknown, callback: (tabs: Array<{ id?: number }>) => void) => {87          callback([{ id: 1 }, { id: 2 }]);88        }),89        sendMessage: vi.fn(async (tabId: number, message: unknown) => {90          sentTabMessages.push({ tabId, message });91          return { ok: true };92        }),93      },94    } as unknown as typeof chrome;9596    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;97    vi.stubGlobal("fetch", vi.fn(async () => {98      throw new Error("network disabled in tests");99    }));100    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);101  });102103  it("touchAutoLock creates an alarm using autoLockMinutes from settings", async () => {104    const vault = await import("./vault");105    const alarms = await import("./alarms");106107    await vault.createVault("correct-pass-12345");108    await vault.updateSettings({ autoLockMinutes: 5 });109110    alarms.touchAutoLock();111112    expect(chrome.alarms.clear).toHaveBeenCalledWith("autolock");113    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {114      delayInMinutes: 5,115    });116  });117118  it("touchAutoLock does not create an alarm when autoLockMinutes is 0", async () => {119    const vault = await import("./vault");120    const alarms = await import("./alarms");121122    await vault.createVault("correct-pass-12345");123    await vault.updateSettings({ autoLockMinutes: 0 });124125    (chrome.alarms.create as unknown as ReturnType<typeof vi.fn>).mockClear();126    alarms.touchAutoLock();127128    expect(chrome.alarms.create).not.toHaveBeenCalled();129  });130131  it("setupAutoLock configures idle detection using the settings period", async () => {132    const vault = await import("./vault");133    const alarms = await import("./alarms");134135    await vault.createVault("correct-pass-12345");136    await vault.updateSettings({ autoLockMinutes: 2 });137138    await alarms.setupAutoLock();139140    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(120);141  });142143  it("setupAutoLock reads the persisted setting before applying idle detection", async () => {144    storage.peach_settings = { autoLockMinutes: 30 };145    const alarms = await import("./alarms");146147    await alarms.setupAutoLock();148149    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);150  });151152  it("alarm firing locks the vault and broadcasts VAULT_LOCKED", async () => {153    const vault = await import("./vault");154    await import("./alarms");155156    await vault.createVault("correct-pass-12345");157    await vault.addEntry({158      title: "GitHub",159      username: "june",160      password: "secret123",161      url: "https://github.com",162    });163164    await alarmListener?.({ name: "autolock" });165166    expect(vault.isUnlocked()).toBe(false);167    expect(sentRuntimeMessages).toContainEqual({ type: "VAULT_LOCKED" });168    expect(sentTabMessages).toEqual([169      { tabId: 1, message: { type: "VAULT_LOCKED" } },170      { tabId: 2, message: { type: "VAULT_LOCKED" } },171    ]);172  });173174  it("idle state change to idle locks the vault and active does not", async () => {175    const vault = await import("./vault");176    await import("./alarms");177178    await vault.createVault("correct-pass-12345");179    await vault.updateSettings({ autoLockMinutes: 5 });180181    await idleListener?.("active");182    expect(vault.isUnlocked()).toBe(true);183184    await idleListener?.("idle");185    expect(vault.isUnlocked()).toBe(false);186    expect(sentRuntimeMessages).toContainEqual({ type: "VAULT_LOCKED" });187  });188189  // PB-26-102: the recovery-confirmation window deliberately suppresses every190  // lock path so the words screen does not re-prompt for a password the user191  // just typed. That suppression is only safe while it is time-boxed — an192  // abandoned words screen used to leave the vault unlocked with no auto-lock193  // and the recovery words retrievable for the rest of the browser session.194  describe("recovery confirmation deadline (PB-26-102)", () => {195    const extensionSender = {196      url: "chrome-extension://test-extension-id/src/popup/index.html",197    } as unknown as chrome.runtime.MessageSender;198199    async function bootUnlocked() {200      const vault = await import("./vault");201      await vault.createVault("correct-pass-12345");202      return vault;203    }204205    it("an alarm before the deadline does not lock the vault", async () => {206      vi.useFakeTimers();207      try {208        const vault = await bootUnlocked();209        await import("./alarms");210        const { setRecoveryConfirmPending } = await import("./recovery-pending");211        await setRecoveryConfirmPending();212213        await vi.advanceTimersByTimeAsync(60_000);214        await alarmListener?.({ name: "autolock" });215216        expect(vault.isUnlocked()).toBe(true);217      } finally {218        vi.useRealTimers();219      }220    });221222    it("an alarm after the deadline locks the vault", async () => {223      vi.useFakeTimers();224      try {225        const vault = await bootUnlocked();226        await import("./alarms");227        const { setRecoveryConfirmPending } = await import("./recovery-pending");228        await setRecoveryConfirmPending();229230        await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);231        await alarmListener?.({ name: "autolock" });232233        expect(vault.isUnlocked()).toBe(false);234      } finally {235        vi.useRealTimers();236      }237    });238239    it("an idle event after the deadline locks the vault", async () => {240      vi.useFakeTimers();241      try {242        const vault = await bootUnlocked();243        await import("./alarms");244        const { setRecoveryConfirmPending } = await import("./recovery-pending");245        await setRecoveryConfirmPending();246247        await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);248        await idleListener?.("idle");249250        expect(vault.isUnlocked()).toBe(false);251      } finally {252        vi.useRealTimers();253      }254    });255256    // The full sequence: the exemption expires, the vault locks on the next257    // alarm, and the words go back behind the password proof.258    it("the recovery words require a fresh password proof once the window expires", async () => {259      vi.useFakeTimers();260      try {261        const vault = await bootUnlocked();262        await import("./alarms");263        const pending = await import("./recovery-pending");264        const messages = await import("./messages");265        await pending.setRecoveryConfirmPending();266267        // Inside the window: the words screen needs no password.268        const exempt = await messages.handleMessage(269          { type: "GET_RECOVERY_WORDS", payload: {} },270          extensionSender,271        );272        // The vault was created under fake timers, so recovery words may not be273        // configured in this harness; either outcome proves the exemption was274        // consulted rather than the password gate.275        expect(exempt.error).not.toBe("password_required");276277        await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);278        await alarmListener?.({ name: "autolock" });279280        expect(vault.isUnlocked()).toBe(false);281        expect(await pending.isRecoveryConfirmPending()).toBe(false);282283        // Locked is enough on its own, but re-unlocking must not resurrect the284        // exemption: the words are gated again until a password is proved.285        expect(await vault.unlock("correct-pass-12345")).toBe(true);286        await expect(287          messages.handleMessage(288            { type: "GET_RECOVERY_WORDS", payload: {} },289            extensionSender,290          ),291        ).resolves.toMatchObject({ ok: false, error: "password_required" });292293        // And the correct password is what opens it.294        await expect(295          messages.handleMessage(296            { type: "GET_RECOVERY_WORDS", payload: { password: "correct-pass-12345" } },297            extensionSender,298          ),299        ).resolves.toMatchObject({ ok: true });300      } finally {301        vi.useRealTimers();302      }303    });304  });305});