alarms.test.ts

11.6 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  it("defers alarm and idle locking while a bounded pairing operation is active", async () => {190    vi.useFakeTimers();191    try {192      const vault = await import("./vault");193      const alarms = await import("./alarms");194      await vault.createVault("correct-pass-12345");195196      const release = alarms.deferAutoLockForOperation(5 * 60 * 1000);197      await alarmListener?.({ name: "autolock" });198      await idleListener?.("idle");199200      expect(vault.isUnlocked()).toBe(true);201      expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {202        delayInMinutes: 5,203      });204205      release();206      await idleListener?.("idle");207      expect(vault.isUnlocked()).toBe(false);208    } finally {209      vi.useRealTimers();210    }211  });212213  it("never defers locking when the operating system reports a locked session", async () => {214    const vault = await import("./vault");215    const alarms = await import("./alarms");216    await vault.createVault("correct-pass-12345");217218    alarms.deferAutoLockForOperation(5 * 60 * 1000);219    await idleListener?.("locked");220221    expect(vault.isUnlocked()).toBe(false);222  });223224  // PB-26-102: the recovery-confirmation window deliberately suppresses every225  // lock path so the words screen does not re-prompt for a password the user226  // just typed. That suppression is only safe while it is time-boxed — an227  // abandoned words screen used to leave the vault unlocked with no auto-lock228  // and the recovery words retrievable for the rest of the browser session.229  describe("recovery confirmation deadline (PB-26-102)", () => {230    const extensionSender = {231      url: "chrome-extension://test-extension-id/src/popup/index.html",232    } as unknown as chrome.runtime.MessageSender;233234    async function bootUnlocked() {235      const vault = await import("./vault");236      await vault.createVault("correct-pass-12345");237      return vault;238    }239240    it("an alarm before the deadline does not lock the vault", async () => {241      vi.useFakeTimers();242      try {243        const vault = await bootUnlocked();244        await import("./alarms");245        const { setRecoveryConfirmPending } = await import("./recovery-pending");246        await setRecoveryConfirmPending();247248        await vi.advanceTimersByTimeAsync(60_000);249        await alarmListener?.({ name: "autolock" });250251        expect(vault.isUnlocked()).toBe(true);252      } finally {253        vi.useRealTimers();254      }255    });256257    it("an alarm after the deadline locks the vault", async () => {258      vi.useFakeTimers();259      try {260        const vault = await bootUnlocked();261        await import("./alarms");262        const { setRecoveryConfirmPending } = await import("./recovery-pending");263        await setRecoveryConfirmPending();264265        await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);266        await alarmListener?.({ name: "autolock" });267268        expect(vault.isUnlocked()).toBe(false);269      } finally {270        vi.useRealTimers();271      }272    });273274    it("an idle event after the deadline locks the vault", async () => {275      vi.useFakeTimers();276      try {277        const vault = await bootUnlocked();278        await import("./alarms");279        const { setRecoveryConfirmPending } = await import("./recovery-pending");280        await setRecoveryConfirmPending();281282        await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);283        await idleListener?.("idle");284285        expect(vault.isUnlocked()).toBe(false);286      } finally {287        vi.useRealTimers();288      }289    });290291    // The full sequence: the exemption expires, the vault locks on the next292    // alarm, and the words go back behind the password proof.293    it("the recovery words require a fresh password proof once the window expires", async () => {294      vi.useFakeTimers();295      try {296        const vault = await bootUnlocked();297        await import("./alarms");298        const pending = await import("./recovery-pending");299        const messages = await import("./messages");300        await pending.setRecoveryConfirmPending();301302        // Inside the window: the words screen needs no password.303        const exempt = await messages.handleMessage(304          { type: "GET_RECOVERY_WORDS", payload: {} },305          extensionSender,306        );307        // The vault was created under fake timers, so recovery words may not be308        // configured in this harness; either outcome proves the exemption was309        // consulted rather than the password gate.310        expect(exempt.error).not.toBe("password_required");311312        await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1);313        await alarmListener?.({ name: "autolock" });314315        expect(vault.isUnlocked()).toBe(false);316        expect(await pending.isRecoveryConfirmPending()).toBe(false);317318        // Locked is enough on its own, but re-unlocking must not resurrect the319        // exemption: the words are gated again until a password is proved.320        expect(await vault.unlock("correct-pass-12345")).toBe(true);321        await expect(322          messages.handleMessage(323            { type: "GET_RECOVERY_WORDS", payload: {} },324            extensionSender,325          ),326        ).resolves.toMatchObject({ ok: false, error: "password_required" });327328        // And the correct password is what opens it.329        await expect(330          messages.handleMessage(331            { type: "GET_RECOVERY_WORDS", payload: { password: "correct-pass-12345" } },332            extensionSender,333          ),334        ).resolves.toMatchObject({ ok: true });335      } finally {336        vi.useRealTimers();337      }338    });339  });340});