/** * Bug Condition Exploration Property Tests: Compliance Duplicate Chart Entries * * Spec: .kiro/specs/compliance-duplicate-chart-entries/ (bugfix) * * BUG CONDITION (from design.md): * EXISTS report_date d WHERE COUNT(compliance_uploads WHERE report_date = d) > 1 * * Five compliance code paths share the root cause "key by `compliance_uploads.id` * instead of by `compliance_uploads.report_date`": * - GET /trends (Property 1, test case 1.A) * - GET /top-recurring (Property 2, test case 1.B) * - GET /category-trend (Property 3, test case 1.C) * - GET /summary (Property 4, test case 1.D) * - persistUpload() snapshots (Property 5, test case 1.E) * * THIS TEST SUITE IS EXPECTED TO FAIL ON UNFIXED CODE. * Failure of these five test cases is the SUCCESS CASE for the exploration — * each failure is a counterexample that confirms the corresponding manifestation * of the bug exists. After the five fixes from design.md are implemented, * these same cases will pass and become regression guards. * * Each case is anchored on the canonical fixture * (`fixture_multi_vertical_single_date` from design.md) AND wrapped in a * fast-check `fc.assert` against `arbScenario` so the property is also * exercised on randomly-generated multi-vertical scenarios with colliding * `report_date`s. * * **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11** */ const http = require('http'); const express = require('express'); const fc = require('fast-check'); // --- Mocks (must be installed BEFORE requiring the route module) --- 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()); // Programmable pg pool: each test installs a query handler that matches the // actual SQL fragments emitted by backend/routes/compliance.js. The default // handler returns an empty rowset. let queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 }); const recordedQueries = []; const mockPool = { query: jest.fn((text, params) => { recordedQueries.push({ text, params, on: 'pool' }); return queryHandler(text, params); }), connect: jest.fn(() => Promise.resolve({ query: jest.fn((text, params) => { recordedQueries.push({ text, params, on: 'client' }); return queryHandler(text, params); }), release: jest.fn(), })), }; jest.mock('../db', () => mockPool); const { createComplianceRouter, persistUpload } = require('../routes/compliance'); // --- HTTP helper --- function request(server, method, urlPath, body) { return new Promise((resolve, reject) => { const addr = server.address(); const options = { hostname: '127.0.0.1', port: addr.port, path: urlPath, 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 raw = Buffer.concat(chunks).toString(); let json; try { json = JSON.parse(raw); } catch { json = null; } resolve({ statusCode: res.statusCode, body: json }); }); }); req.on('error', reject); if (body !== undefined) req.write(JSON.stringify(body)); req.end(); }); } // --- Pool router: dispatch by SQL substring/regex --- /** * Build a query handler from an ordered list of routes. Each route's `match` * is a substring or RegExp tested against the SQL text. The first match wins. * `rows` may be a static array or a function (text, params) => rows. */ function makeQueryHandler(routes) { return (text, params) => { for (const route of routes) { const target = route.match; const hit = target instanceof RegExp ? target.test(text) : text.includes(target); if (hit) { const rows = typeof route.rows === 'function' ? (route.rows(text, params) || []) : (route.rows || []); return Promise.resolve({ rows, rowCount: rows.length }); } } return Promise.resolve({ rows: [], rowCount: 0 }); }; } // --- Fixture builders (per design.md "Test Fixtures Required") --- const TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']; const CATEGORIES = ['Patching', 'Configuration', 'Vulnerability', 'Other']; /** * fixture_multi_vertical_single_date — three uploads sharing 2025-05-11. * Reproduces the original GitLab #12 scenario. * * Each upload has a distinct vertical (NTS_AEO, SDIT_CISO, TSI), distinct * counts, and 6 items spread across two teams (STEAM, ACCESS-ENG) and two * categories (Patching, Configuration). Item layout per upload: * STEAM / Patching x2 * STEAM / Configuration x1 * ACCESS-ENG / Patching x1 * ACCESS-ENG / Configuration x2 * Per-date aggregate (3 uploads): 9 STEAM, 9 ACCESS-ENG, 9 Patching, 9 Configuration. */ function fixtureMultiVerticalSingleDate() { const verticals = ['NTS_AEO', 'SDIT_CISO', 'TSI']; const uploads = verticals.map((v, idx) => ({ id: 300 + idx, report_date: '2025-05-11', vertical: v, new_count: 3 + idx, // 3, 4, 5 sum = 12 recurring_count: 7 + idx * 2, // 7, 9, 11 sum = 27 resolved_count: 1 + idx, // 1, 2, 3 sum = 6 uploaded_at: `2025-05-11T${10 + idx}:00:00Z`, summary_json: JSON.stringify({ entries: [{ team: 'STEAM', metric: 'patching', score: 80 + idx }], overall_scores: { patching: 80 + idx }, }), })); const items = []; let itemId = 2000; const layout = [ { team: 'STEAM', category: 'Patching' }, { team: 'STEAM', category: 'Patching' }, { team: 'STEAM', category: 'Configuration' }, { team: 'ACCESS-ENG', category: 'Patching' }, { team: 'ACCESS-ENG', category: 'Configuration' }, { team: 'ACCESS-ENG', category: 'Configuration' }, ]; for (const u of uploads) { for (const l of layout) { items.push({ id: itemId++, upload_id: u.id, hostname: `${u.vertical}-host-${itemId}`, team: l.team, category: l.category, vertical: u.vertical, status: 'active', }); } } return { uploads, items }; } /** * fixture_cross_vertical_items — two disjoint sets of compliance_items. * NTS_AEO contributes 100 hosts on team STEAM. * SDIT_CISO contributes 50 hosts on team ACCESS-ENG. * Used to exercise the persistUpload() vertical-isolation property (1.E). */ function fixtureCrossVerticalItems() { const items = []; let id = 5000; for (let i = 1; i <= 100; i++) { items.push({ id: id++, hostname: `nts-aeo-host-${i}`, team: 'STEAM', vertical: 'NTS_AEO', status: 'active', }); } for (let i = 1; i <= 50; i++) { items.push({ id: id++, hostname: `sdit-ciso-host-${i}`, team: 'ACCESS-ENG', vertical: 'SDIT_CISO', status: 'active', }); } return items; } // --- fast-check arbitraries (design.md fixture_pbt_generators, restricted to // scenarios where the bug condition holds: at least one report_date has // two or more upload rows). --- const arbReportDate = fc.constantFrom('2025-05-04', '2025-05-11', '2025-05-18'); const arbVertical = fc.constantFrom('NTS_AEO', 'SDIT_CISO', 'TSI', null); const arbUpload = fc.record({ report_date: arbReportDate, vertical: arbVertical, new_count: fc.integer({ min: 0, max: 30 }), recurring_count: fc.integer({ min: 0, max: 30 }), resolved_count: fc.integer({ min: 0, max: 30 }), }); /** * arbScenario — a list of compliance_uploads rows where at least one * report_date appears in two or more rows (i.e., the bug condition holds). * The pre-condition is enforced post-generation via filter() so fast-check * shrinking still finds simple counterexamples. */ const arbScenario = fc.array(arbUpload, { minLength: 2, maxLength: 6 }) .filter(arr => { const counts = {}; for (const u of arr) counts[u.report_date] = (counts[u.report_date] || 0) + 1; return Object.values(counts).some(c => c > 1); }) .map((rawUploads) => rawUploads.map((u, i) => ({ id: 1000 + i, uploaded_at: `${u.report_date}T${10 + i}:00:00Z`, summary_json: JSON.stringify({ entries: [{ team: 'STEAM', metric: 'patching', score: 80 }], overall_scores: { patching: 80 }, }), ...u, }))); /** * arbScenarioWithItems — arbScenario plus a small set of compliance_items * (one to four per upload), used by the /category-trend property test. */ const arbScenarioWithItems = arbScenario.chain(uploads => { const itemArrays = uploads.map(() => fc.array( fc.record({ team: fc.constantFrom(...TEAMS), category: fc.constantFrom(...CATEGORIES), }), { minLength: 1, maxLength: 4 }, )); return fc.tuple(fc.constant(uploads), fc.tuple(...itemArrays)) .map(([ups, perUpload]) => { const items = []; let itemId = 9000; ups.forEach((u, i) => { for (const it of perUpload[i]) { items.push({ id: itemId++, upload_id: u.id, hostname: `host-${itemId}`, vertical: u.vertical, status: 'active', ...it, }); } }); return { uploads: ups, items }; }); }); // --- Shared mock builders for the four read endpoints --- /** * Build a query handler that simulates the database state given a list of * uploads and a list of items. The handler responds to BOTH the unfixed and * fixed shapes of the SQL — the unfixed shape returns one row per upload, * the fixed shape groups by report_date — so the same test code is * meaningful when re-run against fixed code in tasks 3.2 / 4.2 / 5.2. * * The actual SQL fragments are taken verbatim from backend/routes/compliance.js. */ function installReadEndpointHandler(uploads, items) { queryHandler = makeQueryHandler([ // ----- /trends and /top-recurring primary uploads listing ----- // Unfixed: SELECT id, report_date, ..., COALESCE(...) AS total_active FROM compliance_uploads ORDER BY report_date ASC // Fixed: SELECT report_date, SUM(...) ... GROUP BY report_date ORDER BY report_date ASC { match: /FROM\s+compliance_uploads(?!\s+cu\b)[\s\S]*ORDER\s+BY\s+report_date\s+ASC/i, rows: (text) => { if (/GROUP\s+BY\s+report_date/i.test(text)) { // FIXED shape: aggregate by report_date const byDate = {}; for (const u of uploads) { const d = u.report_date; if (!byDate[d]) { byDate[d] = { report_date: d, new_count: 0, recurring_count: 0, resolved_count: 0, total_active: 0, }; } byDate[d].new_count += u.new_count; byDate[d].recurring_count += u.recurring_count; byDate[d].resolved_count += u.resolved_count; byDate[d].total_active += u.new_count + u.recurring_count; } return Object.values(byDate).sort((a, b) => a.report_date.localeCompare(b.report_date), ); } // UNFIXED shape: one row per upload return [...uploads] .sort((a, b) => a.report_date.localeCompare(b.report_date)) .map(u => ({ id: u.id, report_date: u.report_date, new_count: u.new_count, recurring_count: u.recurring_count, resolved_count: u.resolved_count, total_active: u.new_count + u.recurring_count, })); }, }, // ----- /trends per-team item-count query ----- // Unfixed: SELECT ci.upload_id, ci.team, COUNT(ci.id)::int FROM compliance_items ci WHERE ci.team IS NOT NULL GROUP BY ci.upload_id, ci.team // Fixed: SELECT cu.report_date, ci.team, COUNT(...) FROM compliance_items ci JOIN compliance_uploads cu ON ci.upload_id = cu.id WHERE ci.team IS NOT NULL ... GROUP BY cu.report_date, ci.team { match: /FROM\s+compliance_items\s+ci\b[\s\S]*?WHERE\s+ci\.team\s+IS\s+NOT\s+NULL/i, rows: (text) => { if (/GROUP\s+BY\s+cu\.report_date/i.test(text)) { // FIXED shape const grouped = {}; for (const it of items) { if (!it.team) continue; const u = uploads.find(x => x.id === it.upload_id); if (!u || u.report_date == null) continue; const k = `${u.report_date}|${it.team}`; grouped[k] = (grouped[k] || 0) + 1; } return Object.entries(grouped).map(([k, count]) => { const [report_date, team] = k.split('|'); return { report_date, team, count }; }); } // UNFIXED shape: keyed by upload_id const grouped = {}; for (const it of items) { if (!it.team) continue; const k = `${it.upload_id}|${it.team}`; grouped[k] = (grouped[k] || 0) + 1; } return Object.entries(grouped).map(([k, count]) => { const [upload_id, team] = k.split('|'); return { upload_id: Number(upload_id), team, count }; }); }, }, // ----- /category-trend ----- // Unfixed: SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id)::int AS count // FROM compliance_uploads cu JOIN compliance_items ci ON ci.upload_id = cu.id // GROUP BY cu.id, cu.report_date, category ORDER BY cu.report_date ASC // Fixed: ... GROUP BY cu.report_date, COALESCE(ci.category, 'Unknown') ORDER BY ... { match: /FROM\s+compliance_uploads\s+cu\s+JOIN\s+compliance_items\s+ci/i, rows: (text) => { if (/GROUP\s+BY\s+cu\.id/i.test(text)) { // UNFIXED shape: group by (upload_id, date, category) → duplicate rows per date const grouped = {}; for (const it of items) { const u = uploads.find(x => x.id === it.upload_id); if (!u || u.report_date == null) continue; const cat = it.category || 'Unknown'; const k = `${u.id}|${u.report_date}|${cat}`; grouped[k] = (grouped[k] || 0) + 1; } return Object.entries(grouped) .map(([k, count]) => { const [, report_date, category] = k.split('|'); return { report_date, category, count }; }) .sort((a, b) => a.report_date.localeCompare(b.report_date) || a.category.localeCompare(b.category), ); } // FIXED shape: group by (date, category) const grouped = {}; for (const it of items) { const u = uploads.find(x => x.id === it.upload_id); if (!u || u.report_date == null) continue; const cat = it.category || 'Unknown'; const k = `${u.report_date}|${cat}`; grouped[k] = (grouped[k] || 0) + 1; } return Object.entries(grouped) .map(([k, count]) => { const [report_date, category] = k.split('|'); return { report_date, category, count }; }) .sort((a, b) => a.report_date.localeCompare(b.report_date) || a.category.localeCompare(b.category), ); }, }, // ----- /summary primary upload selection ----- // Unfixed: WHERE vertical IS NULL ORDER BY id DESC LIMIT 1 { match: /WHERE\s+vertical\s+IS\s+NULL\s+ORDER\s+BY\s+id\s+DESC\s+LIMIT\s+1/i, rows: () => { const candidates = uploads .filter(u => u.vertical == null && u.summary_json) .sort((a, b) => b.id - a.id); if (candidates.length === 0) return []; const u = candidates[0]; return [{ id: u.id, summary_json: u.summary_json, report_date: u.report_date, uploaded_at: u.uploaded_at, }]; }, }, // Unfixed fallback: WHERE vertical = 'NTS_AEO' ORDER BY id DESC LIMIT 1 { match: /WHERE\s+vertical\s*=\s*'NTS_AEO'\s+ORDER\s+BY\s+id\s+DESC\s+LIMIT\s+1/i, rows: () => { const candidates = uploads .filter(u => u.vertical === 'NTS_AEO' && u.summary_json) .sort((a, b) => b.id - a.id); if (candidates.length === 0) return []; const u = candidates[0]; return [{ id: u.id, summary_json: u.summary_json, report_date: u.report_date, uploaded_at: u.uploaded_at, }]; }, }, // Fixed-side: sibling-disclosure query // SELECT id, vertical, uploaded_at FROM compliance_uploads WHERE report_date = $1 AND id != $2 ORDER BY id ASC { match: /WHERE\s+report_date\s*=\s*\$1\s+AND\s+id\s*!=\s*\$2/i, rows: (_text, params) => { const [reportDate, excludeId] = params || []; return uploads .filter(u => u.report_date === reportDate && u.id !== excludeId) .sort((a, b) => a.id - b.id) .map(u => ({ id: u.id, vertical: u.vertical, uploaded_at: u.uploaded_at })); }, }, ]); } // --- Express server setup --- let app, server; beforeAll((done) => { app = express(); app.use(express.json()); const mockUpload = { single: () => (req, res, next) => next() }; app.use('/api/compliance', createComplianceRouter(mockUpload)); server = app.listen(0, '127.0.0.1', done); }); afterAll((done) => { server.close(done); }); beforeEach(() => { mockPool.query.mockClear(); mockPool.connect.mockClear(); recordedQueries.length = 0; queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 }); }); // ============================================================================= // Test Case 1.A — Property 1: Bug Condition — `/trends` returns one entry // per unique report_date with summed counts and // aggregated per-team totals. // ============================================================================= // // EXPECTED COUNTEREXAMPLE on UNFIXED code: // With three compliance_uploads for 2025-05-11 (NTS_AEO, SDIT_CISO, TSI), // GET /trends returns three entries with report_date='2025-05-11' instead // of one. The handler runs `SELECT id, report_date, ... FROM // compliance_uploads ORDER BY report_date ASC` and `.map()`s each row into // a trend entry; per-team counts are pre-aggregated by upload_id and // looked up by `u.id`, so duplicate-date rows produce duplicate-date // trend entries with split per-team counts. // // **Validates: Requirements 1.1, 1.2, 1.3** // describe('Bug Condition / Property 1 — GET /trends aggregates uploads by report_date', () => { it('1.A canonical fixture — exactly one entry per unique report_date with summed counts and aggregated per-team totals', async () => { const { uploads, items } = fixtureMultiVerticalSingleDate(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/trends'); expect(res.statusCode).toBe(200); // (1) Exactly one entry per unique report_date const targetDate = '2025-05-11'; const matchingTrends = res.body.trends.filter(t => t.report_date === targetDate); expect(matchingTrends).toHaveLength(1); // (2) Counts equal SUM across all uploads sharing that date const targetUploads = uploads.filter(u => u.report_date === targetDate); const expectedNew = targetUploads.reduce((s, u) => s + u.new_count, 0); const expectedRecurring = targetUploads.reduce((s, u) => s + u.recurring_count, 0); const expectedResolved = targetUploads.reduce((s, u) => s + u.resolved_count, 0); const expectedTotalActive = targetUploads.reduce((s, u) => s + u.new_count + u.recurring_count, 0); const trend = matchingTrends[0]; expect(trend.new_count).toBe(expectedNew); expect(trend.recurring_count).toBe(expectedRecurring); expect(trend.resolved_count).toBe(expectedResolved); expect(trend.total_active).toBe(expectedTotalActive); // (3) Per-team counts equal SUM across uploads sharing that date. // Layout per upload: 3 STEAM, 3 ACCESS-ENG. Three uploads share the // date → 9 STEAM, 9 ACCESS-ENG, 0 ACCESS-OPS, 0 INTELDEV. const targetItems = items.filter(it => targetUploads.some(u => u.id === it.upload_id), ); const expectedSTEAM = targetItems.filter(it => it.team === 'STEAM').length; const expectedAccessEng = targetItems.filter(it => it.team === 'ACCESS-ENG').length; const expectedAccessOps = targetItems.filter(it => it.team === 'ACCESS-OPS').length; const expectedIntelDev = targetItems.filter(it => it.team === 'INTELDEV').length; expect(trend.STEAM).toBe(expectedSTEAM); expect(trend['ACCESS-ENG']).toBe(expectedAccessEng); expect(trend['ACCESS-OPS']).toBe(expectedAccessOps); expect(trend.INTELDEV).toBe(expectedIntelDev); }); it('1.A property — GET /trends returns one entry per unique report_date for any multi-vertical scenario', async () => { await fc.assert( fc.asyncProperty(arbScenario, async (uploads) => { installReadEndpointHandler(uploads, []); const res = await request(server, 'GET', '/api/compliance/trends'); expect(res.statusCode).toBe(200); const uniqueDates = new Set(uploads.map(u => u.report_date)); expect(res.body.trends).toHaveLength(uniqueDates.size); // For each unique date, the entry's counts are the SUM // across all uploads sharing that date. for (const date of uniqueDates) { const sharing = uploads.filter(u => u.report_date === date); const entry = res.body.trends.find(t => t.report_date === date); expect(entry).toBeDefined(); expect(entry.new_count).toBe(sharing.reduce((s, u) => s + u.new_count, 0)); expect(entry.recurring_count).toBe(sharing.reduce((s, u) => s + u.recurring_count, 0)); expect(entry.resolved_count).toBe(sharing.reduce((s, u) => s + u.resolved_count, 0)); } }), { numRuns: 25 }, ); }); }); // ============================================================================= // Test Case 1.B — Property 2: Bug Condition — `/top-recurring` waterfall has // one bar per unique report_date with correct // running totals. // ============================================================================= // // EXPECTED COUNTEREXAMPLE on UNFIXED code: // Three uploads for 2025-05-11 produce three waterfall bars labelled // "2025-05-11". Worse, computeWaterfall() carries `start` forward across // the three rows, so the second and third bars' start reflects the prior // row's end inside the same date — the running totals misrepresent the // date-level deltas. The fix aggregates uploads to one row per date // before passing to computeWaterfall(). // // **Validates: Requirements 1.4, 1.5** // describe('Bug Condition / Property 2 — GET /top-recurring has one bar per unique report_date with running invariant', () => { it('1.B canonical fixture — exactly one waterfall entry per unique report_date and running invariant holds', async () => { const { uploads, items } = fixtureMultiVerticalSingleDate(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/top-recurring'); expect(res.statusCode).toBe(200); const wf = res.body.waterfall; // (1) One waterfall entry per unique report_date const dates = wf.map(w => w.date); const uniqueDates = new Set(uploads.map(u => u.report_date)); expect(wf).toHaveLength(uniqueDates.size); expect(new Set(dates).size).toBe(uniqueDates.size); // (2) Running invariant: entry[0].start === 0 AND // entry[i].end === entry[i].start + new_count + recurring_count - resolved_count // entry[i].start === entry[i-1].end (for i >= 1) expect(wf[0].start).toBe(0); for (let i = 0; i < wf.length; i++) { expect(wf[i].end).toBe( wf[i].start + wf[i].new_count + wf[i].recurring_count - wf[i].resolved_count, ); if (i > 0) { expect(wf[i].start).toBe(wf[i - 1].end); } } // (3) For 2025-05-11, the new/recurring/resolved counts equal the // SUM across all uploads sharing that date. const target = wf.find(w => w.date === '2025-05-11'); const sharing = uploads.filter(u => u.report_date === '2025-05-11'); expect(target.new_count).toBe(sharing.reduce((s, u) => s + u.new_count, 0)); expect(target.recurring_count).toBe(sharing.reduce((s, u) => s + u.recurring_count, 0)); expect(target.resolved_count).toBe(sharing.reduce((s, u) => s + u.resolved_count, 0)); }); it('1.B property — waterfall has exactly one entry per unique report_date and the running invariant always holds', async () => { await fc.assert( fc.asyncProperty(arbScenario, async (uploads) => { installReadEndpointHandler(uploads, []); const res = await request(server, 'GET', '/api/compliance/top-recurring'); expect(res.statusCode).toBe(200); const wf = res.body.waterfall; const uniqueDates = new Set(uploads.map(u => u.report_date)); expect(wf).toHaveLength(uniqueDates.size); if (wf.length > 0) { expect(wf[0].start).toBe(0); } for (let i = 0; i < wf.length; i++) { expect(wf[i].end).toBe( wf[i].start + wf[i].new_count + wf[i].recurring_count - wf[i].resolved_count, ); if (i > 0) { expect(wf[i].start).toBe(wf[i - 1].end); } } }), { numRuns: 25 }, ); }); }); // ============================================================================= // Test Case 1.C — Property 3: Bug Condition — `/category-trend` returns one // row per (report_date, category) pair. // ============================================================================= // // EXPECTED COUNTEREXAMPLE on UNFIXED code: // The query `GROUP BY cu.id, cu.report_date, category` keeps `cu.id` in // the grouping, so three uploads for 2025-05-11 each produce their own // (date, category) rows. With items in two categories, the response // contains 3 × 2 = 6 rows for 2025-05-11 instead of 2. // // **Validates: Requirements 1.6, 1.7** // describe('Bug Condition / Property 3 — GET /category-trend returns one row per (date, category)', () => { it('1.C canonical fixture — exactly one row per (report_date, category) and counts equal the SUM across uploads sharing the date', async () => { const { uploads, items } = fixtureMultiVerticalSingleDate(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/category-trend'); expect(res.statusCode).toBe(200); // (1) Exactly one row for ('2025-05-11', 'Patching') const patching = res.body.categoryTrend.filter(c => c.report_date === '2025-05-11' && c.category === 'Patching', ); expect(patching).toHaveLength(1); // (2) That row's count equals the total compliance_items in // 'Patching' across every upload sharing the date. const expectedPatchingCount = items.filter(it => uploads.some(u => u.id === it.upload_id && u.report_date === '2025-05-11') && it.category === 'Patching', ).length; expect(patching[0].count).toBe(expectedPatchingCount); // (3) Same for 'Configuration'. const configuration = res.body.categoryTrend.filter(c => c.report_date === '2025-05-11' && c.category === 'Configuration', ); expect(configuration).toHaveLength(1); const expectedConfigCount = items.filter(it => uploads.some(u => u.id === it.upload_id && u.report_date === '2025-05-11') && it.category === 'Configuration', ).length; expect(configuration[0].count).toBe(expectedConfigCount); }); it('1.C property — for any random multi-vertical scenario, every (date, category) appears at most once', async () => { await fc.assert( fc.asyncProperty(arbScenarioWithItems, async ({ uploads, items }) => { installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/category-trend'); expect(res.statusCode).toBe(200); // (1) Each (date, category) pair appears at most once. const seen = new Set(); for (const row of res.body.categoryTrend) { const key = `${row.report_date}|${row.category}`; expect(seen.has(key)).toBe(false); seen.add(key); } // (2) For every (date, category) pair, the count equals the // SUM of compliance_items in that category across all // uploads sharing the date. for (const row of res.body.categoryTrend) { const expected = items.filter(it => { const u = uploads.find(x => x.id === it.upload_id); return u && u.report_date === row.report_date && (it.category || 'Unknown') === row.category; }).length; expect(row.count).toBe(expected); } }), { numRuns: 25 }, ); }); }); // ============================================================================= // Test Case 1.D — Property 4: Bug Condition — `/summary` does not silently // drop sibling uploads. // ============================================================================= // // EXPECTED COUNTEREXAMPLE on UNFIXED code: // With three uploads for 2025-05-11 (NTS_AEO, SDIT_CISO, TSI), the query // `WHERE vertical IS NULL ORDER BY id DESC LIMIT 1` returns nothing // (none have vertical=null), so the fallback `WHERE vertical = 'NTS_AEO'` // selects only the NTS_AEO upload. The other two verticals' summary_json // is silently dropped — no entries are merged AND no // `multi_vertical_uploads` field exists on the response. // // The fix exposes a `multi_vertical_uploads` array (option (b) per // design.md Fix 4); option (a) would merge entries from every sibling // upload. Either of these resolves the bug. // // **Validates: Requirements 1.8, 1.9** // describe('Bug Condition / Property 4 — GET /summary discloses or merges sibling uploads sharing the latest report_date', () => { it('1.D canonical fixture — response merges sibling entries OR exposes a non-empty multi_vertical_uploads array of length 2', async () => { const { uploads, items } = fixtureMultiVerticalSingleDate(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/summary'); expect(res.statusCode).toBe(200); // The selected primary upload (NTS_AEO via fallback). Sibling uploads // are SDIT_CISO and TSI for the same report_date. const primary = uploads.find(u => u.vertical === 'NTS_AEO'); const expectedSiblings = uploads .filter(u => u.report_date === primary.report_date && u.id !== primary.id) .sort((a, b) => a.id - b.id); const mergedAllEntries = Array.isArray(res.body.entries) && res.body.entries.length === uploads.length; const disclosedSiblings = Array.isArray(res.body.multi_vertical_uploads) && res.body.multi_vertical_uploads.length === expectedSiblings.length; // The bug exists iff neither disclosure mechanism is in place. expect(mergedAllEntries || disclosedSiblings).toBe(true); // If the response exposes multi_vertical_uploads, validate its shape. if (disclosedSiblings) { expect(res.body.multi_vertical_uploads).toHaveLength(2); const ids = res.body.multi_vertical_uploads.map(s => s.id).sort(); expect(ids).toEqual(expectedSiblings.map(s => s.id).sort()); const verticals = res.body.multi_vertical_uploads.map(s => s.vertical).sort(); expect(verticals).toEqual(expectedSiblings.map(s => s.vertical).sort()); } }); it('1.D property — when two or more uploads share the latest report_date, the response merges entries OR discloses every sibling', async () => { await fc.assert( fc.asyncProperty(arbScenario, async (uploads) => { // Need at least one upload to have non-null summary_json AND // be selectable by the existing fallback (vertical IS NULL or // vertical = 'NTS_AEO'). Skip scenarios that do not exercise // the /summary code path. const selectable = uploads.find(u => (u.vertical == null || u.vertical === 'NTS_AEO') && u.summary_json, ); fc.pre(selectable !== undefined); installReadEndpointHandler(uploads, []); const res = await request(server, 'GET', '/api/compliance/summary'); expect(res.statusCode).toBe(200); if (!res.body.upload) { // No primary upload selected → nothing to disclose. return; } const primaryDate = res.body.upload.report_date; const siblings = uploads.filter(u => u.report_date === primaryDate && u.id !== res.body.upload.id, ); if (siblings.length === 0) { // No siblings exist → no disclosure required. return; } const mergedAllEntries = Array.isArray(res.body.entries) && res.body.entries.length >= 1 + siblings.length; const disclosedSiblings = Array.isArray(res.body.multi_vertical_uploads) && res.body.multi_vertical_uploads.length === siblings.length; expect(mergedAllEntries || disclosedSiblings).toBe(true); }), { numRuns: 25 }, ); }); }); // ============================================================================= // Test Case 1.E — Property 5: Bug Condition — `persistUpload()` snapshot // reflects only the snapshotted vertical. // ============================================================================= // // EXPECTED COUNTEREXAMPLE on UNFIXED code: // compliance_items has 100 NTS_AEO hosts on team STEAM and 50 SDIT_CISO // hosts on team ACCESS-ENG. A new SDIT_CISO upload (one item on STEAM, // distinct hostname) is persisted. The snapshot query has no vertical // filter and uses `team AS vertical`, so it produces: // - vertical = 'STEAM' → total_devices = 101 (100 NTS_AEO + 1 new) // - vertical = 'ACCESS-ENG' → total_devices = 50 (all SDIT_CISO) // No row exists for vertical='SDIT_CISO', and the row for STEAM is // contaminated with 100 NTS_AEO hosts. The fix filters by the upload's // vertical (`WHERE vertical IS NOT DISTINCT FROM $1`) and groups by // (vertical, team), producing one snapshot row for SDIT_CISO whose // total_devices reflects only SDIT_CISO hosts. // // **Validates: Requirements 1.10, 1.11** // describe('Bug Condition / Property 5 — persistUpload() snapshot is filtered to the snapshotted vertical', () => { it('1.E canonical fixture — compliance_snapshots row for SDIT_CISO reflects only SDIT_CISO items, not NTS_AEO items', async () => { const items = fixtureCrossVerticalItems(); // 100 NTS_AEO STEAM + 50 SDIT_CISO ACCESS-ENG const incomingItem = { hostname: 'sdit-ciso-new-host-1', ip_address: '10.0.0.1', device_type: 'srv', team: 'STEAM', metric_id: 'M-NEW', metric_desc: 'desc', category: 'Patching', extra_json: {}, }; const incomingVertical = 'SDIT_CISO'; const snapshotInserts = []; // The pool.query handler must respond to: // 1. SELECT id, hostname, metric_id, ... FROM compliance_items WHERE status = 'active' // 2. The snapshot SELECT (unfixed: no vertical filter; fixed: vertical filter) // 3. INSERT INTO compliance_snapshots ... ON CONFLICT ... DO UPDATE // → captured into snapshotInserts queryHandler = makeQueryHandler([ // (1) Initial active-items load { match: /SELECT\s+id,\s+hostname,\s+metric_id,\s+seen_count,\s+first_seen_upload_id\s+FROM\s+compliance_items\s+WHERE\s+status\s*=\s*'active'/i, rows: () => items.map(it => ({ id: it.id, hostname: it.hostname, metric_id: 'M-EXISTING', seen_count: 1, first_seen_upload_id: 1, })), }, // (2a) UNFIXED snapshot query: SELECT team AS vertical ... FROM compliance_items WHERE team IS NOT NULL GROUP BY team { match: /SELECT\s+team\s+AS\s+vertical[\s\S]*FROM\s+compliance_items[\s\S]*WHERE\s+team\s+IS\s+NOT\s+NULL[\s\S]*GROUP\s+BY\s+team/i, rows: () => { // Aggregate ALL items by team (regardless of vertical) — bug condition. const byTeam = {}; const allHosts = items.concat([{ hostname: incomingItem.hostname, team: incomingItem.team, vertical: incomingVertical, status: 'active', }]); for (const it of allHosts) { if (!it.team) continue; if (!byTeam[it.team]) { byTeam[it.team] = { vertical: it.team, hosts: new Set(), compliant: new Set(), nonCompliant: new Set() }; } byTeam[it.team].hosts.add(it.hostname); if (it.status === 'resolved') byTeam[it.team].compliant.add(it.hostname); if (it.status === 'active') byTeam[it.team].nonCompliant.add(it.hostname); } return Object.values(byTeam).map(b => ({ vertical: b.vertical, total_devices: b.hosts.size, compliant: b.compliant.size, non_compliant: b.nonCompliant.size, })); }, }, // (2b) FIXED snapshot query: ... WHERE team IS NOT NULL AND vertical IS NOT DISTINCT FROM $1 GROUP BY vertical, team { match: /WHERE\s+team\s+IS\s+NOT\s+NULL\s+AND\s+vertical\s+IS\s+NOT\s+DISTINCT\s+FROM/i, rows: (_text, params) => { const filterVertical = (params || [])[0]; const allHosts = items.concat([{ hostname: incomingItem.hostname, team: incomingItem.team, vertical: incomingVertical, status: 'active', }]); const filtered = allHosts.filter(it => { if (filterVertical == null) return it.vertical == null; return it.vertical === filterVertical; }); const byKey = {}; for (const it of filtered) { if (!it.team) continue; const k = `${it.vertical}|${it.team}`; if (!byKey[k]) { byKey[k] = { vertical: it.vertical, team: it.team, hosts: new Set(), compliant: new Set(), nonCompliant: new Set(), }; } byKey[k].hosts.add(it.hostname); if (it.status === 'resolved') byKey[k].compliant.add(it.hostname); if (it.status === 'active') byKey[k].nonCompliant.add(it.hostname); } return Object.values(byKey).map(b => ({ vertical: b.vertical, team: b.team, total_devices: b.hosts.size, compliant: b.compliant.size, non_compliant: b.nonCompliant.size, })); }, }, // (3) Snapshot upsert — capture every insert { match: /INSERT\s+INTO\s+compliance_snapshots/i, rows: (_text, params) => { const [snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct] = params || []; snapshotInserts.push({ snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct }); return []; }, }, ]); // The transactional client is also routed through queryHandler; it // must answer the within-transaction queries (BEGIN/COMMIT, INSERT // INTO compliance_uploads RETURNING id, item upserts, the resolved // updates, and the final upload counts UPDATE). const client = { query: jest.fn((text, params) => { if (/^\s*BEGIN/i.test(text) || /^\s*COMMIT/i.test(text) || /^\s*ROLLBACK/i.test(text)) { return Promise.resolve({ rows: [], rowCount: 0 }); } if (/INSERT\s+INTO\s+compliance_uploads[\s\S]*RETURNING\s+id/i.test(text)) { return Promise.resolve({ rows: [{ id: 9999 }], rowCount: 1 }); } // All other within-transaction writes succeed silently. return Promise.resolve({ rows: [], rowCount: 1 }); }), release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(client); await persistUpload({ items: [incomingItem], summary: { entries: [], overall_scores: {} }, reportDate: '2025-05-11', filename: 'sdit-ciso-2025-05-11.xlsx', userId: 1, vertical: incomingVertical, }); // (1) A snapshot row was written for vertical = 'SDIT_CISO'. const sditSnapshots = snapshotInserts.filter(s => s.vertical === 'SDIT_CISO'); expect(sditSnapshots.length).toBeGreaterThan(0); // (2) Its total_devices reflects only SDIT_CISO items. // Pre-existing SDIT_CISO hosts: 50 (on ACCESS-ENG team). // Plus the one incoming SDIT_CISO host on STEAM. // If the snapshot is grouped per (vertical, team), we expect // either one row totalling 51 hosts or two rows that together // total 51. Either way, total_devices for SDIT_CISO must NOT // equal 151 (the inflated cross-vertical figure that includes // the 100 NTS_AEO hosts). const sditTotal = sditSnapshots.reduce((s, r) => s + r.total_devices, 0); const ntsAeoHostCount = items.filter(it => it.vertical === 'NTS_AEO').length; expect(sditTotal).toBeLessThan(ntsAeoHostCount + 1); expect(sditTotal).toBe(50 + 1); // (3) No snapshot row inflates total_devices with NTS_AEO hosts. // The unfixed code emits vertical='STEAM' with total=101 // (100 NTS_AEO + 1 new SDIT_CISO host). The fix emits per-vertical // rows, so any STEAM row must reflect only items whose vertical // is the snapshotted vertical. const steamSnapshots = snapshotInserts.filter(s => s.vertical === 'STEAM'); for (const s of steamSnapshots) { // Under the fix, a snapshot row keyed on vertical='STEAM' should // not exist at all (the upload's vertical is SDIT_CISO). Even if // legacy code paths still write a STEAM row, it must not include // the 100 NTS_AEO hosts as a single combined total. expect(s.total_devices).toBeLessThan(ntsAeoHostCount); } }); }); // ============================================================================= // PRESERVATION TESTS (Task 2 — Property 2) // ============================================================================= // // These tests pin the BASELINE behavior of the unfixed code on inputs where // the bug condition does NOT hold (single-upload-per-date scenarios, empty // state, error paths, and unrelated query-parameter filtering). They MUST // pass on the unfixed code; they will continue to pass after the five fixes // land in tasks 3 through 7. Any future change that alters these responses // in the non-bug-condition input space is a regression. // // Bug Condition negation (from design.md): // FORALL report_date d, COUNT(compliance_uploads WHERE report_date = d) <= 1 // // **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10** // // --- Preservation fixtures (per design.md "Test Fixtures Required") --- /** * fixture_empty — no compliance_uploads, no compliance_items. * Used by 2.A. */ function fixtureEmpty() { return { uploads: [], items: [] }; } /** * fixture_single_upload_aeo_legacy — one legacy AEO upload (vertical IS NULL) * dated 2025-04-01 with 20 items distributed across the four ALLOWED_TEAMS. * Items are evenly tagged across two categories (Patching, Configuration). * Used by 2.B. */ function fixtureSingleUploadAeoLegacy() { const upload = { id: 100, report_date: '2025-04-01', vertical: null, new_count: 12, recurring_count: 8, resolved_count: 3, uploaded_at: '2025-04-01T09:00:00Z', summary_json: JSON.stringify({ entries: [ { team: 'STEAM', metric: 'patching', score: 82 }, { team: 'ACCESS-ENG', metric: 'patching', score: 76 }, { team: 'ACCESS-OPS', metric: 'configuration', score: 88 }, { team: 'INTELDEV', metric: 'configuration', score: 91 }, ], overall_scores: { patching: 79, configuration: 90 }, }), }; const teamLayout = ['STEAM', 'STEAM', 'STEAM', 'STEAM', 'STEAM', 'ACCESS-ENG', 'ACCESS-ENG', 'ACCESS-ENG', 'ACCESS-ENG', 'ACCESS-ENG', 'ACCESS-OPS', 'ACCESS-OPS', 'ACCESS-OPS', 'ACCESS-OPS', 'ACCESS-OPS', 'INTELDEV', 'INTELDEV', 'INTELDEV', 'INTELDEV', 'INTELDEV']; const items = teamLayout.map((team, i) => ({ id: 4000 + i, upload_id: upload.id, hostname: `aeo-host-${i + 1}`, team, category: i % 2 === 0 ? 'Patching' : 'Configuration', vertical: null, status: 'active', })); return { uploads: [upload], items }; } /** * fixture_single_upload_per_date — five uploads on five distinct dates with * varied vertical values, satisfying the bug-condition negation * (every report_date has exactly one upload row). 4–6 items per upload. * Used by 2.C. */ function fixtureSingleUploadPerDate() { const spec = [ { id: 200, date: '2025-04-01', vertical: null, new: 5, rec: 3, res: 1 }, { id: 201, date: '2025-04-08', vertical: 'NTS_AEO', new: 7, rec: 2, res: 4 }, { id: 202, date: '2025-04-15', vertical: 'SDIT_CISO', new: 4, rec: 6, res: 2 }, { id: 203, date: '2025-04-22', vertical: 'TSI', new: 9, rec: 1, res: 0 }, { id: 204, date: '2025-05-01', vertical: null, new: 6, rec: 4, res: 5 }, ]; const uploads = spec.map(s => ({ id: s.id, report_date: s.date, vertical: s.vertical, new_count: s.new, recurring_count: s.rec, resolved_count: s.res, uploaded_at: `${s.date}T08:00:00Z`, summary_json: JSON.stringify({ entries: [{ team: 'STEAM', metric: 'patching', score: 80 + (s.id % 10) }], overall_scores: { patching: 80 + (s.id % 10) }, }), })); const items = []; let itemId = 6000; for (const u of uploads) { const layout = [ { team: 'STEAM', category: 'Patching' }, { team: 'STEAM', category: 'Configuration' }, { team: 'ACCESS-ENG', category: 'Patching' }, { team: 'ACCESS-OPS', category: 'Configuration' }, { team: 'INTELDEV', category: 'Patching' }, ]; for (const l of layout) { items.push({ id: itemId++, upload_id: u.id, hostname: `${u.vertical || 'AEO'}-host-${itemId}`, team: l.team, category: l.category, vertical: u.vertical, status: 'active', }); } } return { uploads, items }; } /** * fixture_cross_vertical_items_single — only NTS_AEO items present in * compliance_items (no SDIT_CISO/TSI items). Used by 2.E to exercise * the persistUpload() single-vertical-month preservation path. */ function fixtureSingleVerticalItems() { const items = []; let id = 7000; for (let i = 1; i <= 30; i++) { items.push({ id: id++, hostname: `nts-aeo-only-host-${i}`, team: i % 2 === 0 ? 'STEAM' : 'ACCESS-ENG', vertical: 'NTS_AEO', status: i % 5 === 0 ? 'resolved' : 'active', }); } return items; } // --- arbScenario_singleUploadPerDate — fast-check generator restricted to // scenarios where every report_date has exactly one upload row. --- const arbUniqueDate = fc.constantFrom( '2025-03-04', '2025-03-11', '2025-03-18', '2025-03-25', '2025-04-01', '2025-04-08', '2025-04-15', '2025-04-22', '2025-05-01', '2025-05-18', '2025-05-25', ); const arbUploadUnique = fc.record({ report_date: arbUniqueDate, vertical: arbVertical, new_count: fc.integer({ min: 0, max: 30 }), recurring_count: fc.integer({ min: 0, max: 30 }), resolved_count: fc.integer({ min: 0, max: 30 }), }); /** * arbScenarioSingleUploadPerDate — list of compliance_uploads rows where * every report_date appears at most once. Enforced post-generation via a * filter; fast-check shrinking still finds simple counterexamples. */ const arbScenarioSingleUploadPerDate = fc.array(arbUploadUnique, { minLength: 0, maxLength: 6 }) .filter(arr => { const dates = arr.map(u => u.report_date); return new Set(dates).size === dates.length; }) .map((rawUploads) => rawUploads.map((u, i) => ({ id: 8000 + i, uploaded_at: `${u.report_date}T${10 + i}:00:00Z`, summary_json: JSON.stringify({ entries: [{ team: 'STEAM', metric: 'patching', score: 80 }], overall_scores: { patching: 80 }, }), ...u, }))); // ============================================================================= // Test Case 2.A — Empty-state preservation // ============================================================================= // // With no compliance_uploads and no compliance_items, every read endpoint // SHALL return its documented empty-state shape unchanged. // // **Validates: Requirements 3.3, 3.10** // describe('Preservation 2.A — empty-state response shapes are unchanged', () => { it('GET /trends returns { trends: [] } when no uploads exist', async () => { const { uploads, items } = fixtureEmpty(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/trends'); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ trends: [] }); }); it('GET /top-recurring returns { waterfall: [] } when no uploads exist', async () => { const { uploads, items } = fixtureEmpty(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/top-recurring'); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ waterfall: [] }); }); it('GET /category-trend returns { categoryTrend: [] } when no uploads exist', async () => { const { uploads, items } = fixtureEmpty(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/category-trend'); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ categoryTrend: [] }); }); it('GET /summary returns { entries: [], overall_scores: {}, upload: null } when no uploads exist', async () => { const { uploads, items } = fixtureEmpty(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/summary'); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ entries: [], overall_scores: {}, upload: null }); }); }); // ============================================================================= // Test Case 2.B — Single AEO-legacy-upload preservation // ============================================================================= // // One legacy upload with vertical IS NULL. The four read endpoints SHALL // produce responses that match the captured baseline byte-for-byte. // This is the "snapshot equality — single AEO-only upload" case from // design.md "Preservation Checking → Test Cases". // // **Validates: Requirements 3.1, 3.2, 3.4, 3.5, 3.6** // describe('Preservation 2.B — single AEO-legacy upload responses are byte-for-byte stable', () => { it('GET /trends returns one entry for the legacy upload with full per-team breakdown', async () => { const { uploads, items } = fixtureSingleUploadAeoLegacy(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/trends'); expect(res.statusCode).toBe(200); // The legacy fixture has 5 items per team across 4 teams = 20 items. // Per-team breakdown is captured exactly so any future change that // alters this aggregation surfaces as a regression. expect(res.body).toEqual({ trends: [ { report_date: '2025-04-01', new_count: 12, recurring_count: 8, resolved_count: 3, total_active: 20, STEAM: 5, 'ACCESS-ENG': 5, 'ACCESS-OPS': 5, INTELDEV: 5, }, ], }); }); it('GET /top-recurring returns a single waterfall entry with start=0 and correct end', async () => { const { uploads, items } = fixtureSingleUploadAeoLegacy(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/top-recurring'); expect(res.statusCode).toBe(200); // For a single legacy upload, the running-total invariant collapses // to start=0, end = new + recurring - resolved = 12 + 8 - 3 = 17. expect(res.body).toEqual({ waterfall: [ { date: '2025-04-01', start: 0, new_count: 12, recurring_count: 8, resolved_count: 3, end: 17, }, ], }); }); it('GET /category-trend returns one row per category with the legacy upload counts', async () => { const { uploads, items } = fixtureSingleUploadAeoLegacy(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/category-trend'); expect(res.statusCode).toBe(200); // 20 items total split across two categories: items at even indexes // are 'Patching' (10 items), odd indexes are 'Configuration' (10 items). expect(res.body).toEqual({ categoryTrend: [ { report_date: '2025-04-01', category: 'Configuration', count: 10 }, { report_date: '2025-04-01', category: 'Patching', count: 10 }, ], }); }); it('GET /summary returns the legacy upload`s entries and overall_scores via the vertical IS NULL fallback', async () => { const { uploads, items } = fixtureSingleUploadAeoLegacy(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/summary'); expect(res.statusCode).toBe(200); // The vertical IS NULL → vertical = 'NTS_AEO' fallback selects the // legacy upload directly (vertical IS NULL succeeds first). The // response surfaces summary.entries and summary.overall_scores // unchanged plus a stub upload reference. expect(res.body).toEqual({ entries: [ { team: 'STEAM', metric: 'patching', score: 82 }, { team: 'ACCESS-ENG', metric: 'patching', score: 76 }, { team: 'ACCESS-OPS', metric: 'configuration', score: 88 }, { team: 'INTELDEV', metric: 'configuration', score: 91 }, ], overall_scores: { patching: 79, configuration: 90 }, upload: { id: 100, report_date: '2025-04-01', uploaded_at: '2025-04-01T09:00:00Z', }, }); }); }); // ============================================================================= // Test Case 2.C — Multiple single-upload-per-date preservation // ============================================================================= // // Five distinct dates, one upload per date, varied vertical values. Every // read endpoint SHALL produce results identical to the captured baseline. // Bug condition negation holds (no two uploads share a report_date). // // **Validates: Requirements 3.1, 3.4, 3.5** // describe('Preservation 2.C — multiple single-upload-per-date responses are byte-for-byte stable', () => { it('GET /trends returns one entry per date, ordered by report_date ASC, with correct per-team breakdowns', async () => { const { uploads, items } = fixtureSingleUploadPerDate(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/trends'); expect(res.statusCode).toBe(200); // Each upload has 5 items in the layout (2 STEAM, 1 ACCESS-ENG, // 1 ACCESS-OPS, 1 INTELDEV). With one upload per date, every date // produces an identical per-team breakdown. const expectedTrends = uploads.map(u => ({ report_date: u.report_date, new_count: u.new_count, recurring_count: u.recurring_count, resolved_count: u.resolved_count, total_active: u.new_count + u.recurring_count, STEAM: 2, 'ACCESS-ENG': 1, 'ACCESS-OPS': 1, INTELDEV: 1, })); expect(res.body).toEqual({ trends: expectedTrends }); }); it('GET /top-recurring emits one waterfall entry per date with start carrying forward from previous end', async () => { const { uploads, items } = fixtureSingleUploadPerDate(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/top-recurring'); expect(res.statusCode).toBe(200); // Build the expected waterfall by walking uploads in date order and // carrying `start` forward. Single-upload-per-date is the canonical // case computeWaterfall() was designed for; the running-total // semantics MUST match the pre-fix output exactly. let start = 0; const expectedWaterfall = uploads.map(u => { const end = start + u.new_count + u.recurring_count - u.resolved_count; const entry = { date: u.report_date, start, new_count: u.new_count, recurring_count: u.recurring_count, resolved_count: u.resolved_count, end, }; start = end; return entry; }); expect(res.body).toEqual({ waterfall: expectedWaterfall }); }); it('GET /category-trend returns one row per (date, category), ordered by date then category', async () => { const { uploads, items } = fixtureSingleUploadPerDate(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/category-trend'); expect(res.statusCode).toBe(200); // Each upload has 3 items in 'Patching' (indexes 0, 2, 4) and // 2 items in 'Configuration' (indexes 1, 3). Single-upload-per-date // means each (date, category) pair appears exactly once. const expected = []; for (const u of uploads) { expected.push({ report_date: u.report_date, category: 'Configuration', count: 2 }); expected.push({ report_date: u.report_date, category: 'Patching', count: 3 }); } expect(res.body).toEqual({ categoryTrend: expected }); }); it('GET /summary surfaces the latest legacy/NTS_AEO upload via the existing fallback', async () => { const { uploads, items } = fixtureSingleUploadPerDate(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/summary'); expect(res.statusCode).toBe(200); // The fallback prefers vertical IS NULL with the highest id. In this // fixture, the highest-id upload with vertical IS NULL is id=204 // (date 2025-05-01). const primary = uploads.find(u => u.id === 204); expect(res.body).toEqual({ entries: [{ team: 'STEAM', metric: 'patching', score: 80 + (204 % 10) }], overall_scores: { patching: 80 + (204 % 10) }, upload: { id: primary.id, report_date: primary.report_date, uploaded_at: primary.uploaded_at, }, }); }); }); // ============================================================================= // Test Case 2.D — /summary `team` query parameter preservation // ============================================================================= // // The `team` query parameter still filters `entries` server-side and still // rejects non-ALLOWED_TEAMS values with HTTP 400. // // **Validates: Requirement 3.7** // describe('Preservation 2.D — GET /summary `team` query parameter is unchanged', () => { it('?team=STEAM filters entries to STEAM only', async () => { const { uploads, items } = fixtureSingleUploadAeoLegacy(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/summary?team=STEAM'); expect(res.statusCode).toBe(200); expect(res.body.entries).toEqual([ { team: 'STEAM', metric: 'patching', score: 82 }, ]); // The unrelated overall_scores and upload fields remain unchanged // when filtering by team. expect(res.body.overall_scores).toEqual({ patching: 79, configuration: 90 }); expect(res.body.upload).toEqual({ id: 100, report_date: '2025-04-01', uploaded_at: '2025-04-01T09:00:00Z', }); }); it('?team=OTHER (not in ALLOWED_TEAMS) returns HTTP 400 with { error }', async () => { const { uploads, items } = fixtureSingleUploadAeoLegacy(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/summary?team=OTHER'); expect(res.statusCode).toBe(400); expect(res.body).toEqual({ error: 'Invalid team' }); }); it('?team=ACCESS-ENG filters entries to ACCESS-ENG only', async () => { const { uploads, items } = fixtureSingleUploadAeoLegacy(); installReadEndpointHandler(uploads, items); const res = await request(server, 'GET', '/api/compliance/summary?team=ACCESS-ENG'); expect(res.statusCode).toBe(200); expect(res.body.entries).toEqual([ { team: 'ACCESS-ENG', metric: 'patching', score: 76 }, ]); }); }); // ============================================================================= // Test Case 2.E — persistUpload() single-vertical-month preservation // ============================================================================= // // When compliance_items contains rows from only one vertical, the snapshot // rows written by persistUpload() SHALL be identical to the pre-fix output. // This is the single-vertical-month equivalent of the fixed-code expectation // that snapshots reflect only the snapshotted vertical. // // **Validates: Requirement 3.8** // describe('Preservation 2.E — persistUpload() snapshot is unchanged for single-vertical months', () => { it('snapshot rows reflect the single-vertical compliance_items state with no cross-contamination', async () => { const items = fixtureSingleVerticalItems(); // only NTS_AEO items const incomingItem = { hostname: 'nts-aeo-new-host-1', ip_address: '10.0.0.1', device_type: 'srv', team: 'STEAM', metric_id: 'M-NEW', metric_desc: 'desc', category: 'Patching', extra_json: {}, }; const incomingVertical = 'NTS_AEO'; const snapshotInserts = []; queryHandler = makeQueryHandler([ // (1) Initial active-items load { match: /SELECT\s+id,\s+hostname,\s+metric_id,\s+seen_count,\s+first_seen_upload_id\s+FROM\s+compliance_items\s+WHERE\s+status\s*=\s*'active'/i, rows: () => items .filter(it => it.status === 'active') .map(it => ({ id: it.id, hostname: it.hostname, metric_id: 'M-EXISTING', seen_count: 1, first_seen_upload_id: 1, })), }, // (2a) UNFIXED snapshot query: SELECT team AS vertical ... GROUP BY team { match: /SELECT\s+team\s+AS\s+vertical[\s\S]*FROM\s+compliance_items[\s\S]*WHERE\s+team\s+IS\s+NOT\s+NULL[\s\S]*GROUP\s+BY\s+team/i, rows: () => { // Single-vertical month: aggregating ALL items by team // gives the same per-vertical answer as filtering by // the snapshotted vertical, because no other verticals // contribute. This is the preservation case. const allHosts = items.concat([{ hostname: incomingItem.hostname, team: incomingItem.team, vertical: incomingVertical, status: 'active', }]); const byTeam = {}; for (const it of allHosts) { if (!it.team) continue; if (!byTeam[it.team]) { byTeam[it.team] = { vertical: it.team, hosts: new Set(), compliant: new Set(), nonCompliant: new Set() }; } byTeam[it.team].hosts.add(it.hostname); if (it.status === 'resolved') byTeam[it.team].compliant.add(it.hostname); if (it.status === 'active') byTeam[it.team].nonCompliant.add(it.hostname); } return Object.values(byTeam).map(b => ({ vertical: b.vertical, total_devices: b.hosts.size, compliant: b.compliant.size, non_compliant: b.nonCompliant.size, })); }, }, // (2b) FIXED snapshot query: ... WHERE team IS NOT NULL AND vertical IS NOT DISTINCT FROM $1 GROUP BY vertical, team // // The /commit route calls persistUpload() without a vertical // argument (legacy AEO uploads default to vertical = null), so // the fixed SQL filters items via `vertical IS NOT DISTINCT FROM // null`. For a single-vertical month, this is equivalent to // aggregating by team alone — every contributing item shares the // same (null) vertical bucket from the upload's perspective. // // Returning rows with `vertical: null, team: ` makes the // production INSERT loop's `vs.vertical || vs.team` fallback // resolve to the team name, which preserves the historical // team-as-vertical snapshot key for legacy AEO upload contexts. { match: /WHERE\s+team\s+IS\s+NOT\s+NULL\s+AND\s+vertical\s+IS\s+NOT\s+DISTINCT\s+FROM/i, rows: () => { const allHosts = items.concat([{ hostname: incomingItem.hostname, team: incomingItem.team, vertical: incomingVertical, status: 'active', }]); const byTeam = {}; for (const it of allHosts) { if (!it.team) continue; if (!byTeam[it.team]) { byTeam[it.team] = { team: it.team, hosts: new Set(), compliant: new Set(), nonCompliant: new Set(), }; } byTeam[it.team].hosts.add(it.hostname); if (it.status === 'resolved') byTeam[it.team].compliant.add(it.hostname); if (it.status === 'active') byTeam[it.team].nonCompliant.add(it.hostname); } return Object.values(byTeam).map(b => ({ vertical: null, team: b.team, total_devices: b.hosts.size, compliant: b.compliant.size, non_compliant: b.nonCompliant.size, })); }, }, // (3) Snapshot upsert — capture every insert { match: /INSERT\s+INTO\s+compliance_snapshots/i, rows: (_text, params) => { const [snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct] = params || []; snapshotInserts.push({ snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct }); return []; }, }, ]); const client = { query: jest.fn((text, _params) => { if (/^\s*BEGIN/i.test(text) || /^\s*COMMIT/i.test(text) || /^\s*ROLLBACK/i.test(text)) { return Promise.resolve({ rows: [], rowCount: 0 }); } if (/INSERT\s+INTO\s+compliance_uploads[\s\S]*RETURNING\s+id/i.test(text)) { return Promise.resolve({ rows: [{ id: 9999 }], rowCount: 1 }); } return Promise.resolve({ rows: [], rowCount: 1 }); }), release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(client); const result = await persistUpload({ items: [incomingItem], summary: { entries: [], overall_scores: {} }, reportDate: '2025-04-01', filename: 'nts-aeo-only-2025-04-01.xlsx', userId: 1, }); // (1) Upload commit succeeded with the expected uploadId. expect(result.uploadId).toBe(9999); // (2) Snapshot rows were written. With only NTS_AEO items present, // the per-team aggregation gives: // - team='STEAM' → all 15 NTS_AEO STEAM hosts (even ids // in the fixture, indexes 2..30 = 15 hosts) plus the new // incoming STEAM host = 16 total. // - team='ACCESS-ENG' → 15 NTS_AEO ACCESS-ENG hosts. const steamSnap = snapshotInserts.find(s => s.vertical === 'STEAM'); const accessSnap = snapshotInserts.find(s => s.vertical === 'ACCESS-ENG'); expect(steamSnap).toBeDefined(); expect(accessSnap).toBeDefined(); // STEAM bucket: NTS_AEO indexes 2,4,...,30 = 15 hosts + the new one. expect(steamSnap.total_devices).toBe(16); // ACCESS-ENG bucket: NTS_AEO indexes 1,3,...,29 = 15 hosts. expect(accessSnap.total_devices).toBe(15); // (3) compliance_pct is consistent with the captured baseline: // resolved hosts are at indexes divisible by 5 (5,10,15,20,25,30). // STEAM: 10, 20, 30 → 3 resolved out of 16 → 18.75%. // ACCESS-ENG: 5, 15, 25 → 3 resolved out of 15 → 20%. expect(steamSnap.compliant).toBe(3); expect(steamSnap.non_compliant).toBe(13); // 16 - 3 resolved = 13 active expect(steamSnap.compliance_pct).toBe(18.75); expect(accessSnap.compliant).toBe(3); expect(accessSnap.non_compliant).toBe(12); expect(accessSnap.compliance_pct).toBe(20); }); }); // ============================================================================= // Test Case 2.F — persistUpload() snapshot error-path preservation // ============================================================================= // // When the snapshot SELECT query fails, persistUpload() SHALL still commit // the upload, log the error to console.error, and not surface the error to // the caller. The HTTP layer SHALL NOT respond with an error status. // // **Validates: Requirement 3.9** // describe('Preservation 2.F — persistUpload() commits the upload when snapshot creation fails', () => { it('snapshot query rejection is caught — upload commits, error is logged, no error is thrown to caller', async () => { const incomingItem = { hostname: 'preserve-host-1', ip_address: '10.0.0.99', device_type: 'srv', team: 'STEAM', metric_id: 'M-PRESERVE', metric_desc: 'desc', category: 'Patching', extra_json: {}, }; const snapshotInserts = []; queryHandler = makeQueryHandler([ // (1) Initial active-items load — empty { match: /SELECT\s+id,\s+hostname,\s+metric_id,\s+seen_count,\s+first_seen_upload_id\s+FROM\s+compliance_items\s+WHERE\s+status\s*=\s*'active'/i, rows: () => [], }, // (2) Snapshot SELECT — REJECT to force the error path. We use // a sentinel marker via a function-rows handler that throws. // The makeQueryHandler infrastructure expects a row array, so // we drop in a row provider that returns a Promise rejection // by overriding the handler below. ]); // Wrap the existing handler so the snapshot SELECT rejects. const baseHandler = queryHandler; queryHandler = (text, params) => { if (/SELECT\s+team\s+AS\s+vertical[\s\S]*FROM\s+compliance_items[\s\S]*WHERE\s+team\s+IS\s+NOT\s+NULL[\s\S]*GROUP\s+BY\s+team/i.test(text) || /WHERE\s+team\s+IS\s+NOT\s+NULL\s+AND\s+vertical\s+IS\s+NOT\s+DISTINCT\s+FROM/i.test(text)) { return Promise.reject(new Error('simulated snapshot query failure')); } if (/INSERT\s+INTO\s+compliance_snapshots/i.test(text)) { const [snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct] = params || []; snapshotInserts.push({ snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct }); return Promise.resolve({ rows: [], rowCount: 0 }); } return baseHandler(text, params); }; const client = { query: jest.fn((text, _params) => { if (/^\s*BEGIN/i.test(text) || /^\s*COMMIT/i.test(text) || /^\s*ROLLBACK/i.test(text)) { return Promise.resolve({ rows: [], rowCount: 0 }); } if (/INSERT\s+INTO\s+compliance_uploads[\s\S]*RETURNING\s+id/i.test(text)) { return Promise.resolve({ rows: [{ id: 4242 }], rowCount: 1 }); } return Promise.resolve({ rows: [], rowCount: 1 }); }), release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(client); // Silence the expected console.error so the test output stays clean. const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); let result; let thrown; try { result = await persistUpload({ items: [incomingItem], summary: { entries: [], overall_scores: {} }, reportDate: '2025-04-01', filename: 'preserve-error-2025-04-01.xlsx', userId: 1, }); } catch (err) { thrown = err; } // (1) persistUpload() did NOT throw — the snapshot error is swallowed. expect(thrown).toBeUndefined(); // (2) Upload commit returned the expected payload. expect(result).toBeDefined(); expect(result.uploadId).toBe(4242); expect(result.newCount).toBe(1); expect(result.recurringCount).toBe(0); expect(result.resolvedCount).toBe(0); // (3) The transaction reached COMMIT (not ROLLBACK) before the // snapshot block ran. Inspect the client.query mock for COMMIT. const commitCalls = client.query.mock.calls.filter(c => /^\s*COMMIT/i.test(c[0])); const rollbackCalls = client.query.mock.calls.filter(c => /^\s*ROLLBACK/i.test(c[0])); expect(commitCalls.length).toBe(1); expect(rollbackCalls.length).toBe(0); // (4) The error WAS logged (preserves the existing error-path // observability contract). expect(errSpy).toHaveBeenCalled(); const loggedFirstArg = errSpy.mock.calls.map(c => String(c[0])).join('|'); expect(loggedFirstArg).toContain('[Compliance] Snapshot creation error:'); // (5) No INSERT INTO compliance_snapshots fired because the SELECT // rejected before the upsert loop. expect(snapshotInserts).toHaveLength(0); errSpy.mockRestore(); }); }); // ============================================================================= // Cross-endpoint preservation property — fast-check extension of Task 2 // ============================================================================= // // For any randomly generated scenario where every report_date has exactly // one upload row (the bug-condition negation), the four read endpoints // SHALL satisfy the same shape and equality predicates that the captured // baseline establishes for the canonical fixtures: one trend per date, // one waterfall entry per date with the running invariant, one // (date, category) row per pair, and a /summary with a non-null `upload` // when any selectable upload exists. // // **Validates: Requirements 3.1, 3.2, 3.4, 3.5** // describe('Preservation cross-endpoint property — single-upload-per-date scenarios match the baseline shape', () => { it('GET /trends produces one entry per report_date, in date-ascending order, with counts equal to the per-date upload values', async () => { await fc.assert( fc.asyncProperty(arbScenarioSingleUploadPerDate, async (uploads) => { installReadEndpointHandler(uploads, []); const res = await request(server, 'GET', '/api/compliance/trends'); expect(res.statusCode).toBe(200); expect(res.body.trends).toHaveLength(uploads.length); // Order: report_date ascending. const sorted = [...uploads].sort((a, b) => a.report_date.localeCompare(b.report_date), ); for (let i = 0; i < sorted.length; i++) { const u = sorted[i]; const t = res.body.trends[i]; expect(t.report_date).toBe(u.report_date); expect(t.new_count).toBe(u.new_count); expect(t.recurring_count).toBe(u.recurring_count); expect(t.resolved_count).toBe(u.resolved_count); expect(t.total_active).toBe(u.new_count + u.recurring_count); // No items in this scenario → all per-team counts are 0. expect(t.STEAM).toBe(0); expect(t['ACCESS-ENG']).toBe(0); expect(t['ACCESS-OPS']).toBe(0); expect(t.INTELDEV).toBe(0); } }), { numRuns: 25 }, ); }); it('GET /top-recurring produces one waterfall entry per report_date with the running invariant carried forward', async () => { await fc.assert( fc.asyncProperty(arbScenarioSingleUploadPerDate, async (uploads) => { installReadEndpointHandler(uploads, []); const res = await request(server, 'GET', '/api/compliance/top-recurring'); expect(res.statusCode).toBe(200); const wf = res.body.waterfall; expect(wf).toHaveLength(uploads.length); if (wf.length > 0) { expect(wf[0].start).toBe(0); } for (let i = 0; i < wf.length; i++) { expect(wf[i].end).toBe( wf[i].start + wf[i].new_count + wf[i].recurring_count - wf[i].resolved_count, ); if (i > 0) { expect(wf[i].start).toBe(wf[i - 1].end); } } }), { numRuns: 25 }, ); }); it('GET /category-trend has at most one row per (date, category) pair', async () => { await fc.assert( fc.asyncProperty(arbScenarioSingleUploadPerDate, async (uploads) => { installReadEndpointHandler(uploads, []); const res = await request(server, 'GET', '/api/compliance/category-trend'); expect(res.statusCode).toBe(200); const seen = new Set(); for (const row of res.body.categoryTrend) { const key = `${row.report_date}|${row.category}`; expect(seen.has(key)).toBe(false); seen.add(key); } }), { numRuns: 25 }, ); }); it('GET /summary returns null upload when no selectable upload exists, otherwise discloses no siblings', async () => { await fc.assert( fc.asyncProperty(arbScenarioSingleUploadPerDate, async (uploads) => { installReadEndpointHandler(uploads, []); const res = await request(server, 'GET', '/api/compliance/summary'); expect(res.statusCode).toBe(200); const selectable = uploads.filter(u => (u.vertical == null || u.vertical === 'NTS_AEO') && u.summary_json, ); if (selectable.length === 0) { expect(res.body).toEqual({ entries: [], overall_scores: {}, upload: null }); return; } // A primary upload exists. Bug-condition negation holds, so // no sibling upload shares its report_date. expect(res.body.upload).not.toBeNull(); const primaryDate = res.body.upload.report_date; const siblings = uploads.filter(u => u.report_date === primaryDate && u.id !== res.body.upload.id, ); expect(siblings).toHaveLength(0); }), { numRuns: 25 }, ); }); });