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 190 191 192 193 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 257 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 268 const exempt = await messages.handleMessage(269 { type: "GET_RECOVERY_WORDS", payload: {} },270 extensionSender,271 );272 273 274 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 284 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 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});