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 225 226 227 228 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 292 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 303 const exempt = await messages.handleMessage(304 { type: "GET_RECOVERY_WORDS", payload: {} },305 extensionSender,306 );307 308 309 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 319 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 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});