1949 lines
86 KiB
JavaScript
1949 lines
86 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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: <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 },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|