diff --git a/backend/__tests__/vcl-aggregated-burndown.property.test.js b/backend/__tests__/vcl-aggregated-burndown.property.test.js new file mode 100644 index 0000000..fea4fd4 --- /dev/null +++ b/backend/__tests__/vcl-aggregated-burndown.property.test.js @@ -0,0 +1,308 @@ +/** + * Property-Based Tests: VCL Aggregated Burndown + * + * Feature: vcl-aggregated-burndown + * + * Tests the pure helper functions `deduplicateByHostname` and `computeAggregatedBurndown` + * from `backend/helpers/vclHelpers.js`. + * + * Validates: Requirements 1.5, 1.6, 1.7, 2.2, 2.3, 2.4, 2.5, 4.1, 4.2, 4.3, 4.4, 5.1, 5.2, 5.4 + */ + +const fc = require('fast-check'); + +// Mock db pool before importing anything +jest.mock('../db', () => ({ + query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })), + connect: jest.fn(() => Promise.resolve({ + query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })), + release: jest.fn(), + })), +})); + +jest.mock('../helpers/auditLog', () => jest.fn()); +jest.mock('../helpers/ivantiApi', () => ({ + ivantiFormPost: jest.fn(), + ivantiPost: jest.fn(), +})); + +const { + deduplicateByHostname, + computeAggregatedBurndown, +} = require('../helpers/vclHelpers'); + +// --- Generators --- + +const hostnameArb = fc.stringMatching(/^[a-zA-Z0-9._-]+$/, { minLength: 1, maxLength: 20 }); + +const validDateArb = fc.record({ + year: fc.integer({ min: 2020, max: 2030 }), + month: fc.integer({ min: 1, max: 12 }), + day: fc.integer({ min: 1, max: 28 }), +}).map(({ year, month, day }) => + `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` +); + +const verticalCodeArb = fc.constantFrom('NTS_AEO', 'SDIT_CISO', 'TSI', 'SR', 'AllOthers'); + +const deviceArb = fc.record({ + hostname: hostnameArb, + resolution_date: fc.oneof(fc.constant(null), validDateArb), + vertical: verticalCodeArb, +}); + +// Generator for items that may have duplicate hostnames (for deduplication testing) +const duplicateItemsArb = fc.array( + fc.record({ + hostname: fc.constantFrom('srv-001', 'srv-002', 'srv-003', 'srv-004', 'srv-005'), + resolution_date: fc.oneof(fc.constant(null), validDateArb), + vertical: verticalCodeArb, + }), + { minLength: 0, maxLength: 30 } +); + +// --- Property 1: Partition Invariant --- + +describe('Feature: vcl-aggregated-burndown, Property 1: Partition Invariant', () => { + /** + * For any array of device objects passed to computeAggregatedBurndown, + * blockers + with_dates = total. + * + * **Validates: Requirements 2.2** + */ + it('blockers + with_dates = total for any input', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 50 }), + (devices) => { + const result = computeAggregatedBurndown(devices); + expect(result.blockers + result.with_dates).toBe(result.total); + } + ), + { numRuns: 100 } + ); + }); +}); + +// --- Property 2: Monthly Bucket Conservation --- + +describe('Feature: vcl-aggregated-burndown, Property 2: Monthly Bucket Conservation', () => { + /** + * For any array of device objects, the sum of all values in monthly + * must equal with_dates. + * + * **Validates: Requirements 2.3, 1.5** + */ + it('sum of monthly values = with_dates', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 50 }), + (devices) => { + const result = computeAggregatedBurndown(devices); + const monthlySum = Object.values(result.monthly).reduce((s, v) => s + v, 0); + expect(monthlySum).toBe(result.with_dates); + } + ), + { numRuns: 100 } + ); + }); +}); + +// --- Property 3: Chronological Monthly Ordering --- + +describe('Feature: vcl-aggregated-burndown, Property 3: Chronological Monthly Ordering', () => { + /** + * For any array of device objects, the keys of monthly must be in + * ascending chronological order (lexicographic sort of YYYY-MM strings). + * + * **Validates: Requirements 2.4** + */ + it('monthly keys are in ascending chronological order', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 50 }), + (devices) => { + const result = computeAggregatedBurndown(devices); + const keys = Object.keys(result.monthly); + for (let i = 1; i < keys.length; i++) { + expect(keys[i - 1] < keys[i]).toBe(true); + } + } + ), + { numRuns: 100 } + ); + }); +}); + +// --- Property 4: Cumulative Projection Consistency --- + +describe('Feature: vcl-aggregated-burndown, Property 4: Cumulative Projection Consistency', () => { + /** + * For any array of device objects, projection[month].remaining = + * total - (cumulative sum of monthly[m] for all m <= month). + * + * **Validates: Requirements 2.5** + */ + it('projection remaining = total - cumulative remediated', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 50 }), + (devices) => { + const result = computeAggregatedBurndown(devices); + const months = Object.keys(result.monthly); + let cumulative = 0; + for (const month of months) { + cumulative += result.monthly[month]; + expect(result.projection[month].remediated).toBe(result.monthly[month]); + expect(result.projection[month].remaining).toBe(result.total - cumulative); + } + } + ), + { numRuns: 100 } + ); + }); +}); + +// --- Property 5: Projected Clear Date Logic --- + +describe('Feature: vcl-aggregated-burndown, Property 5: Projected Clear Date Logic', () => { + /** + * If blockers > 0, projected_clear_date must be null. + * If blockers = 0 and with_dates > 0, projected_clear_date must equal the last month key. + * + * **Validates: Requirements 1.7** + */ + it('null when blockers > 0, last month key when blockers = 0 and with_dates > 0', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 50 }), + (devices) => { + const result = computeAggregatedBurndown(devices); + if (result.blockers > 0) { + expect(result.projected_clear_date).toBeNull(); + } else if (result.with_dates > 0) { + const months = Object.keys(result.monthly); + expect(result.projected_clear_date).toBe(months[months.length - 1]); + } else { + // total = 0 case + expect(result.projected_clear_date).toBeNull(); + } + } + ), + { numRuns: 100 } + ); + }); +}); + +// --- Property 6: Hostname Deduplication with Earliest Date --- + +describe('Feature: vcl-aggregated-burndown, Property 6: Hostname Deduplication with Earliest Date', () => { + /** + * For any array of items where the same hostname appears multiple times, + * deduplicateByHostname produces exactly one entry per unique hostname, + * and that entry's resolution_date is the earliest non-null date (or null if all null). + * + * **Validates: Requirements 1.6** + */ + it('one entry per hostname with earliest non-null date', () => { + fc.assert( + fc.property( + duplicateItemsArb, + (items) => { + const result = deduplicateByHostname(items); + + // One entry per unique hostname + const uniqueHostnames = new Set(items.map(i => i.hostname)); + expect(result.length).toBe(uniqueHostnames.size); + + // Each result hostname appears exactly once + const resultHostnames = result.map(r => r.hostname); + expect(new Set(resultHostnames).size).toBe(result.length); + + // For each hostname, verify the date is the earliest non-null + for (const entry of result) { + const allForHost = items.filter(i => i.hostname === entry.hostname); + const nonNullDates = allForHost + .map(i => i.resolution_date) + .filter(d => d != null); + + if (nonNullDates.length === 0) { + expect(entry.resolution_date).toBeNull(); + } else { + const earliest = nonNullDates.sort()[0]; + expect(entry.resolution_date).toBe(earliest); + } + } + } + ), + { numRuns: 100 } + ); + }); +}); + +// --- Property 7: Aggregation Consistency with Per-Vertical Computation --- + +describe('Feature: vcl-aggregated-burndown, Property 7: Aggregation Consistency with Per-Vertical Computation', () => { + /** + * Aggregated total = sum of per-vertical totals. + * Aggregated blockers = sum of per-vertical blockers. + * Aggregated with_dates = sum of per-vertical with_dates. + * + * **Validates: Requirements 4.1, 4.2, 4.3, 4.4** + */ + it('aggregated totals = sum of per-vertical totals', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 50 }), + (devices) => { + const result = computeAggregatedBurndown(devices); + + const sumTotal = result.by_vertical.reduce((s, v) => s + v.total, 0); + const sumBlockers = result.by_vertical.reduce((s, v) => s + v.blockers, 0); + const sumWithDates = result.by_vertical.reduce((s, v) => s + v.with_dates, 0); + + expect(sumTotal).toBe(result.total); + expect(sumBlockers).toBe(result.blockers); + expect(sumWithDates).toBe(result.with_dates); + } + ), + { numRuns: 100 } + ); + }); +}); + +// --- Property 8: By-Vertical Sorting and Filtering --- + +describe('Feature: vcl-aggregated-burndown, Property 8: By-Vertical Sorting and Filtering', () => { + /** + * by_vertical is sorted descending by total, contains no zero-total entries, + * and the sum of all by_vertical[i].total equals the overall total. + * + * **Validates: Requirements 5.1, 5.2, 5.4** + */ + it('sorted descending by total, no zero entries, sum = total', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 50 }), + (devices) => { + const result = computeAggregatedBurndown(devices); + + // Sorted descending by total + for (let i = 1; i < result.by_vertical.length; i++) { + expect(result.by_vertical[i - 1].total).toBeGreaterThanOrEqual(result.by_vertical[i].total); + } + + // No zero-total entries + for (const v of result.by_vertical) { + expect(v.total).toBeGreaterThan(0); + } + + // Sum = overall total + const sum = result.by_vertical.reduce((s, v) => s + v.total, 0); + expect(sum).toBe(result.total); + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/backend/__tests__/vcl-aggregated-burndown.test.js b/backend/__tests__/vcl-aggregated-burndown.test.js new file mode 100644 index 0000000..d4a526e --- /dev/null +++ b/backend/__tests__/vcl-aggregated-burndown.test.js @@ -0,0 +1,371 @@ +/** + * Unit and Integration Tests: VCL Aggregated Burndown + * + * Feature: vcl-aggregated-burndown + * + * Tests cover: + * - deduplicateByHostname edge cases + * - computeAggregatedBurndown edge cases + * - GET /burndown endpoint with mocked DB + * - Empty DB returns zero/empty response + * - All-blocker scenario + * - Auth middleware enforcement + */ + +const http = require('http'); +const express = require('express'); + +// Mock auth middleware +jest.mock('../middleware/auth', () => ({ + requireAuth: () => (req, res, next) => { + req.user = { id: 1, username: 'testuser', group: 'Admin' }; + next(); + }, + requireGroup: () => (req, res, next) => next(), +})); + +jest.mock('../helpers/auditLog', () => jest.fn()); +jest.mock('../helpers/ivantiApi', () => ({ + ivantiFormPost: jest.fn(), + ivantiPost: jest.fn(), +})); + +// Mock driftChecker +jest.mock('../helpers/driftChecker', () => ({ + loadConfig: jest.fn(() => ({})), + compareSchemaToDrift: jest.fn(() => null), + reconcileConfig: jest.fn(() => ({ changes: [] })), +})); + +const mockPool = { + query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })), + connect: jest.fn(() => Promise.resolve({ + query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })), + release: jest.fn(), + })), +}; +jest.mock('../db', () => mockPool); + +const { + deduplicateByHostname, + computeAggregatedBurndown, +} = require('../helpers/vclHelpers'); + +const { createVCLMultiVerticalRouter } = require('../routes/vclMultiVertical'); + +// --- HTTP helper --- + +function request(server, method, path, body) { + return new Promise((resolve, reject) => { + const addr = server.address(); + const options = { + hostname: '127.0.0.1', + port: addr.port, + path, + method, + headers: { 'Content-Type': 'application/json' }, + }; + + const req = http.request(options, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const rawBody = Buffer.concat(chunks).toString(); + let json; + try { json = JSON.parse(rawBody); } catch (e) { json = null; } + resolve({ statusCode: res.statusCode, body: json }); + }); + }); + + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +// --- Setup --- + +let app, server; + +beforeAll((done) => { + app = express(); + app.use(express.json()); + + const mockUpload = { array: () => (req, res, next) => next() }; + const router = createVCLMultiVerticalRouter(mockUpload); + app.use('/api/compliance/vcl-multi', router); + + server = app.listen(0, '127.0.0.1', done); +}); + +afterAll((done) => { + server.close(done); +}); + +beforeEach(() => { + mockPool.query.mockReset(); + mockPool.connect.mockReset(); + mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 }); +}); + +// --- deduplicateByHostname unit tests --- + +describe('deduplicateByHostname', () => { + it('returns empty array for empty input', () => { + expect(deduplicateByHostname([])).toEqual([]); + }); + + it('passes through single item unchanged', () => { + const items = [{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }]; + const result = deduplicateByHostname(items); + expect(result).toEqual([{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }]); + }); + + it('deduplicates by hostname keeping earliest non-null date', () => { + const items = [ + { hostname: 'srv-001', resolution_date: '2026-08-15', vertical: 'NTS_AEO' }, + { hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' }, + { hostname: 'srv-001', resolution_date: '2026-07-10', vertical: 'TSI' }, + ]; + const result = deduplicateByHostname(items); + expect(result).toHaveLength(1); + expect(result[0].hostname).toBe('srv-001'); + expect(result[0].resolution_date).toBe('2026-06-01'); + }); + + it('returns null date when all entries for a hostname have null dates', () => { + const items = [ + { hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' }, + { hostname: 'srv-001', resolution_date: null, vertical: 'SDIT_CISO' }, + ]; + const result = deduplicateByHostname(items); + expect(result).toHaveLength(1); + expect(result[0].resolution_date).toBeNull(); + }); + + it('picks earliest non-null date even when some entries are null', () => { + const items = [ + { hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' }, + { hostname: 'srv-001', resolution_date: '2026-09-01', vertical: 'SDIT_CISO' }, + { hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' }, + ]; + const result = deduplicateByHostname(items); + expect(result).toHaveLength(1); + expect(result[0].resolution_date).toBe('2026-06-15'); + }); + + it('preserves vertical from the first entry', () => { + const items = [ + { hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'NTS_AEO' }, + { hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' }, + ]; + const result = deduplicateByHostname(items); + expect(result[0].vertical).toBe('NTS_AEO'); + }); +}); + +// --- computeAggregatedBurndown unit tests --- + +describe('computeAggregatedBurndown', () => { + it('returns zero/empty for empty input', () => { + const result = computeAggregatedBurndown([]); + expect(result.total).toBe(0); + expect(result.blockers).toBe(0); + expect(result.with_dates).toBe(0); + expect(result.monthly).toEqual({}); + expect(result.projection).toEqual({}); + expect(result.projected_clear_date).toBeNull(); + expect(result.by_vertical).toEqual([]); + }); + + it('all blockers — with_dates=0, monthly={}, projected_clear_date=null', () => { + const devices = [ + { hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' }, + { hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' }, + { hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' }, + ]; + const result = computeAggregatedBurndown(devices); + expect(result.total).toBe(3); + expect(result.blockers).toBe(3); + expect(result.with_dates).toBe(0); + expect(result.monthly).toEqual({}); + expect(result.projected_clear_date).toBeNull(); + }); + + it('single device with date — correct monthly bucket and projection', () => { + const devices = [ + { hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }, + ]; + const result = computeAggregatedBurndown(devices); + expect(result.total).toBe(1); + expect(result.blockers).toBe(0); + expect(result.with_dates).toBe(1); + expect(result.monthly).toEqual({ '2026-06': 1 }); + expect(result.projection).toEqual({ '2026-06': { remediated: 1, remaining: 0 } }); + expect(result.projected_clear_date).toBe('2026-06'); + }); + + it('mixed blockers and in-progress — projected_clear_date is null', () => { + const devices = [ + { hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }, + { hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' }, + ]; + const result = computeAggregatedBurndown(devices); + expect(result.total).toBe(2); + expect(result.blockers).toBe(1); + expect(result.with_dates).toBe(1); + expect(result.projected_clear_date).toBeNull(); + }); + + it('multiple months — correct cumulative projection', () => { + const devices = [ + { hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }, + { hostname: 'srv-002', resolution_date: '2026-06-20', vertical: 'NTS_AEO' }, + { hostname: 'srv-003', resolution_date: '2026-07-10', vertical: 'SDIT_CISO' }, + { hostname: 'srv-004', resolution_date: '2026-08-01', vertical: 'TSI' }, + ]; + const result = computeAggregatedBurndown(devices); + expect(result.total).toBe(4); + expect(result.monthly).toEqual({ '2026-06': 2, '2026-07': 1, '2026-08': 1 }); + expect(result.projection['2026-06'].remaining).toBe(2); // 4 - 2 + expect(result.projection['2026-07'].remaining).toBe(1); // 4 - 3 + expect(result.projection['2026-08'].remaining).toBe(0); // 4 - 4 + expect(result.projected_clear_date).toBe('2026-08'); + }); + + it('by_vertical sorted descending by total, omits zero-total verticals', () => { + const devices = [ + { hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' }, + { hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' }, + { hostname: 'srv-003', resolution_date: null, vertical: 'NTS_AEO' }, + { hostname: 'srv-004', resolution_date: '2026-07-01', vertical: 'NTS_AEO' }, + ]; + const result = computeAggregatedBurndown(devices); + expect(result.by_vertical[0].vertical).toBe('NTS_AEO'); + expect(result.by_vertical[0].total).toBe(3); + expect(result.by_vertical[1].vertical).toBe('TSI'); + expect(result.by_vertical[1].total).toBe(1); + }); +}); + +// --- GET /burndown endpoint tests --- + +describe('GET /api/compliance/vcl-multi/burndown', () => { + it('returns zero/empty response when no active devices exist', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown'); + + expect(res.statusCode).toBe(200); + expect(res.body.total_non_compliant).toBe(0); + expect(res.body.blockers).toBe(0); + expect(res.body.with_dates).toBe(0); + expect(res.body.monthly_forecast).toEqual({}); + expect(res.body.projected_clear_date).toBeNull(); + expect(res.body.by_vertical).toEqual([]); + }); + + it('returns correct burndown data with mocked DB rows', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [ + { hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }, + { hostname: 'srv-002', resolution_date: '2026-07-01', vertical: 'NTS_AEO' }, + { hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' }, + { hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'SDIT_CISO' }, // duplicate hostname + ], + }); + + const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown'); + + expect(res.statusCode).toBe(200); + // srv-001 deduplicated: earliest date is 2026-06-15 + expect(res.body.total_non_compliant).toBe(3); // srv-001, srv-002, srv-003 + expect(res.body.blockers).toBe(1); // srv-003 + expect(res.body.with_dates).toBe(2); // srv-001, srv-002 + expect(res.body.monthly_forecast['2026-06']).toBe(1); + expect(res.body.monthly_forecast['2026-07']).toBe(1); + expect(res.body.projected_clear_date).toBeNull(); // blockers > 0 + }); + + it('returns all-blocker response correctly', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [ + { hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' }, + { hostname: 'srv-002', resolution_date: null, vertical: 'SDIT_CISO' }, + ], + }); + + const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown'); + + expect(res.statusCode).toBe(200); + expect(res.body.total_non_compliant).toBe(2); + expect(res.body.blockers).toBe(2); + expect(res.body.with_dates).toBe(0); + expect(res.body.monthly_forecast).toEqual({}); + expect(res.body.projected_clear_date).toBeNull(); + }); + + it('returns 500 on database error', async () => { + mockPool.query.mockRejectedValueOnce(new Error('Connection refused')); + + const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown'); + + expect(res.statusCode).toBe(500); + expect(res.body.error).toBe('Database error'); + }); + + it('response shape matches API contract', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [ + { hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }, + ], + }); + + const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown'); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('total_non_compliant'); + expect(res.body).toHaveProperty('blockers'); + expect(res.body).toHaveProperty('with_dates'); + expect(res.body).toHaveProperty('monthly_forecast'); + expect(res.body).toHaveProperty('projected_clear_date'); + expect(res.body).toHaveProperty('by_vertical'); + expect(Array.isArray(res.body.by_vertical)).toBe(true); + }); +}); + +// --- Auth enforcement test --- + +describe('GET /burndown — auth enforcement', () => { + it('returns 401 when auth middleware rejects', async () => { + // Create a separate app with rejecting auth + const rejectApp = express(); + rejectApp.use(express.json()); + + // Override requireAuth to reject + jest.resetModules(); + jest.doMock('../middleware/auth', () => ({ + requireAuth: () => (req, res, next) => { + res.status(401).json({ error: 'Authentication required' }); + }, + requireGroup: () => (req, res, next) => next(), + })); + + const { createVCLMultiVerticalRouter: createRouter } = require('../routes/vclMultiVertical'); + const mockUpload = { array: () => (req, res, next) => next() }; + const router = createRouter(mockUpload); + rejectApp.use('/api/compliance/vcl-multi', router); + + const rejectServer = await new Promise((resolve) => { + const s = rejectApp.listen(0, '127.0.0.1', () => resolve(s)); + }); + + try { + const res = await request(rejectServer, 'GET', '/api/compliance/vcl-multi/burndown'); + expect(res.statusCode).toBe(401); + expect(res.body.error).toBe('Authentication required'); + } finally { + await new Promise((resolve) => rejectServer.close(resolve)); + } + }); +}); diff --git a/backend/helpers/vclHelpers.js b/backend/helpers/vclHelpers.js index cf3610c..00faa48 100644 --- a/backend/helpers/vclHelpers.js +++ b/backend/helpers/vclHelpers.js @@ -278,6 +278,116 @@ function computeVerticalBurndown(items) { }; } +/** + * Deduplicates devices by hostname, keeping the earliest non-null resolution_date. + * A device appearing in multiple metrics counts once. + * + * @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} items + * @returns {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} + */ +function deduplicateByHostname(items) { + const map = {}; + for (const item of items) { + const key = item.hostname; + if (!map[key]) { + map[key] = { hostname: item.hostname, resolution_date: item.resolution_date || null, vertical: item.vertical }; + } else { + // Keep the earliest non-null resolution_date + const existing = map[key]; + if (item.resolution_date != null) { + if (existing.resolution_date == null || item.resolution_date < existing.resolution_date) { + existing.resolution_date = item.resolution_date; + } + } + } + } + return Object.values(map); +} + +/** + * Computes aggregated burndown from a deduplicated array of device objects. + * Each device has { hostname, resolution_date, vertical }. + * + * @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} devices + * @returns {{ + * total: number, + * blockers: number, + * with_dates: number, + * monthly: Object, + * projection: Object, + * projected_clear_date: string|null, + * by_vertical: Array<{ vertical: string, total: number, blockers: number, with_dates: number }> + * }} + */ +function computeAggregatedBurndown(devices) { + const total = devices.length; + const withDates = devices.filter(d => d.resolution_date != null); + const blockerDevices = devices.filter(d => d.resolution_date == null); + const blockers = blockerDevices.length; + const with_dates = withDates.length; + + // Bucket by month (YYYY-MM) + const monthly = {}; + for (const device of withDates) { + const dateStr = typeof device.resolution_date === 'string' + ? device.resolution_date + : device.resolution_date.toISOString().slice(0, 10); + const month = dateStr.slice(0, 7); + monthly[month] = (monthly[month] || 0) + 1; + } + + // Sort monthly keys chronologically + const sortedMonths = Object.keys(monthly).sort(); + const sortedMonthly = {}; + for (const m of sortedMonths) { + sortedMonthly[m] = monthly[m]; + } + + // Cumulative projection + let remaining = total; + const projection = {}; + for (const month of sortedMonths) { + remaining -= sortedMonthly[month]; + projection[month] = { remediated: sortedMonthly[month], remaining }; + } + + // Projected clear date + let projected_clear_date = null; + if (blockers === 0 && sortedMonths.length > 0) { + projected_clear_date = sortedMonths[sortedMonths.length - 1]; + } + + // Per-vertical breakdown + const verticalMap = {}; + for (const device of devices) { + const v = device.vertical; + if (!verticalMap[v]) { + verticalMap[v] = { vertical: v, total: 0, blockers: 0, with_dates: 0 }; + } + verticalMap[v].total++; + if (device.resolution_date == null) { + verticalMap[v].blockers++; + } else { + verticalMap[v].with_dates++; + } + } + + // Sort descending by total, filter out zero-total entries + const by_vertical = Object.values(verticalMap) + .filter(v => v.total > 0) + .sort((a, b) => b.total - a.total); + + return { + total, + blockers, + with_dates, + monthly: sortedMonthly, + projection, + projected_clear_date, + by_vertical, + }; +} + module.exports = { truncateText, validateRemediationPlan, @@ -292,4 +402,6 @@ module.exports = { mapColumnHeaders, parseVerticalFilename, computeVerticalBurndown, + deduplicateByHostname, + computeAggregatedBurndown, }; diff --git a/backend/routes/vclMultiVertical.js b/backend/routes/vclMultiVertical.js index a513226..1028f10 100644 --- a/backend/routes/vclMultiVertical.js +++ b/backend/routes/vclMultiVertical.js @@ -7,7 +7,7 @@ const fs = require('fs'); const { spawn } = require('child_process'); const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); -const { parseVerticalFilename, computeVerticalBurndown, isValidDateString, categorizeNonCompliant } = require('../helpers/vclHelpers'); +const { parseVerticalFilename, computeVerticalBurndown, isValidDateString, categorizeNonCompliant, deduplicateByHostname, computeAggregatedBurndown } = require('../helpers/vclHelpers'); const logAudit = require('../helpers/auditLog'); const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py'); @@ -1240,6 +1240,54 @@ function createVCLMultiVerticalRouter(upload) { } }); + // ----------------------------------------------------------------------- + // GET /burndown — Aggregated cross-vertical burndown forecast + // ----------------------------------------------------------------------- + + /** + * GET /burndown + * Returns aggregated burndown forecast across all verticals. + * Deduplicates devices by hostname (earliest non-null resolution_date). + * + * @method GET + * @route /burndown + * + * @response 200 + * { + * total_non_compliant: number, + * blockers: number, + * with_dates: number, + * monthly_forecast: Object, + * projected_clear_date: string|null, + * by_vertical: Array<{ vertical: string, total: number, blockers: number, with_dates: number }> + * } + * @response 500 { error: string } + */ + router.get('/burndown', async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT hostname, resolution_date, vertical + FROM compliance_items + WHERE vertical IS NOT NULL AND status = 'active'` + ); + + const devices = deduplicateByHostname(rows); + const burndown = computeAggregatedBurndown(devices); + + res.json({ + total_non_compliant: burndown.total, + blockers: burndown.blockers, + with_dates: burndown.with_dates, + monthly_forecast: burndown.monthly, + projected_clear_date: burndown.projected_clear_date, + by_vertical: burndown.by_vertical, + }); + } catch (err) { + console.error('[VCL Multi] GET /burndown error:', err.message); + res.status(500).json({ error: 'Database error' }); + } + }); + return router; } diff --git a/frontend/src/components/pages/CCPMetricsPage.js b/frontend/src/components/pages/CCPMetricsPage.js index 2c43374..e2ca56f 100644 --- a/frontend/src/components/pages/CCPMetricsPage.js +++ b/frontend/src/components/pages/CCPMetricsPage.js @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3, Settings, Trash2, RotateCcw } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; -import { PieChart, Pie, Cell, ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts'; +import { PieChart, Pie, Cell, ComposedChart, Bar, BarChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts'; import MultiVerticalUploadModal from './MultiVerticalUploadModal'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -227,6 +227,126 @@ function TrendChart({ months }) { ); } +// --------------------------------------------------------------------------- +// Aggregated Burndown Chart +// --------------------------------------------------------------------------- +function AggregatedBurndownChart({ data, loading, error }) { + if (loading) { + return ( +
+ +
Loading...
+
+ ); + } + + if (error) { + return ( +
+
+ Error loading burndown data: {error} +
+
+ ); + } + + if (!data) return null; + + // Empty state: no non-compliant devices + if (data.total_non_compliant === 0) { + return ( +
+
No non-compliant devices across any vertical.
+
+ ); + } + + // All blockers: no monthly forecast + const monthlyKeys = Object.keys(data.monthly_forecast || {}); + const hasMonthlyData = monthlyKeys.length > 0; + + // Prepare chart data + const monthlyData = monthlyKeys + .sort() + .map(month => ({ month, count: data.monthly_forecast[month] })); + + return ( +
+
+ Aggregated Burndown Forecast +
+ + {/* Summary header */} +
+
+
Non-Compliant
+
{data.total_non_compliant.toLocaleString()}
+
+
+
Blockers
+
{data.blockers.toLocaleString()}
+
+
+
In-Progress
+
{data.with_dates.toLocaleString()}
+
+ {data.projected_clear_date && ( +
+
Projected Clear
+
{data.projected_clear_date}
+
+ )} +
+ + {/* Chart or blocker message */} + {hasMonthlyData ? ( + + + + + + + + + + ) : ( +
+ All {data.blockers.toLocaleString()} non-compliant devices lack remediation dates. +
+ )} + + {/* Per-vertical contribution table */} + {data.by_vertical && data.by_vertical.length > 0 && ( +
+
+ By Vertical +
+ + + + + + + + + + + {data.by_vertical.map(v => ( + + + + + + + ))} + +
VerticalTotalBlockersWith Dates
{v.vertical}{v.total.toLocaleString()} 0 ? '#EF4444' : '#64748B', padding: '0.5rem 1rem' }}>{v.blockers.toLocaleString()}{v.with_dates.toLocaleString()}
+
+ )} +
+ ); +} + // --------------------------------------------------------------------------- // Vertical Breakdown Table // --------------------------------------------------------------------------- @@ -888,6 +1008,9 @@ export default function CCPMetricsPage() { const { isAdmin, isEditor } = useAuth(); const [stats, setStats] = useState(null); const [trend, setTrend] = useState(null); + const [burndownData, setBurndownData] = useState(null); + const [burndownLoading, setBurndownLoading] = useState(true); + const [burndownError, setBurndownError] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showUpload, setShowUpload] = useState(false); @@ -903,6 +1026,8 @@ export default function CCPMetricsPage() { const fetchData = useCallback(() => { setLoading(true); setError(null); + setBurndownLoading(true); + setBurndownError(null); Promise.all([ fetch(`${API_BASE}/compliance/vcl-multi/stats`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load stats'); return r.json(); }), fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error('Failed to load trend'); return r.json(); }), @@ -914,6 +1039,12 @@ export default function CCPMetricsPage() { setError(err.message); setLoading(false); }); + + // Fetch burndown independently so a failure doesn't block the rest of the page + fetch(`${API_BASE}/compliance/vcl-multi/burndown`, { credentials: 'include' }) + .then(r => { if (!r.ok) throw new Error('Failed to load burndown'); return r.json(); }) + .then(data => { setBurndownData(data); setBurndownLoading(false); }) + .catch(err => { setBurndownError(err.message); setBurndownLoading(false); }); }, []); useEffect(() => { fetchData(); }, [fetchData]); @@ -1060,6 +1191,13 @@ export default function CCPMetricsPage() { + {/* Aggregated burndown forecast */} + + {/* Vertical breakdown table */}