1126 lines
48 KiB
JavaScript
1126 lines
48 KiB
JavaScript
|
|
/**
|
|||
|
|
* Preservation Property Tests: Compliance Duplicate Failing Metrics
|
|||
|
|
*
|
|||
|
|
* Spec: .kiro/specs/compliance-duplicate-failing-metrics/ (bugfix)
|
|||
|
|
*
|
|||
|
|
* These tests verify that UNIQUE-KEY inputs (where every (hostname, metric_id)
|
|||
|
|
* is unique across verticals) produce unchanged outputs after the fix.
|
|||
|
|
* They should PASS on unfixed code — they capture baseline behaviour to preserve.
|
|||
|
|
*
|
|||
|
|
* Properties tested:
|
|||
|
|
* 7.A — /items unique-key preservation (response equality across team/status combos)
|
|||
|
|
* 7.B — /items/:hostname unique-key preservation (active-then-resolved ordering)
|
|||
|
|
* 7.C — /vcl/stats unique-key preservation (stats, donut, heavy_hitters, vertical_breakdown)
|
|||
|
|
* 7.D — /mttr unique-key preservation (aging array equality)
|
|||
|
|
* 7.E — persistUpload() unique-key preservation (snapshot rows for single-status-per-hostname)
|
|||
|
|
* 7.F — /items query-param validation (HTTP 400 for invalid team/status, 404 for unknown hostname)
|
|||
|
|
* 8.A — Representative-row policy on duplicates (SKIP — asserts post-fix behavior)
|
|||
|
|
*
|
|||
|
|
* **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7**
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
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 mock
|
|||
|
|
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) {
|
|||
|
|
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);
|
|||
|
|
req.end();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Query handler builder ---
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 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.
|
|||
|
|
*/
|
|||
|
|
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 });
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
// --- Unique-key fixture builders ---
|
|||
|
|
// Every (hostname, metric_id) pair is unique across the array.
|
|||
|
|
// These represent the "no bug condition" case — behaviour must be unchanged after the fix.
|
|||
|
|
|
|||
|
|
const ALLOWED_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* fixtureUniqueKeyActive — Multiple devices, each with unique metric_ids, all active.
|
|||
|
|
* Covers the standard /items and /mttr paths.
|
|||
|
|
*/
|
|||
|
|
function fixtureUniqueKeyActive() {
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
id: 1, hostname: 'DEVICE-A', ip_address: '10.0.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.1.1',
|
|||
|
|
metric_desc: 'Password Complexity', category: 'Configuration',
|
|||
|
|
status: 'active', seen_count: 3, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 90, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 2, hostname: 'DEVICE-A', ip_address: '10.0.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.2.1',
|
|||
|
|
metric_desc: 'Firmware Version', category: 'Patching',
|
|||
|
|
status: 'active', seen_count: 5, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 85, resolved_upload_id: null,
|
|||
|
|
resolution_date: '2025-09-30', remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 3, hostname: 'DEVICE-B', ip_address: '10.0.0.2',
|
|||
|
|
device_type: 'Router', team: 'ACCESS-ENG', metric_id: '7.1.1',
|
|||
|
|
metric_desc: 'Password Complexity', category: 'Configuration',
|
|||
|
|
status: 'active', seen_count: 1, vertical: 'NTS_AEO',
|
|||
|
|
upload_id: 110, first_seen_upload_id: 110, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 4, hostname: 'DEVICE-C', ip_address: '10.0.0.3',
|
|||
|
|
device_type: 'Firewall', team: 'STEAM', metric_id: '7.3.1',
|
|||
|
|
metric_desc: 'Logging Enabled', category: 'Monitoring',
|
|||
|
|
status: 'active', seen_count: 7, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 70, resolved_upload_id: null,
|
|||
|
|
resolution_date: '2025-08-15', remediation_plan: 'Upgrade firmware', extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* fixtureUniqueKeyMixed — Devices with both active and resolved metrics.
|
|||
|
|
* Covers /items/:hostname ordering and /items with status=resolved.
|
|||
|
|
*/
|
|||
|
|
function fixtureUniqueKeyMixed() {
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
id: 10, hostname: 'MIXED-DEVICE', ip_address: '10.1.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.1.1',
|
|||
|
|
metric_desc: 'Password Complexity', category: 'Configuration',
|
|||
|
|
status: 'active', seen_count: 4, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 80, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 11, hostname: 'MIXED-DEVICE', ip_address: '10.1.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.2.1',
|
|||
|
|
metric_desc: 'Firmware Version', category: 'Patching',
|
|||
|
|
status: 'resolved', seen_count: 6, vertical: null,
|
|||
|
|
upload_id: 105, first_seen_upload_id: 75, resolved_upload_id: 105,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 12, hostname: 'MIXED-DEVICE', ip_address: '10.1.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.3.1',
|
|||
|
|
metric_desc: 'Logging Enabled', category: 'Monitoring',
|
|||
|
|
status: 'active', seen_count: 2, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 90, resolved_upload_id: null,
|
|||
|
|
resolution_date: '2025-10-01', remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* fixtureSingleStatusPerHostname — For persistUpload() snapshot preservation.
|
|||
|
|
* Each hostname has only active OR only resolved rows (never both).
|
|||
|
|
*/
|
|||
|
|
function fixtureSingleStatusPerHostname() {
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
id: 20, hostname: 'ACTIVE-ONLY-HOST', ip_address: '10.2.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.1.1',
|
|||
|
|
metric_desc: 'Password Complexity', category: 'Configuration',
|
|||
|
|
status: 'active', seen_count: 3, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 90, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 21, hostname: 'ACTIVE-ONLY-HOST', ip_address: '10.2.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.2.1',
|
|||
|
|
metric_desc: 'Firmware Version', category: 'Patching',
|
|||
|
|
status: 'active', seen_count: 5, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 85, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 22, hostname: 'RESOLVED-ONLY-HOST', ip_address: '10.2.0.2',
|
|||
|
|
device_type: 'Router', team: 'STEAM', metric_id: '7.3.1',
|
|||
|
|
metric_desc: 'Logging Enabled', category: 'Monitoring',
|
|||
|
|
status: 'resolved', seen_count: 4, vertical: null,
|
|||
|
|
upload_id: 105, first_seen_upload_id: 80, resolved_upload_id: 105,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Query handler installers for preservation tests ---
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Install handler for /items unique-key preservation.
|
|||
|
|
*/
|
|||
|
|
function installItemsHandler(items) {
|
|||
|
|
queryHandler = makeQueryHandler([
|
|||
|
|
{
|
|||
|
|
match: /FROM\s+compliance_items\s+ci[\s\S]*WHERE\s+ci\.team\s*=\s*\$1\s+AND\s+ci\.status\s*=\s*\$2/i,
|
|||
|
|
rows: (_text, params) => {
|
|||
|
|
const [team, status] = params || [];
|
|||
|
|
return items
|
|||
|
|
.filter(i => i.team === team && i.status === status)
|
|||
|
|
.map(i => ({
|
|||
|
|
hostname: i.hostname, ip_address: i.ip_address,
|
|||
|
|
device_type: i.device_type, team: i.team,
|
|||
|
|
metric_id: i.metric_id, metric_desc: i.metric_desc,
|
|||
|
|
category: i.category, status: i.status,
|
|||
|
|
seen_count: i.seen_count,
|
|||
|
|
first_seen: '2025-01-01', last_seen: '2025-05-01',
|
|||
|
|
resolved_on: i.resolved_upload_id ? '2025-05-01' : null,
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{ match: 'compliance_notes', rows: [] },
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Install handler for /items/:hostname unique-key preservation.
|
|||
|
|
*/
|
|||
|
|
function installItemsHostnameHandler(items) {
|
|||
|
|
queryHandler = makeQueryHandler([
|
|||
|
|
{
|
|||
|
|
match: /FROM\s+compliance_items\s+ci[\s\S]*WHERE\s+ci\.hostname\s*=\s*\$1/i,
|
|||
|
|
rows: (_text, params) => {
|
|||
|
|
const [hostname] = params || [];
|
|||
|
|
return items
|
|||
|
|
.filter(i => i.hostname === hostname)
|
|||
|
|
.sort((a, b) => {
|
|||
|
|
if (a.status !== b.status) return b.status.localeCompare(a.status);
|
|||
|
|
return a.metric_id.localeCompare(b.metric_id);
|
|||
|
|
})
|
|||
|
|
.map(i => ({
|
|||
|
|
metric_id: i.metric_id, metric_desc: i.metric_desc,
|
|||
|
|
category: i.category, status: i.status,
|
|||
|
|
ip_address: i.ip_address, device_type: i.device_type,
|
|||
|
|
team: i.team, seen_count: i.seen_count,
|
|||
|
|
extra_json: i.extra_json || '{}',
|
|||
|
|
resolution_date: i.resolution_date,
|
|||
|
|
remediation_plan: i.remediation_plan,
|
|||
|
|
first_seen: '2025-01-01', first_seen_at: '2025-01-01T00:00:00Z',
|
|||
|
|
last_seen: '2025-05-01', last_seen_at: '2025-05-01T00:00:00Z',
|
|||
|
|
resolved_on: i.resolved_upload_id ? '2025-05-01' : null,
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{ match: 'compliance_notes', rows: [] },
|
|||
|
|
{ match: 'compliance_item_history', rows: [] },
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Install handler for /vcl/stats unique-key preservation.
|
|||
|
|
*/
|
|||
|
|
function installVclStatsHandler(items) {
|
|||
|
|
const activeItems = items.filter(i => i.status === 'active');
|
|||
|
|
|
|||
|
|
queryHandler = makeQueryHandler([
|
|||
|
|
// Global stats query
|
|||
|
|
{
|
|||
|
|
match: /COUNT\(DISTINCT\s+hostname\)\s+AS\s+total_devices/i,
|
|||
|
|
rows: () => {
|
|||
|
|
const allHostnames = new Set(items.map(i => i.hostname));
|
|||
|
|
const activeHostnames = new Set(activeItems.map(i => i.hostname));
|
|||
|
|
const compliantHostnames = [...allHostnames].filter(h => !activeHostnames.has(h));
|
|||
|
|
return [{
|
|||
|
|
total_devices: allHostnames.size,
|
|||
|
|
in_scope: allHostnames.size,
|
|||
|
|
compliant: compliantHostnames.length,
|
|||
|
|
non_compliant: activeHostnames.size,
|
|||
|
|
}];
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
// Donut query
|
|||
|
|
{
|
|||
|
|
match: /MAX\(resolution_date\)[\s\S]*GROUP\s+BY\s+hostname/i,
|
|||
|
|
rows: () => {
|
|||
|
|
const byHost = {};
|
|||
|
|
for (const i of activeItems) {
|
|||
|
|
if (!byHost[i.hostname]) byHost[i.hostname] = { hostname: i.hostname, resolution_date: null };
|
|||
|
|
if (i.resolution_date) byHost[i.hostname].resolution_date = i.resolution_date;
|
|||
|
|
}
|
|||
|
|
return Object.values(byHost);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
// Heavy-hitters query — matches the new CTE pattern: WITH device_team AS (SELECT DISTINCT ON (hostname)...)
|
|||
|
|
{
|
|||
|
|
match: /WITH\s+device_team\s+AS[\s\S]*SELECT\s+DISTINCT\s+ON\s*\(hostname\)[\s\S]*WHERE\s+status\s*=\s*'active'[\s\S]*GROUP\s+BY\s+team/i,
|
|||
|
|
rows: () => {
|
|||
|
|
const teamCounts = {};
|
|||
|
|
const teamDates = {};
|
|||
|
|
for (const i of activeItems) {
|
|||
|
|
const t = i.team || 'Unknown';
|
|||
|
|
if (!teamCounts[t]) { teamCounts[t] = new Set(); teamDates[t] = null; }
|
|||
|
|
teamCounts[t].add(i.hostname);
|
|||
|
|
if (i.resolution_date && (!teamDates[t] || i.resolution_date > teamDates[t])) {
|
|||
|
|
teamDates[t] = i.resolution_date;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return Object.entries(teamCounts)
|
|||
|
|
.map(([team, hosts]) => ({
|
|||
|
|
team,
|
|||
|
|
non_compliant: hosts.size,
|
|||
|
|
compliance_date: teamDates[team] ? new Date(teamDates[team] + 'T00:00:00Z') : null,
|
|||
|
|
}))
|
|||
|
|
.sort((a, b) => b.non_compliant - a.non_compliant);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
// Per-team total query — matches the new CTE pattern: WITH device_team AS (...) SELECT COUNT(*)::int AS total FROM device_team WHERE team = $1
|
|||
|
|
{
|
|||
|
|
match: /WITH\s+device_team\s+AS[\s\S]*SELECT\s+COUNT\(\*\).*AS\s+total\s+FROM\s+device_team\s+WHERE\s+team/i,
|
|||
|
|
rows: (_text, params) => {
|
|||
|
|
const [team] = params || [];
|
|||
|
|
const hosts = new Set(items.filter(i => (i.team || 'Unknown') === team).map(i => i.hostname));
|
|||
|
|
return [{ total: hosts.size }];
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
// Forecast query — matches the new DISTINCT ON pattern
|
|||
|
|
{
|
|||
|
|
match: /SELECT\s+DISTINCT\s+ON\s*\(hostname,\s*metric_id\)\s*resolution_date/i,
|
|||
|
|
rows: (_text, params) => {
|
|||
|
|
const [team] = params || [];
|
|||
|
|
return activeItems
|
|||
|
|
.filter(i => (i.team || 'Unknown') === team && i.resolution_date != null)
|
|||
|
|
.map(i => ({ resolution_date: i.resolution_date }));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
// vcl_vertical_metadata
|
|||
|
|
{ match: 'vcl_vertical_metadata', rows: [] },
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Install handler for /mttr unique-key preservation.
|
|||
|
|
*/
|
|||
|
|
function installMttrHandler(items) {
|
|||
|
|
queryHandler = makeQueryHandler([
|
|||
|
|
{
|
|||
|
|
match: /seen_count[\s\S]*FROM\s+compliance_items\s+WHERE\s+status\s*=\s*'active'/i,
|
|||
|
|
rows: () => {
|
|||
|
|
return items
|
|||
|
|
.filter(i => i.status === 'active')
|
|||
|
|
.map(i => ({ seen_count: i.seen_count || 1, team: i.team }));
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Install handler for persistUpload() unique-key preservation.
|
|||
|
|
*/
|
|||
|
|
function installPersistUploadHandler(items) {
|
|||
|
|
queryHandler = makeQueryHandler([
|
|||
|
|
// persistUpload: SELECT active items
|
|||
|
|
{
|
|||
|
|
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(i => i.status === 'active').map(i => ({
|
|||
|
|
id: i.id, hostname: i.hostname, metric_id: i.metric_id,
|
|||
|
|
seen_count: i.seen_count, first_seen_upload_id: i.first_seen_upload_id,
|
|||
|
|
})),
|
|||
|
|
},
|
|||
|
|
{ match: 'BEGIN', rows: [] },
|
|||
|
|
{ match: 'COMMIT', rows: [] },
|
|||
|
|
{
|
|||
|
|
match: /INSERT\s+INTO\s+compliance_uploads/i,
|
|||
|
|
rows: () => [{ id: 999 }],
|
|||
|
|
},
|
|||
|
|
{ match: /UPDATE\s+compliance_uploads/i, rows: [] },
|
|||
|
|
{ match: /UPDATE\s+compliance_items/i, rows: [] },
|
|||
|
|
// Snapshot query — matches the new CTE pattern: WITH hostname_status AS (...) SELECT team AS vertical, COUNT(*)::int AS total_devices...
|
|||
|
|
{
|
|||
|
|
match: /WITH\s+hostname_status\s+AS[\s\S]*SELECT\s+team\s+AS\s+vertical/i,
|
|||
|
|
rows: (_text, params) => {
|
|||
|
|
const [vertical] = params || [];
|
|||
|
|
const filtered = items.filter(i => {
|
|||
|
|
if (vertical === null || vertical === undefined) return i.vertical == null;
|
|||
|
|
return i.vertical === vertical;
|
|||
|
|
}).filter(i => i.team != null);
|
|||
|
|
|
|||
|
|
const byTeam = {};
|
|||
|
|
for (const i of filtered) {
|
|||
|
|
if (!byTeam[i.team]) byTeam[i.team] = { team: i.team, hostnames: {} };
|
|||
|
|
if (!byTeam[i.team].hostnames[i.hostname]) {
|
|||
|
|
byTeam[i.team].hostnames[i.hostname] = new Set();
|
|||
|
|
}
|
|||
|
|
byTeam[i.team].hostnames[i.hostname].add(i.status);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Object.values(byTeam).map(t => {
|
|||
|
|
const hostnames = Object.keys(t.hostnames);
|
|||
|
|
const total_devices = hostnames.length;
|
|||
|
|
let compliant = 0;
|
|||
|
|
let non_compliant = 0;
|
|||
|
|
for (const h of hostnames) {
|
|||
|
|
const statuses = t.hostnames[h];
|
|||
|
|
// CTE uses MIN(status): 'active' < 'resolved', so if active exists, host is non_compliant
|
|||
|
|
if (statuses.has('active')) non_compliant++;
|
|||
|
|
else if (statuses.has('resolved')) compliant++;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
vertical: vertical,
|
|||
|
|
team: t.team,
|
|||
|
|
total_devices,
|
|||
|
|
compliant,
|
|||
|
|
non_compliant,
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{ match: /INSERT\s+INTO\s+compliance_snapshots/i, rows: [] },
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- 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 });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// Property 7.A — /items unique-key preservation
|
|||
|
|
// =============================================================================
|
|||
|
|
//
|
|||
|
|
// For any unique-key fixture, the response from GET /items?team=...&status=...
|
|||
|
|
// returns the correct devices with correct failing_metrics counts.
|
|||
|
|
// Each (hostname, metric_id) is unique, so no dedup should alter the output.
|
|||
|
|
//
|
|||
|
|
// **Validates: Requirements 3.1, 3.4**
|
|||
|
|
//
|
|||
|
|
describe('Property 7.A — /items unique-key preservation', () => {
|
|||
|
|
it('7.A — unique-key active items: response contains correct devices and metric counts', async () => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installItemsHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items?team=STEAM&status=active');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
expect(res.body.team).toBe('STEAM');
|
|||
|
|
expect(res.body.status).toBe('active');
|
|||
|
|
|
|||
|
|
// STEAM active items: DEVICE-A (7.1.1, 7.2.1) and DEVICE-C (7.3.1)
|
|||
|
|
const steamActive = items.filter(i => i.team === 'STEAM' && i.status === 'active');
|
|||
|
|
const expectedHostnames = [...new Set(steamActive.map(i => i.hostname))];
|
|||
|
|
expect(res.body.devices.length).toBe(expectedHostnames.length);
|
|||
|
|
|
|||
|
|
const deviceA = res.body.devices.find(d => d.hostname === 'DEVICE-A');
|
|||
|
|
expect(deviceA).toBeDefined();
|
|||
|
|
expect(deviceA.failing_metrics.length).toBe(2);
|
|||
|
|
expect(deviceA.failing_metrics.map(m => m.metric_id).sort()).toEqual(['7.1.1', '7.2.1']);
|
|||
|
|
|
|||
|
|
const deviceC = res.body.devices.find(d => d.hostname === 'DEVICE-C');
|
|||
|
|
expect(deviceC).toBeDefined();
|
|||
|
|
expect(deviceC.failing_metrics.length).toBe(1);
|
|||
|
|
expect(deviceC.failing_metrics[0].metric_id).toBe('7.3.1');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.A — unique-key items across teams: each team returns only its devices', async () => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installItemsHandler(items);
|
|||
|
|
|
|||
|
|
const resEng = await request(server, 'GET', '/api/compliance/items?team=ACCESS-ENG&status=active');
|
|||
|
|
expect(resEng.statusCode).toBe(200);
|
|||
|
|
expect(resEng.body.devices.length).toBe(1);
|
|||
|
|
expect(resEng.body.devices[0].hostname).toBe('DEVICE-B');
|
|||
|
|
expect(resEng.body.devices[0].failing_metrics.length).toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.A property — for any unique-key input, failing_metrics count equals distinct metric_ids per hostname', async () => {
|
|||
|
|
await fc.assert(
|
|||
|
|
fc.asyncProperty(
|
|||
|
|
fc.constantFrom('STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'),
|
|||
|
|
fc.constantFrom('active', 'resolved'),
|
|||
|
|
async (team, status) => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installItemsHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', `/api/compliance/items?team=${team}&status=${status}`);
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
// For unique-key inputs, each device's failing_metrics count
|
|||
|
|
// should equal the number of distinct metric_ids for that hostname
|
|||
|
|
const filtered = items.filter(i => i.team === team && i.status === status);
|
|||
|
|
const expectedByHost = {};
|
|||
|
|
for (const i of filtered) {
|
|||
|
|
if (!expectedByHost[i.hostname]) expectedByHost[i.hostname] = new Set();
|
|||
|
|
expectedByHost[i.hostname].add(i.metric_id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const device of res.body.devices) {
|
|||
|
|
const expected = expectedByHost[device.hostname];
|
|||
|
|
if (expected) {
|
|||
|
|
expect(device.failing_metrics.length).toBe(expected.size);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
{ numRuns: 8 },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// Property 7.B — /items/:hostname unique-key preservation
|
|||
|
|
// =============================================================================
|
|||
|
|
//
|
|||
|
|
// For any unique-key fixture, the response from GET /items/:hostname preserves
|
|||
|
|
// active-then-resolved ordering (status DESC alphabetically means 'resolved'
|
|||
|
|
// sorts before 'active' in DESC, but the route uses status DESC which puts
|
|||
|
|
// 'active' items first because 'a' < 'r' reversed). Within each status group,
|
|||
|
|
// metrics are sorted by metric_id.
|
|||
|
|
//
|
|||
|
|
// **Validates: Requirements 3.2, 3.3**
|
|||
|
|
//
|
|||
|
|
describe('Property 7.B — /items/:hostname unique-key preservation', () => {
|
|||
|
|
it('7.B — mixed-status device: active metrics before resolved, sorted by metric_id within group', async () => {
|
|||
|
|
const items = fixtureUniqueKeyMixed();
|
|||
|
|
installItemsHostnameHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items/MIXED-DEVICE');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
expect(res.body.metrics).toBeDefined();
|
|||
|
|
|
|||
|
|
// The route orders by status DESC, metric_id — 'resolved' > 'active' alphabetically
|
|||
|
|
// so status DESC puts 'resolved' first, then 'active'
|
|||
|
|
// Actually: ORDER BY ci.status DESC, ci.metric_id
|
|||
|
|
// 'resolved' > 'active' alphabetically, so DESC puts 'resolved' first? No:
|
|||
|
|
// DESC on strings: 'r' > 'a', so DESC means 'r' comes first.
|
|||
|
|
// Wait — the exploration test says "active-then-resolved ordering" but the SQL is
|
|||
|
|
// ORDER BY ci.status DESC which puts 'resolved' before 'active' (r > a in DESC).
|
|||
|
|
// Let's verify what the actual route produces and preserve it.
|
|||
|
|
|
|||
|
|
// The fixture has: 7.1.1 active, 7.2.1 resolved, 7.3.1 active
|
|||
|
|
// SQL ORDER BY status DESC, metric_id → resolved first, then active
|
|||
|
|
// So: 7.2.1 (resolved), then 7.1.1 (active), 7.3.1 (active)
|
|||
|
|
// Wait: DESC on status means 'r' > 'a', so resolved comes first.
|
|||
|
|
// Then within resolved: 7.2.1
|
|||
|
|
// Then within active: 7.1.1, 7.3.1
|
|||
|
|
|
|||
|
|
const metrics = res.body.metrics;
|
|||
|
|
expect(metrics.length).toBe(3);
|
|||
|
|
|
|||
|
|
// Verify ordering: resolved first (status DESC), then active, sorted by metric_id within
|
|||
|
|
expect(metrics[0].status).toBe('resolved');
|
|||
|
|
expect(metrics[0].metric_id).toBe('7.2.1');
|
|||
|
|
expect(metrics[1].status).toBe('active');
|
|||
|
|
expect(metrics[1].metric_id).toBe('7.1.1');
|
|||
|
|
expect(metrics[2].status).toBe('active');
|
|||
|
|
expect(metrics[2].metric_id).toBe('7.3.1');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.B — each metric appears exactly once per (metric_id, status) for unique-key input', async () => {
|
|||
|
|
const items = fixtureUniqueKeyMixed();
|
|||
|
|
installItemsHostnameHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items/MIXED-DEVICE');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
const pairs = res.body.metrics.map(m => `${m.metric_id}|${m.status}`);
|
|||
|
|
expect(pairs.length).toBe(new Set(pairs).size);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.B — seen_count is preserved correctly for unique-key items', async () => {
|
|||
|
|
const items = fixtureUniqueKeyMixed();
|
|||
|
|
installItemsHostnameHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items/MIXED-DEVICE');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
// Verify seen_count matches the fixture values
|
|||
|
|
const metric711 = res.body.metrics.find(m => m.metric_id === '7.1.1');
|
|||
|
|
expect(metric711.seen_count).toBe(4);
|
|||
|
|
|
|||
|
|
const metric721 = res.body.metrics.find(m => m.metric_id === '7.2.1');
|
|||
|
|
expect(metric721.seen_count).toBe(6);
|
|||
|
|
|
|||
|
|
const metric731 = res.body.metrics.find(m => m.metric_id === '7.3.1');
|
|||
|
|
expect(metric731.seen_count).toBe(2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.B property — for any unique-key input, ordering is status DESC then metric_id ASC', async () => {
|
|||
|
|
await fc.assert(
|
|||
|
|
fc.asyncProperty(
|
|||
|
|
fc.constant(true),
|
|||
|
|
async () => {
|
|||
|
|
const items = fixtureUniqueKeyMixed();
|
|||
|
|
installItemsHostnameHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items/MIXED-DEVICE');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
const metrics = res.body.metrics;
|
|||
|
|
// Verify ordering: status DESC (resolved before active), then metric_id ASC
|
|||
|
|
for (let i = 1; i < metrics.length; i++) {
|
|||
|
|
const prev = metrics[i - 1];
|
|||
|
|
const curr = metrics[i];
|
|||
|
|
if (prev.status === curr.status) {
|
|||
|
|
expect(prev.metric_id <= curr.metric_id).toBe(true);
|
|||
|
|
} else {
|
|||
|
|
// status DESC: 'resolved' > 'active' alphabetically
|
|||
|
|
expect(prev.status >= curr.status).toBe(true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
{ numRuns: 5 },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// Property 7.C — /vcl/stats unique-key preservation
|
|||
|
|
// =============================================================================
|
|||
|
|
//
|
|||
|
|
// For any unique-key fixture, the /vcl/stats response has consistent stats,
|
|||
|
|
// donut, heavy_hitters, and vertical_breakdown values.
|
|||
|
|
//
|
|||
|
|
// **Validates: Requirements 3.6**
|
|||
|
|
//
|
|||
|
|
describe('Property 7.C — /vcl/stats unique-key preservation', () => {
|
|||
|
|
it('7.C — unique-key items: stats.non_compliant equals distinct active hostnames', async () => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installVclStatsHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/vcl/stats');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
const activeHostnames = new Set(items.filter(i => i.status === 'active').map(i => i.hostname));
|
|||
|
|
expect(res.body.stats.non_compliant).toBe(activeHostnames.size);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.C — unique-key items: SUM(heavy_hitters.non_compliant) === stats.non_compliant', async () => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installVclStatsHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/vcl/stats');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
const sumHH = res.body.heavy_hitters.reduce((s, hh) => s + hh.non_compliant, 0);
|
|||
|
|
expect(sumHH).toBe(res.body.stats.non_compliant);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.C — unique-key items: donut blocked + in_progress equals non_compliant', async () => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installVclStatsHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/vcl/stats');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
const donutTotal = res.body.donut.blocked.count + res.body.donut.in_progress.count;
|
|||
|
|
expect(donutTotal).toBe(res.body.stats.non_compliant);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.C — unique-key items: vertical_breakdown blockers are non-negative', async () => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installVclStatsHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/vcl/stats');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
for (const vb of res.body.vertical_breakdown) {
|
|||
|
|
expect(vb.blockers).toBeGreaterThanOrEqual(0);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.C property — for any unique-key input, heavy_hitters sum reconciles with stats', async () => {
|
|||
|
|
await fc.assert(
|
|||
|
|
fc.asyncProperty(
|
|||
|
|
fc.integer({ min: 1, max: 5 }),
|
|||
|
|
async (numDevices) => {
|
|||
|
|
// Generate unique-key items with varying device counts
|
|||
|
|
const items = [];
|
|||
|
|
for (let d = 0; d < numDevices; d++) {
|
|||
|
|
items.push({
|
|||
|
|
id: 100 + d, hostname: `GEN-DEVICE-${d}`, ip_address: `10.0.${d}.1`,
|
|||
|
|
device_type: 'Switch', team: ALLOWED_TEAMS[d % ALLOWED_TEAMS.length],
|
|||
|
|
metric_id: `7.${d + 1}.1`, metric_desc: `Metric ${d}`, category: 'Config',
|
|||
|
|
status: 'active', seen_count: d + 1, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 90, resolved_upload_id: null,
|
|||
|
|
resolution_date: d % 2 === 0 ? '2025-09-30' : null,
|
|||
|
|
remediation_plan: null, extra_json: '{}',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
installVclStatsHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/vcl/stats');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
// Heavy hitters sum should equal stats.non_compliant
|
|||
|
|
const sumHH = res.body.heavy_hitters.reduce((s, hh) => s + hh.non_compliant, 0);
|
|||
|
|
expect(sumHH).toBe(res.body.stats.non_compliant);
|
|||
|
|
|
|||
|
|
// All blockers non-negative
|
|||
|
|
for (const vb of res.body.vertical_breakdown) {
|
|||
|
|
expect(vb.blockers).toBeGreaterThanOrEqual(0);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
{ numRuns: 10 },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// Property 7.D — /mttr unique-key preservation
|
|||
|
|
// =============================================================================
|
|||
|
|
//
|
|||
|
|
// For any unique-key fixture, the /mttr aging array correctly buckets each
|
|||
|
|
// active item exactly once.
|
|||
|
|
//
|
|||
|
|
// **Validates: Requirements 3.7**
|
|||
|
|
//
|
|||
|
|
describe('Property 7.D — /mttr unique-key preservation', () => {
|
|||
|
|
it('7.D — unique-key active items: SUM(aging.total) equals active item count', async () => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installMttrHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/mttr');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
const totalBucketed = res.body.aging.reduce((s, b) => s + b.total, 0);
|
|||
|
|
const activeCount = items.filter(i => i.status === 'active').length;
|
|||
|
|
expect(totalBucketed).toBe(activeCount);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.D — unique-key items: per-team bucket totals match team active counts', async () => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installMttrHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/mttr');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
// Sum per-team across all buckets
|
|||
|
|
const teamTotals = {};
|
|||
|
|
for (const bucket of res.body.aging) {
|
|||
|
|
for (const team of ALLOWED_TEAMS) {
|
|||
|
|
if (!teamTotals[team]) teamTotals[team] = 0;
|
|||
|
|
teamTotals[team] += bucket[team] || 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Compare with fixture
|
|||
|
|
for (const team of ALLOWED_TEAMS) {
|
|||
|
|
const expected = items.filter(i => i.status === 'active' && i.team === team).length;
|
|||
|
|
expect(teamTotals[team]).toBe(expected);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.D — unique-key items: correct bucket assignment by seen_count', async () => {
|
|||
|
|
const items = fixtureUniqueKeyActive();
|
|||
|
|
installMttrHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/mttr');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
// DEVICE-A 7.1.1 seen_count=3 → '2–3 cycles'
|
|||
|
|
// DEVICE-A 7.2.1 seen_count=5 → '4–6 cycles'
|
|||
|
|
// DEVICE-B 7.1.1 seen_count=1 → '1 cycle'
|
|||
|
|
// DEVICE-C 7.3.1 seen_count=7 → '7+ cycles'
|
|||
|
|
const buckets = {};
|
|||
|
|
for (const b of res.body.aging) {
|
|||
|
|
buckets[b.bucket] = b;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
expect(buckets['1 cycle'].total).toBe(1);
|
|||
|
|
expect(buckets['2–3 cycles'].total).toBe(1);
|
|||
|
|
expect(buckets['4–6 cycles'].total).toBe(1);
|
|||
|
|
expect(buckets['7+ cycles'].total).toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.D property — for any unique-key input, total bucketed equals active item count', async () => {
|
|||
|
|
await fc.assert(
|
|||
|
|
fc.asyncProperty(
|
|||
|
|
fc.array(
|
|||
|
|
fc.record({
|
|||
|
|
seenCount: fc.integer({ min: 1, max: 20 }),
|
|||
|
|
team: fc.constantFrom(...ALLOWED_TEAMS),
|
|||
|
|
}),
|
|||
|
|
{ minLength: 1, maxLength: 6 },
|
|||
|
|
),
|
|||
|
|
async (configs) => {
|
|||
|
|
const items = configs.map((cfg, idx) => ({
|
|||
|
|
id: 200 + idx, hostname: `PBT-DEVICE-${idx}`, ip_address: `10.5.${idx}.1`,
|
|||
|
|
device_type: 'Switch', team: cfg.team,
|
|||
|
|
metric_id: `8.${idx + 1}.1`, metric_desc: `PBT Metric ${idx}`, category: 'Config',
|
|||
|
|
status: 'active', seen_count: cfg.seenCount, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 90, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
}));
|
|||
|
|
installMttrHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/mttr');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
const totalBucketed = res.body.aging.reduce((s, b) => s + b.total, 0);
|
|||
|
|
expect(totalBucketed).toBe(items.length);
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
{ numRuns: 10 },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// Property 7.E — persistUpload() unique-key preservation
|
|||
|
|
// =============================================================================
|
|||
|
|
//
|
|||
|
|
// For a single-status-per-hostname fixture (every hostname has only active or
|
|||
|
|
// only resolved rows, never both), persistUpload() produces snapshot rows where
|
|||
|
|
// compliant + non_compliant === total_devices.
|
|||
|
|
//
|
|||
|
|
// **Validates: Requirements 3.5**
|
|||
|
|
//
|
|||
|
|
describe('Property 7.E — persistUpload() unique-key preservation', () => {
|
|||
|
|
it('7.E — single-status-per-hostname: snapshot satisfies compliant + non_compliant === total_devices', async () => {
|
|||
|
|
const items = fixtureSingleStatusPerHostname();
|
|||
|
|
installPersistUploadHandler(items);
|
|||
|
|
recordedQueries.length = 0;
|
|||
|
|
|
|||
|
|
await persistUpload({
|
|||
|
|
items: [],
|
|||
|
|
summary: {},
|
|||
|
|
reportDate: '2025-06-01',
|
|||
|
|
filename: 'test-upload.xlsx',
|
|||
|
|
userId: 1,
|
|||
|
|
vertical: null,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Find the snapshot query and verify its results
|
|||
|
|
const verticalStatsQuery = recordedQueries.find(q =>
|
|||
|
|
q.text && /WITH\s+hostname_status\s+AS/i.test(q.text)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
expect(verticalStatsQuery).toBeDefined();
|
|||
|
|
const mockResult = await queryHandler(verticalStatsQuery.text, verticalStatsQuery.params);
|
|||
|
|
|
|||
|
|
for (const row of mockResult.rows) {
|
|||
|
|
// For single-status-per-hostname, each hostname is in exactly one column
|
|||
|
|
expect(row.compliant + row.non_compliant).toBe(row.total_devices);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.E — single-status-per-hostname: STEAM team has correct counts', async () => {
|
|||
|
|
const items = fixtureSingleStatusPerHostname();
|
|||
|
|
installPersistUploadHandler(items);
|
|||
|
|
recordedQueries.length = 0;
|
|||
|
|
|
|||
|
|
await persistUpload({
|
|||
|
|
items: [],
|
|||
|
|
summary: {},
|
|||
|
|
reportDate: '2025-06-01',
|
|||
|
|
filename: 'test-upload.xlsx',
|
|||
|
|
userId: 1,
|
|||
|
|
vertical: null,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const verticalStatsQuery = recordedQueries.find(q =>
|
|||
|
|
q.text && /WITH\s+hostname_status\s+AS/i.test(q.text)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const mockResult = await queryHandler(verticalStatsQuery.text, verticalStatsQuery.params);
|
|||
|
|
const steamRow = mockResult.rows.find(r => r.team === 'STEAM');
|
|||
|
|
expect(steamRow).toBeDefined();
|
|||
|
|
|
|||
|
|
// ACTIVE-ONLY-HOST has 2 active metrics → non_compliant
|
|||
|
|
// RESOLVED-ONLY-HOST has 1 resolved metric → compliant
|
|||
|
|
expect(steamRow.total_devices).toBe(2);
|
|||
|
|
expect(steamRow.non_compliant).toBe(1); // ACTIVE-ONLY-HOST
|
|||
|
|
expect(steamRow.compliant).toBe(1); // RESOLVED-ONLY-HOST
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.E property — for any single-status-per-hostname input, snapshot invariant holds', async () => {
|
|||
|
|
await fc.assert(
|
|||
|
|
fc.asyncProperty(
|
|||
|
|
fc.array(
|
|||
|
|
fc.record({
|
|||
|
|
status: fc.constantFrom('active', 'resolved'),
|
|||
|
|
team: fc.constantFrom(...ALLOWED_TEAMS),
|
|||
|
|
}),
|
|||
|
|
{ minLength: 1, maxLength: 4 },
|
|||
|
|
),
|
|||
|
|
async (configs) => {
|
|||
|
|
// Generate items where each hostname has only one status
|
|||
|
|
const items = configs.map((cfg, idx) => ({
|
|||
|
|
id: 300 + idx, hostname: `SNAP-HOST-${idx}`, ip_address: `10.3.${idx}.1`,
|
|||
|
|
device_type: 'Switch', team: cfg.team,
|
|||
|
|
metric_id: `9.${idx + 1}.1`, metric_desc: `Snap Metric ${idx}`, category: 'Config',
|
|||
|
|
status: cfg.status, seen_count: idx + 1, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 90,
|
|||
|
|
resolved_upload_id: cfg.status === 'resolved' ? 100 : null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
}));
|
|||
|
|
installPersistUploadHandler(items);
|
|||
|
|
recordedQueries.length = 0;
|
|||
|
|
|
|||
|
|
await persistUpload({
|
|||
|
|
items: [],
|
|||
|
|
summary: {},
|
|||
|
|
reportDate: '2025-06-01',
|
|||
|
|
filename: 'test-upload.xlsx',
|
|||
|
|
userId: 1,
|
|||
|
|
vertical: null,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const verticalStatsQuery = recordedQueries.find(q =>
|
|||
|
|
q.text && /WITH\s+hostname_status\s+AS/i.test(q.text)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (verticalStatsQuery) {
|
|||
|
|
const mockResult = await queryHandler(verticalStatsQuery.text, verticalStatsQuery.params);
|
|||
|
|
for (const row of mockResult.rows) {
|
|||
|
|
expect(row.compliant + row.non_compliant).toBeLessThanOrEqual(row.total_devices);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
{ numRuns: 10 },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// Property 7.F — /items query-param validation preservation
|
|||
|
|
// =============================================================================
|
|||
|
|
//
|
|||
|
|
// Invalid team/status params return HTTP 400. Unknown hostname returns HTTP 404.
|
|||
|
|
// These validations must be unchanged by the fix.
|
|||
|
|
//
|
|||
|
|
// **Validates: Requirements 3.1, 3.4**
|
|||
|
|
//
|
|||
|
|
describe('Property 7.F — /items query-param validation preservation', () => {
|
|||
|
|
it('7.F — invalid team returns HTTP 400', async () => {
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items?team=INVALID_TEAM&status=active');
|
|||
|
|
expect(res.statusCode).toBe(400);
|
|||
|
|
expect(res.body.error).toMatch(/Invalid team/i);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.F — missing team returns HTTP 400', async () => {
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items?status=active');
|
|||
|
|
expect(res.statusCode).toBe(400);
|
|||
|
|
expect(res.body.error).toMatch(/team is required/i);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.F — invalid status returns HTTP 400', async () => {
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items?team=STEAM&status=invalid');
|
|||
|
|
expect(res.statusCode).toBe(400);
|
|||
|
|
expect(res.body.error).toMatch(/Invalid status/i);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.F — unknown hostname returns HTTP 404', async () => {
|
|||
|
|
// Install handler that returns empty rows for unknown hostname
|
|||
|
|
installItemsHostnameHandler([]);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items/NONEXISTENT-HOST-XYZ');
|
|||
|
|
expect(res.statusCode).toBe(404);
|
|||
|
|
expect(res.body.error).toMatch(/Device not found/i);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.F property — for any invalid team name, /items returns 400', async () => {
|
|||
|
|
await fc.assert(
|
|||
|
|
fc.asyncProperty(
|
|||
|
|
fc.string({ minLength: 1, maxLength: 20 }).filter(s =>
|
|||
|
|
!['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'].includes(s)
|
|||
|
|
),
|
|||
|
|
async (invalidTeam) => {
|
|||
|
|
const encoded = encodeURIComponent(invalidTeam);
|
|||
|
|
const res = await request(server, 'GET', `/api/compliance/items?team=${encoded}&status=active`);
|
|||
|
|
expect(res.statusCode).toBe(400);
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
{ numRuns: 10 },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('7.F property — for any invalid status, /items returns 400', async () => {
|
|||
|
|
await fc.assert(
|
|||
|
|
fc.asyncProperty(
|
|||
|
|
fc.string({ minLength: 1, maxLength: 20 }).filter(s =>
|
|||
|
|
!['active', 'resolved'].includes(s)
|
|||
|
|
),
|
|||
|
|
async (invalidStatus) => {
|
|||
|
|
const encoded = encodeURIComponent(invalidStatus);
|
|||
|
|
const res = await request(server, 'GET', `/api/compliance/items?team=STEAM&status=${encoded}`);
|
|||
|
|
expect(res.statusCode).toBe(400);
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
{ numRuns: 10 },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// Property 8.A — Representative-row policy on duplicates (SKIPPED)
|
|||
|
|
// =============================================================================
|
|||
|
|
//
|
|||
|
|
// This property asserts post-fix behavior: for inputs WITH duplicates, the
|
|||
|
|
// surviving entry carries seen_count = MAX(seen_count), first_seen = MIN(first_seen),
|
|||
|
|
// last_seen = MAX(last_seen) across the duplicate rows.
|
|||
|
|
//
|
|||
|
|
// SKIP until the fix lands (task 3.8 will unskip this).
|
|||
|
|
//
|
|||
|
|
// **Validates: Requirements 3.2, 3.3**
|
|||
|
|
//
|
|||
|
|
describe('Property 8.A — Representative-row policy on duplicates', () => {
|
|||
|
|
// eslint-disable-next-line jest/no-disabled-tests
|
|||
|
|
test.skip('8.A — duplicate rows: surviving entry carries MAX(seen_count)', async () => {
|
|||
|
|
// This test exercises the duplicate path and asserts the post-fix contract.
|
|||
|
|
// It will be unskipped in task 3.8 after the fix is implemented.
|
|||
|
|
const items = [
|
|||
|
|
{
|
|||
|
|
id: 1, hostname: 'DUP-DEVICE', ip_address: '10.0.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.1.1',
|
|||
|
|
metric_desc: 'Password Complexity', category: 'Configuration',
|
|||
|
|
status: 'active', seen_count: 3, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 90, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 2, hostname: 'DUP-DEVICE', ip_address: '10.0.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.1.1',
|
|||
|
|
metric_desc: 'Password Complexity', category: 'Configuration',
|
|||
|
|
status: 'active', seen_count: 7, vertical: 'NTS_AEO',
|
|||
|
|
upload_id: 110, first_seen_upload_id: 80, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
installItemsHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items?team=STEAM&status=active');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
const device = res.body.devices.find(d => d.hostname === 'DUP-DEVICE');
|
|||
|
|
expect(device).toBeDefined();
|
|||
|
|
|
|||
|
|
// After fix: only one entry for metric 7.1.1
|
|||
|
|
expect(device.failing_metrics.length).toBe(1);
|
|||
|
|
expect(device.failing_metrics[0].metric_id).toBe('7.1.1');
|
|||
|
|
|
|||
|
|
// Representative row carries MAX(seen_count) = 7
|
|||
|
|
expect(device.seen_count).toBe(7);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// eslint-disable-next-line jest/no-disabled-tests
|
|||
|
|
test.skip('8.A — duplicate rows on /items/:hostname: surviving entry carries MAX(seen_count)', async () => {
|
|||
|
|
const items = [
|
|||
|
|
{
|
|||
|
|
id: 1, hostname: 'DUP-DEVICE', ip_address: '10.0.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.1.1',
|
|||
|
|
metric_desc: 'Password Complexity', category: 'Configuration',
|
|||
|
|
status: 'active', seen_count: 3, vertical: null,
|
|||
|
|
upload_id: 100, first_seen_upload_id: 90, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 2, hostname: 'DUP-DEVICE', ip_address: '10.0.0.1',
|
|||
|
|
device_type: 'Switch', team: 'STEAM', metric_id: '7.1.1',
|
|||
|
|
metric_desc: 'Password Complexity', category: 'Configuration',
|
|||
|
|
status: 'active', seen_count: 7, vertical: 'NTS_AEO',
|
|||
|
|
upload_id: 110, first_seen_upload_id: 80, resolved_upload_id: null,
|
|||
|
|
resolution_date: null, remediation_plan: null, extra_json: '{}',
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
installItemsHostnameHandler(items);
|
|||
|
|
|
|||
|
|
const res = await request(server, 'GET', '/api/compliance/items/DUP-DEVICE');
|
|||
|
|
expect(res.statusCode).toBe(200);
|
|||
|
|
|
|||
|
|
// After fix: only one entry for (7.1.1, active)
|
|||
|
|
const activeMetrics = res.body.metrics.filter(m => m.metric_id === '7.1.1' && m.status === 'active');
|
|||
|
|
expect(activeMetrics.length).toBe(1);
|
|||
|
|
|
|||
|
|
// Representative row carries MAX(seen_count) = 7
|
|||
|
|
expect(activeMetrics[0].seen_count).toBe(7);
|
|||
|
|
});
|
|||
|
|
});
|