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.
This commit is contained in:
SaelixCode
2026-06-21 15:50:39 -04:00
parent 1dc4635511
commit 501f96dbe1
3 changed files with 40 additions and 1 deletions
@@ -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<void>(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);
});
});
@@ -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')
+2 -1
View File
@@ -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;