ThreatCrush extension scan interval fix
What was fixed
- The options page could save an empty event-check interval as
NaN. - Changing the interval in options did not reprogram the extension alarm, so the background event check could keep the previous cadence.
- The interval is now normalized to 1-60 minutes, defaults invalid/blank input to 5 minutes, and recreates the
threatcrush-event-checkalarm on save. - Related labels now point to their inputs for easier testing and accessibility.
Files changed
apps/extension/src/options/App.jsxapps/extension/__tests__/options-app.test.jsx
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,
+ });
+ });
+});