From 501f96dbe1e9e67ecc02be15b92a982dc2167b8d Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sun, 21 Jun 2026 15:50:39 -0400 Subject: [PATCH] feat(drift): cap the scanner interval at the documented max intervalMs() enforced the 15-minute floor but not the 1440-minute cap the settings route and UI apply, so a corrupted or bypass-written DB value could silently defer an enabled scan indefinitely. Clamp to the same bounds (fall back to the default outside them). Adds tests for the over-cap fallback, the isScanning overlap guard, and the route rejecting an over-cap interval. --- .../src/__tests__/drift-scan-service.test.ts | 29 +++++++++++++++++++ backend/src/__tests__/settings-routes.test.ts | 9 ++++++ backend/src/services/DriftScanService.ts | 3 +- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/backend/src/__tests__/drift-scan-service.test.ts b/backend/src/__tests__/drift-scan-service.test.ts index f6e1c0d1..3021a7aa 100644 --- a/backend/src/__tests__/drift-scan-service.test.ts +++ b/backend/src/__tests__/drift-scan-service.test.ts @@ -88,4 +88,33 @@ describe('DriftScanService', () => { await svc.tick(); // within the 60-minute interval => skipped expect(spy).toHaveBeenCalledTimes(localNodeCount()); // not doubled }); + + it('drops a tick that fires while a scan is already running', async () => { + db().updateGlobalSetting('drift_scan_enabled', '1'); + db().updateGlobalSetting('drift_scan_interval_minutes', '60'); + let release!: () => void; + const gate = new Promise(r => { release = r; }); + const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileNode').mockImplementation(async () => { + await gate; // hold the scan open so a second tick overlaps it + return { stacks: 0, detected: 0, resolved: 0 }; + }); + const svc = DriftScanService.getInstance(); + const first = svc.tick(); // starts a scan and suspends on the gate (isScanning is now true) + await Promise.resolve(); + await svc.tick(); // fires mid-scan => dropped by the isScanning guard + release(); + await first; + expect(spy).toHaveBeenCalledTimes(localNodeCount()); // only the first tick scanned + }); + + it('falls back to the default interval for an out-of-range or malformed stored value', () => { + const svc = DriftScanService.getInstance() as unknown as { intervalMs(): number }; + const defaultMs = 60 * 60_000; + db().updateGlobalSetting('drift_scan_interval_minutes', '99999'); // above the 1440 cap + expect(svc.intervalMs()).toBe(defaultMs); + db().updateGlobalSetting('drift_scan_interval_minutes', '5'); // below the 15 floor + expect(svc.intervalMs()).toBe(defaultMs); + db().updateGlobalSetting('drift_scan_interval_minutes', 'banana'); // not a number + expect(svc.intervalMs()).toBe(defaultMs); + }); }); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index fc4aea08..c80c1ce1 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -286,6 +286,15 @@ describe('drift detection scan settings', () => { expect(DatabaseService.getInstance().getGlobalSettings().drift_scan_interval_minutes).not.toBe('5'); }); + it('rejects an out-of-range interval above the 1440 minute cap (400)', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'drift_scan_interval_minutes', value: '99999' }); + expect(res.status).toBe(400); + expect(DatabaseService.getInstance().getGlobalSettings().drift_scan_interval_minutes).not.toBe('99999'); + }); + it('rejects a non-admin write (403)', async () => { const res = await request(app) .post('/api/settings') diff --git a/backend/src/services/DriftScanService.ts b/backend/src/services/DriftScanService.ts index d8b50839..694f8e79 100644 --- a/backend/src/services/DriftScanService.ts +++ b/backend/src/services/DriftScanService.ts @@ -24,6 +24,7 @@ const BASE_TICK_MS = 60_000; // re-read settings and check whether a sc const INITIAL_DELAY_MS = 45_000; // let Docker and the node registry settle before the first scan const DEFAULT_INTERVAL_MINUTES = 60; const MIN_INTERVAL_MINUTES = 15; +const MAX_INTERVAL_MINUTES = 1440; // matches the settings-route Zod cap; an over-cap or corrupted DB value falls back to the default rather than deferring scans indefinitely export class DriftScanService { private static instance: DriftScanService; @@ -70,7 +71,7 @@ export class DriftScanService { private intervalMs(): number { try { const raw = Number(DatabaseService.getInstance().getGlobalSettings()['drift_scan_interval_minutes']); - const minutes = Number.isFinite(raw) && raw >= MIN_INTERVAL_MINUTES ? raw : DEFAULT_INTERVAL_MINUTES; + const minutes = Number.isFinite(raw) && raw >= MIN_INTERVAL_MINUTES && raw <= MAX_INTERVAL_MINUTES ? raw : DEFAULT_INTERVAL_MINUTES; return minutes * 60_000; } catch { return DEFAULT_INTERVAL_MINUTES * 60_000;