ThreatCrush extension scan interval fix

Second accepted bugfix slice for the ThreatCrush extension/options workflow. Prepared 2026-06-19.

What was fixed

Files changed

Verification

Passed: 1 test file, 2 tests.

corepack pnpm --filter @profullstack/threatcrush-extension exec vitest run __tests__/options-app.test.jsx

Note: a full workspace install hit a local native build prerequisite for better-sqlite3 / Visual Studio C++ tools. I used a focused extension install and ran the extension test directly.

Patch

diff --git a/apps/extension/src/options/App.jsx b/apps/extension/src/options/App.jsx
index dbc4d97..04cb925 100644
--- a/apps/extension/src/options/App.jsx
+++ b/apps/extension/src/options/App.jsx
@@ -8,6 +8,20 @@ const DEFAULT_SETTINGS = {
   scanInterval: 5,
 };
 
+const MIN_SCAN_INTERVAL = 1;
+const MAX_SCAN_INTERVAL = 60;
+const EVENT_CHECK_ALARM = 'threatcrush-event-check';
+
+function normalizeScanInterval(value) {
+  const parsed = Number.parseInt(value, 10);
+
+  if (!Number.isFinite(parsed)) {
+    return DEFAULT_SETTINGS.scanInterval;
+  }
+
+  return Math.min(MAX_SCAN_INTERVAL, Math.max(MIN_SCAN_INTERVAL, parsed));
+}
+
 export default function App() {
   const [settings, setSettings] = useState(DEFAULT_SETTINGS);
   const [saved, setSaved] = useState(false);
@@ -21,7 +35,16 @@ export default function App() {
 
   async function handleSave(e) {
     e.preventDefault();
-    await chrome.storage.local.set(settings);
+    const nextSettings = {
+      ...settings,
+      scanInterval: normalizeScanInterval(settings.scanInterval),
+    };
+
+    await chrome.storage.local.set(nextSettings);
+    await chrome.alarms.create(EVENT_CHECK_ALARM, {
+      periodInMinutes: nextSettings.scanInterval,
+    });
+    setSettings(nextSettings);
     setSaved(true);
     setTimeout(() => setSaved(false), 2000);
   }
@@ -48,8 +71,9 @@ export default function App() {
 
           <div className="space-y-3">
             <div>
-              <label className="block text-xs text-gray-400 mb-1">Server URL</label>
+              <label htmlFor="server-url" className="block text-xs text-gray-400 mb-1">Server URL</label>
               <input
+                id="server-url"
                 type="url"
                 value={settings.serverUrl}
                 onChange={(e) => updateSetting('serverUrl', e.target.value)}
@@ -58,8 +82,9 @@ export default function App() {
             </div>
 
             <div>
-              <label className="block text-xs text-gray-400 mb-1">License Key</label>
+              <label htmlFor="license-key" className="block text-xs text-gray-400 mb-1">License Key</label>
               <input
+                id="license-key"
                 type="password"
                 value={settings.licenseKey}
                 onChange={(e) => updateSetting('licenseKey', e.target.value)}
@@ -105,15 +130,16 @@ export default function App() {
             </label>
 
             <div>
-              <label className="block text-xs text-gray-400 mb-1">
+              <label htmlFor="scan-interval" className="block text-xs text-gray-400 mb-1">
                 Event check interval (minutes)
               </label>
               <input
+                id="scan-interval"
                 type="number"
                 min="1"
                 max="60"
                 value={settings.scanInterval}
-                onChange={(e) => updateSetting('scanInterval', parseInt(e.target.value, 10))}
+                onChange={(e) => updateSetting('scanInterval', e.target.value)}
                 className="w-24 px-3 py-2 bg-[#0a0a0a] border border-[#222] rounded-lg text-sm text-white focus:outline-none focus:border-[#00ff41] transition-colors"
               />
             </div>
diff --git a/apps/extension/__tests__/options-app.test.jsx b/apps/extension/__tests__/options-app.test.jsx
new file mode 100644
index 0000000..4094fdd
--- /dev/null
+++ b/apps/extension/__tests__/options-app.test.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+import App from '../src/options/App.jsx';
+
+describe('extension options app', () => {
+  beforeEach(() => {
+    global.chrome = {
+      alarms: {
+        create: vi.fn().mockResolvedValue(undefined),
+      },
+      storage: {
+        local: {
+          get: vi.fn((keys, callback) => callback({})),
+          set: vi.fn().mockResolvedValue(undefined),
+        },
+      },
+    };
+  });
+
+  it('normalizes a blank scan interval and reschedules the alarm on save', async () => {
+    render(<App />);
+
+    const intervalInput = screen.getByLabelText(/event check interval/i);
+    fireEvent.change(intervalInput, { target: { value: '' } });
+    fireEvent.click(screen.getByRole('button', { name: /save settings/i }));
+
+    await waitFor(() => {
+      expect(chrome.storage.local.set).toHaveBeenCalledWith(
+        expect.objectContaining({ scanInterval: 5 })
+      );
+    });
+    expect(chrome.alarms.create).toHaveBeenCalledWith('threatcrush-event-check', {
+      periodInMinutes: 5,
+    });
+  });
+
+  it('persists the selected scan interval and applies it to the event alarm', async () => {
+    render(<App />);
+
+    const intervalInput = screen.getByLabelText(/event check interval/i);
+    fireEvent.change(intervalInput, { target: { value: '15' } });
+    fireEvent.click(screen.getByRole('button', { name: /save settings/i }));
+
+    await waitFor(() => {
+      expect(chrome.storage.local.set).toHaveBeenCalledWith(
+        expect.objectContaining({ scanInterval: 15 })
+      );
+    });
+    expect(chrome.alarms.create).toHaveBeenCalledWith('threatcrush-event-check', {
+      periodInMinutes: 15,
+    });
+  });
+});
Self-destructs in · 1 view · Keep Forever — $5 · Support · HTMLDrops