Files
cve-dashboard/backend/__tests__/compliance-duplicate-chart-entries.property.test.js
Jordan Ramos 3814de5845 Fix duplicate chart entries on compliance page when multiple verticals share a report_date
Aggregate /trends, /top-recurring, /category-trend by report_date instead of
per-upload row. Add sibling-upload disclosure to /summary. Filter persistUpload
snapshot query by the upload's vertical to prevent cross-vertical contamination.

Fixes GitLab #12 (reported by nkapur — STEAM active findings chart showed 3
entries for 5/11 after uploading three vertical data sets for that date).

Includes 30 property-based tests covering bug condition and preservation.
2026-05-18 15:00:53 -06:00

1949 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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). 46 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: <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 },
);
});
});