/** * DC-059: disk-space POST /config threshold-ordering invariant. * * DiskSpaceMonitor._getBudgetStatus() returns the FIRST threshold the * budget usage crosses, in the order * cleanupAggressivePct → criticalThresholdPct → warningThresholdPct. * If a caller writes the three thresholds out of order * (e.g. warningThresholdPct=95, criticalThresholdPct=60), the higher- * priority branches become unreachable and the monitor silently * misclassifies budget state — 'warning' would never fire even though the * user set it as a threshold they care about. * * The fix lives in `routes/disk-space.js`: a `mergeAndCheckOrdering()` * helper validates the *effective* (merged with live baseline) config * against the invariant `warningThresholdPct < criticalThresholdPct < * cleanupAggressivePct` BEFORE the route mutates diskSpaceMonitor.diskConfig. * * Tests cover: * 1. Monotonic ascending order is accepted (happy path). * 2. warningThresholdPct >= criticalThresholdPct is rejected with 400. * 3. criticalThresholdPct >= cleanupAggressivePct is rejected with 400. * 4. Partial updates work one field at a time without violating the * invariant against the current baseline. * 5. Out-of-bounds numeric values are clamped to the same bounds the * original inline Math.min/Math.max chains enforced (50/60/70 → 99). * 6. DiskSpaceMonitor.configure is NEVER called when the request is * rejected (no partial mutation). * 7. The merged config returned to the client is the post-clamp value, * not the raw request body. */ const express = require('express'); const http = require('http'); const DEFAULT_CONFIG = { enabled: true, diskBudgetGB: 10, warningThresholdPct: 80, criticalThresholdPct: 90, autoCleanup: true, cleanupAggressivePct: 95, }; function buildFakeDiskSpaceMonitor(initial = { ...DEFAULT_CONFIG }) { const state = { ...initial }; return { configure: jest.fn((updates) => { Object.assign(state, updates); return { ...state }; }), getConfig: jest.fn(() => ({ ...state })), getSnapshot: jest.fn(async () => ({})), getDetailedBreakdown: jest.fn(async () => ({})), performCleanup: jest.fn(async () => ({})), // Test-only: peek at the internal state to confirm no mutation on rejection _state: state, }; } function buildRouter(monitor) { // Reset module cache so each test starts fresh jest.resetModules(); const mod = require('../../routes/disk-space'); return mod({ diskSpaceMonitor: monitor, asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await try { await fn(req, res, next); } catch (e) { next(e); } }, log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, }); } function buildApp(router) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { next(); }); // strip auth app.use('/', router); // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = err.statusCode || err.status || 500; res.status(status).json({ error: err.message, code: err.code || 'ERR', field: err.field || null, }); }); return app; } function supertestFetch(app) { return function (method, path, body) { return new Promise((resolve, reject) => { const server = app.listen(0, () => { const { port } = server.address(); const data = body ? JSON.stringify(body) : null; const req = http.request({ method, hostname: '127.0.0.1', port, path, headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}, }, (res) => { let chunks = ''; res.on('data', (c) => { chunks += c; }); res.on('end', () => { server.close(); let parsed; try { parsed = JSON.parse(chunks); } catch { parsed = chunks; } resolve({ status: res.statusCode, body: parsed }); }); }); req.on('error', (e) => { server.close(); reject(e); }); if (data) req.write(data); req.end(); }); }); }; } describe('routes/disk-space POST /config (DC-059 threshold ordering)', () => { let monitor, app, fetch; beforeEach(() => { monitor = buildFakeDiskSpaceMonitor(); const router = buildRouter(monitor); app = buildApp(router); fetch = supertestFetch(app); }); test('happy path — strict monotonic ascending order is accepted', async () => { const res = await fetch('POST', '/config', { warningThresholdPct: 75, criticalThresholdPct: 88, cleanupAggressivePct: 95, }); expect(res.status).toBe(200); expect(res.body.config).toEqual(expect.objectContaining({ warningThresholdPct: 75, criticalThresholdPct: 88, cleanupAggressivePct: 95, })); expect(monitor.configure).toHaveBeenCalledTimes(1); }); test('warningThresholdPct >= criticalThresholdPct is rejected with 400', async () => { const res = await fetch('POST', '/config', { warningThresholdPct: 95, criticalThresholdPct: 80, cleanupAggressivePct: 99, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/); expect(res.body.field).toBe('warningThresholdPct'); // Critical invariant: monitor.configure was NEVER called. expect(monitor.configure).not.toHaveBeenCalled(); }); test('criticalThresholdPct >= cleanupAggressivePct is rejected with 400', async () => { const res = await fetch('POST', '/config', { warningThresholdPct: 60, criticalThresholdPct: 95, cleanupAggressivePct: 80, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/criticalThresholdPct.*strictly less than.*cleanupAggressivePct/); expect(res.body.field).toBe('criticalThresholdPct'); expect(monitor.configure).not.toHaveBeenCalled(); }); test('equal thresholds are rejected (strict <, not <=)', async () => { const res = await fetch('POST', '/config', { warningThresholdPct: 80, criticalThresholdPct: 80, cleanupAggressivePct: 90, }); expect(res.status).toBe(400); expect(monitor.configure).not.toHaveBeenCalled(); }); test('partial update — single field accepted against existing baseline', async () => { // Defaults: warning=80, critical=90, aggressive=95. Raise warning to 85. const res = await fetch('POST', '/config', { warningThresholdPct: 85 }); expect(res.status).toBe(200); expect(res.body.config.warningThresholdPct).toBe(85); expect(res.body.config.criticalThresholdPct).toBe(90); expect(res.body.config.cleanupAggressivePct).toBe(95); }); test('partial update — would violate invariant against baseline, rejected', async () => { // Defaults: warning=80, critical=90, aggressive=95. Setting warning=95 // would collide with the existing critical=90 (warning >= critical). const res = await fetch('POST', '/config', { warningThresholdPct: 95 }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/); expect(monitor.configure).not.toHaveBeenCalled(); }); test('partial update — succeeds after baseline was updated in a prior request', async () => { // First request: bump warning from 80 → 85 (within current critical=90). let res = await fetch('POST', '/config', { warningThresholdPct: 85 }); expect(res.status).toBe(200); // Second request: now bump warning from 85 → 89. Still under critical=90. res = await fetch('POST', '/config', { warningThresholdPct: 89 }); expect(res.status).toBe(200); expect(monitor.configure).toHaveBeenCalledTimes(2); }); test('partial update — would violate against the NEW baseline, rejected', async () => { // Step 1: raise warning to 85. let res = await fetch('POST', '/config', { warningThresholdPct: 85 }); expect(res.status).toBe(200); // Step 2: try to raise warning to 95 — would collide with critical=90. res = await fetch('POST', '/config', { warningThresholdPct: 95 }); expect(res.status).toBe(400); // monitor.configure should have run exactly once (the accepted request). expect(monitor.configure).toHaveBeenCalledTimes(1); }); test('out-of-bounds values are clamped to documented ranges', async () => { // Note: the three values must produce a valid monotonic ordering AFTER // clamping. Setting warning=20 (→ 50), critical=200 (→ 99), aggressive=70 // would produce critical=99 > aggressive=70 which is rejected by the // ordering check. Use values that clamp into a valid range. const res = await fetch('POST', '/config', { warningThresholdPct: 20, // below warning min 50 → clamped to 50 criticalThresholdPct: 85, // valid cleanupAggressivePct: 200, // above aggressive max 99 → clamped to 99 }); expect(res.status).toBe(200); expect(res.body.config).toEqual(expect.objectContaining({ warningThresholdPct: 50, criticalThresholdPct: 85, cleanupAggressivePct: 99, })); }); test('non-numeric threshold values are silently dropped (legacy behaviour preserved)', async () => { // Strings are not numbers → unchanged from baseline. Confirms the // ordering check doesn\'t reject legitimate "I didn\'t change this" requests. const res = await fetch('POST', '/config', { warningThresholdPct: '80', }); expect(res.status).toBe(200); expect(res.body.config.warningThresholdPct).toBe(80); // baseline unchanged expect(monitor.configure).toHaveBeenCalledWith({}); // empty updates }); test('diskBudgetGB and autoCleanup updates still work alongside threshold validation', async () => { const res = await fetch('POST', '/config', { diskBudgetGB: 50, autoCleanup: false, warningThresholdPct: 81, }); expect(res.status).toBe(200); expect(res.body.config.diskBudgetGB).toBe(50); expect(res.body.config.autoCleanup).toBe(false); expect(res.body.config.warningThresholdPct).toBe(81); }); test('rejected request does NOT mutate the live diskConfig', async () => { const before = { ...monitor._state }; const res = await fetch('POST', '/config', { warningThresholdPct: 95, // collides with critical=90 }); expect(res.status).toBe(400); expect(monitor._state).toEqual(before); }); test('POST /config with no thresholds in body is a no-op against baseline', async () => { const res = await fetch('POST', '/config', { diskBudgetGB: 25 }); expect(res.status).toBe(200); expect(res.body.config.diskBudgetGB).toBe(25); expect(res.body.config.warningThresholdPct).toBe(80); // unchanged expect(res.body.config.criticalThresholdPct).toBe(90); // unchanged expect(res.body.config.cleanupAggressivePct).toBe(95); // unchanged }); });