Fix remediation plan and resolution date missing from compliance table

Add ci.resolution_date and ci.remediation_plan to the GET /items endpoint
SELECT clause and update groupByHostname() to aggregate them as first-non-null
across each hostname's metric rows. The frontend already rendered these columns
but the list endpoint never fetched the data from the database.

Includes exploration and preservation property tests for groupByHostname().
This commit is contained in:
Jordan Ramos
2026-05-27 12:54:31 -06:00
parent ea875e9193
commit d65411b0d7
3 changed files with 554 additions and 1 deletions

View File

@@ -0,0 +1,216 @@
/**
* Bug Condition Exploration Property Test: Compliance Remediation Display Fix
*
* Spec: .kiro/specs/compliance-remediation-display-fix/ (bugfix)
*
* BUG CONDITION:
* isBugCondition(row) = row.resolution_date != null OR row.remediation_plan != null
* Any row with metadata set will lose it through groupByHostname() because the
* function does not propagate resolution_date or remediation_plan into device objects.
*
* THIS TEST IS EXPECTED TO FAIL ON UNFIXED CODE.
* Failure confirms the bug exists — resolution_date and remediation_plan are undefined
* in the grouped device objects returned by groupByHostname().
*
* **Validates: Requirements 1.1, 1.2, 2.2**
*/
const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({
requireAuth: () => (req, res, next) => next(),
requireGroup: () => (req, res, next) => next(),
}));
jest.mock('../helpers/auditLog', () => jest.fn());
jest.mock('../helpers/driftChecker', () => ({
loadConfig: jest.fn(() => ({})),
compareSchemaToDrift: jest.fn(() => null),
reconcileConfig: jest.fn(() => ({ changes: [] })),
}));
jest.mock('../helpers/vclHelpers', () => ({
isValidDateString: jest.fn(() => true),
validateRemediationPlan: jest.fn(() => ({ valid: true })),
computeVCLStats: jest.fn(() => ({})),
categorizeNonCompliant: jest.fn(() => []),
rankHeavyHitters: jest.fn(() => []),
computeForecastBurndown: jest.fn(() => ({})),
matchByHostname: jest.fn(() => []),
computeBulkDiff: jest.fn(() => ({})),
mapColumnHeaders: jest.fn(() => ({})),
}));
const mockPool = {
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
connect: jest.fn(() => Promise.resolve({
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
release: jest.fn(),
})),
};
jest.mock('../db', () => mockPool);
const { groupByHostname } = require('../routes/compliance');
// --- Generators ---
/** Generate a date string in YYYY-MM-DD format (avoid toISOString on shrunk invalid dates) */
const arbDateString = fc.tuple(
fc.integer({ min: 2020, max: 2030 }),
fc.integer({ min: 1, max: 12 }),
fc.integer({ min: 1, max: 28 })
).map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
/** Generate a non-empty remediation plan string */
const arbRemediationPlan = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0);
/** Generate a hostname (alphanumeric with dashes, realistic) */
const arbHostname = fc.stringMatching(/^[A-Z][A-Z0-9\-]{2,20}$/);
/** Generate a metric_id like "7.1.1" or "3.2" */
const arbMetricId = fc.tuple(
fc.integer({ min: 1, max: 9 }),
fc.integer({ min: 1, max: 9 }),
fc.integer({ min: 1, max: 9 })
).map(([a, b, c]) => `${a}.${b}.${c}`);
/**
* Generate a compliance row with non-null resolution_date and/or remediation_plan.
* This is the bug condition: rows that have metadata which should be propagated.
*/
const arbComplianceRowWithMetadata = fc.record({
hostname: arbHostname,
ip_address: fc.ipV4(),
device_type: fc.constantFrom('Switch', 'Router', 'Firewall', 'Server'),
team: fc.constantFrom('STEAM', 'ACCESS-ENG'),
status: fc.constant('active'),
metric_id: arbMetricId,
metric_desc: fc.string({ minLength: 3, maxLength: 50 }),
category: fc.constantFrom('Configuration', 'Patching', 'Access Control'),
seen_count: fc.integer({ min: 1, max: 20 }),
first_seen: arbDateString,
last_seen: arbDateString,
resolved_on: fc.constant(null),
resolution_date: arbDateString,
remediation_plan: arbRemediationPlan,
});
// --- Property Test ---
describe('Bug Condition Exploration: resolution_date and remediation_plan in groupByHostname()', () => {
it('Property 1: groupByHostname() should propagate resolution_date from rows to device objects', () => {
fc.assert(
fc.property(arbComplianceRowWithMetadata, (row) => {
const rows = [row];
const noteHostnames = new Set();
const devices = groupByHostname(rows, noteHostnames);
// There should be exactly one device for the single hostname
expect(devices).toHaveLength(1);
const device = devices[0];
// BUG CONDITION: resolution_date should be propagated but is undefined on unfixed code
expect(device.resolution_date).toBe(row.resolution_date);
}),
{ numRuns: 100 }
);
});
it('Property 2: groupByHostname() should propagate remediation_plan from rows to device objects', () => {
fc.assert(
fc.property(arbComplianceRowWithMetadata, (row) => {
const rows = [row];
const noteHostnames = new Set();
const devices = groupByHostname(rows, noteHostnames);
expect(devices).toHaveLength(1);
const device = devices[0];
// BUG CONDITION: remediation_plan should be propagated but is undefined on unfixed code
expect(device.remediation_plan).toBe(row.remediation_plan);
}),
{ numRuns: 100 }
);
});
it('Property 3: groupByHostname() should pick first non-null resolution_date across multiple rows for same hostname', () => {
fc.assert(
fc.property(
arbHostname,
fc.array(arbMetricId, { minLength: 2, maxLength: 5 }),
arbDateString,
(hostname, metricIds, resolutionDate) => {
// Create multiple rows for the same hostname, first row has resolution_date
const rows = metricIds.map((mid, idx) => ({
hostname,
ip_address: '10.0.0.1',
device_type: 'Switch',
team: 'STEAM',
status: 'active',
metric_id: mid,
metric_desc: `Metric ${mid}`,
category: 'Configuration',
seen_count: 1,
first_seen: '2025-01-01',
last_seen: '2025-06-01',
resolved_on: null,
resolution_date: idx === 0 ? resolutionDate : null,
remediation_plan: null,
}));
const noteHostnames = new Set();
const devices = groupByHostname(rows, noteHostnames);
expect(devices).toHaveLength(1);
const device = devices[0];
// The first non-null resolution_date should be propagated
expect(device.resolution_date).toBe(resolutionDate);
}
),
{ numRuns: 100 }
);
});
it('Property 4: groupByHostname() should pick first non-null remediation_plan across multiple rows for same hostname', () => {
fc.assert(
fc.property(
arbHostname,
fc.array(arbMetricId, { minLength: 2, maxLength: 5 }),
arbRemediationPlan,
(hostname, metricIds, plan) => {
// Create multiple rows for the same hostname, first row has remediation_plan
const rows = metricIds.map((mid, idx) => ({
hostname,
ip_address: '10.0.0.1',
device_type: 'Switch',
team: 'STEAM',
status: 'active',
metric_id: mid,
metric_desc: `Metric ${mid}`,
category: 'Configuration',
seen_count: 1,
first_seen: '2025-01-01',
last_seen: '2025-06-01',
resolved_on: null,
resolution_date: null,
remediation_plan: idx === 0 ? plan : null,
}));
const noteHostnames = new Set();
const devices = groupByHostname(rows, noteHostnames);
expect(devices).toHaveLength(1);
const device = devices[0];
// The first non-null remediation_plan should be propagated
expect(device.remediation_plan).toBe(plan);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,333 @@
/**
* Preservation Property Tests: Compliance Remediation Display Fix
*
* Spec: .kiro/specs/compliance-remediation-display-fix/ (bugfix)
*
* These tests verify that groupByHostname() correctly aggregates existing fields
* on UNFIXED code. They should PASS on unfixed code — they capture baseline
* behaviour that must be preserved through the fix.
*
* Properties tested:
* P2.A — Each device.hostname appears exactly once in output
* P2.B — device.failing_metrics contains no duplicate metric_ids
* P2.C — device.seen_count >= every row's seen_count for that hostname
* P2.D — device.first_seen <= every row's first_seen for that hostname
* P2.E — device.last_seen >= every row's last_seen for that hostname
* P2.F — device.has_notes matches noteHostnames membership
*
* **Validates: Requirements 3.3, 3.5**
*/
const fc = require('fast-check');
// Mock dependencies required by the compliance module
jest.mock('../middleware/auth', () => ({
requireAuth: () => (req, res, next) => next(),
requireGroup: () => (req, res, next) => next(),
}));
jest.mock('../helpers/auditLog', () => jest.fn());
jest.mock('../db', () => ({
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
connect: jest.fn(() => Promise.resolve({ query: jest.fn(), release: jest.fn() })),
}));
const { groupByHostname } = require('../routes/compliance');
// --- Generators ---
/**
* Generate a valid hostname string (alphanumeric + hyphens, 1-20 chars).
*/
const hostnameArb = fc.stringMatching(/^[A-Z][A-Z0-9\-]{0,14}$/);
/**
* Generate a metric_id string like "7.1.1", "7.2.3", etc.
*/
const metricIdArb = fc.tuple(
fc.integer({ min: 1, max: 9 }),
fc.integer({ min: 1, max: 9 }),
fc.integer({ min: 1, max: 9 })
).map(([a, b, c]) => `${a}.${b}.${c}`);
/**
* Generate a date string in YYYY-MM-DD format for first_seen/last_seen.
*/
const dateArb = fc.tuple(
fc.integer({ min: 2023, max: 2025 }),
fc.integer({ min: 1, max: 12 }),
fc.integer({ min: 1, max: 28 })
).map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
/**
* Generate a compliance row with resolution_date = null and remediation_plan = null
* (non-bug-condition inputs — these are the rows that should work correctly on unfixed code).
*/
function complianceRowArb(hostname, metricId) {
return fc.record({
hostname: fc.constant(hostname),
ip_address: fc.constantFrom('10.0.0.1', '10.0.0.2', '192.168.1.1', ''),
device_type: fc.constantFrom('Switch', 'Router', 'Firewall', ''),
team: fc.constantFrom('STEAM', 'ACCESS-ENG'),
status: fc.constantFrom('active', 'resolved'),
metric_id: fc.constant(metricId),
metric_desc: fc.constantFrom('Password Complexity', 'Firmware Version', 'Logging Enabled'),
category: fc.constantFrom('Configuration', 'Patching', 'Monitoring'),
seen_count: fc.integer({ min: 1, max: 20 }),
first_seen: dateArb,
last_seen: dateArb,
resolved_on: fc.constant(null),
resolution_date: fc.constant(null),
remediation_plan: fc.constant(null),
});
}
/**
* Generate an array of compliance rows with varying hostnames and metric_ids.
* Ensures at least 1 row, with 1-4 hostnames and 1-5 metrics per hostname.
*/
const complianceRowsArb = fc.tuple(
fc.array(hostnameArb, { minLength: 1, maxLength: 4 }),
fc.array(metricIdArb, { minLength: 1, maxLength: 5 })
).chain(([hostnames, metricIds]) => {
// Ensure unique hostnames and metric_ids
const uniqueHostnames = [...new Set(hostnames)];
const uniqueMetricIds = [...new Set(metricIds)];
if (uniqueHostnames.length === 0 || uniqueMetricIds.length === 0) {
// Fallback: generate at least one row
return complianceRowArb('HOST-A', '1.1.1').map(row => [row]);
}
// Generate rows: each hostname gets some subset of metric_ids
// Some hostnames may share metric_ids (same metric failing on different devices)
const rowArbs = [];
for (const hostname of uniqueHostnames) {
// Each hostname gets 1 to all metric_ids
const metricsForHost = uniqueMetricIds.slice(0, Math.max(1, Math.ceil(Math.random() * uniqueMetricIds.length)));
for (const metricId of metricsForHost) {
rowArbs.push(complianceRowArb(hostname, metricId));
}
}
return fc.tuple(...rowArbs).map(rows => rows);
});
/**
* Better generator: explicitly controls the structure to ensure good coverage.
* Generates 1-3 hostnames, each with 1-4 rows (possibly duplicate metric_ids to test dedup).
*/
const structuredRowsArb = fc.record({
numHostnames: fc.integer({ min: 1, max: 3 }),
numMetricsPerHost: fc.integer({ min: 1, max: 4 }),
allowDuplicateMetrics: fc.boolean(),
}).chain(({ numHostnames, numMetricsPerHost, allowDuplicateMetrics }) => {
const hostnameArbs = fc.array(hostnameArb, { minLength: numHostnames, maxLength: numHostnames });
const metricArbs = fc.array(metricIdArb, { minLength: numMetricsPerHost, maxLength: numMetricsPerHost });
return fc.tuple(hostnameArbs, metricArbs).chain(([hostnames, metrics]) => {
const uniqueHostnames = [...new Set(hostnames)];
if (uniqueHostnames.length === 0) return fc.constant([]);
const rowArbs = [];
for (const hostname of uniqueHostnames) {
const metricsToUse = allowDuplicateMetrics
? metrics // May have duplicates
: [...new Set(metrics)];
for (const metricId of metricsToUse) {
rowArbs.push(complianceRowArb(hostname, metricId));
}
// Add an extra duplicate row for the first metric to test dedup
if (allowDuplicateMetrics && metricsToUse.length > 0) {
rowArbs.push(complianceRowArb(hostname, metricsToUse[0]));
}
}
if (rowArbs.length === 0) return fc.constant([]);
return fc.tuple(...rowArbs).map(rows => rows);
});
});
// =============================================================================
// Property P2.A — Each device.hostname appears exactly once in output
// =============================================================================
//
// **Validates: Requirements 3.3, 3.5**
//
describe('Property P2.A — Each device.hostname appears exactly once in output', () => {
it('P2.A — groupByHostname produces one device per unique hostname', () => {
fc.assert(
fc.property(structuredRowsArb, (rows) => {
if (rows.length === 0) return;
const noteHostnames = new Set();
const devices = groupByHostname(rows, noteHostnames);
// Each hostname in the output should appear exactly once
const outputHostnames = devices.map(d => d.hostname);
const uniqueOutputHostnames = new Set(outputHostnames);
expect(outputHostnames.length).toBe(uniqueOutputHostnames.size);
// Every hostname from input should appear in output
const inputHostnames = new Set(rows.map(r => r.hostname));
for (const hostname of inputHostnames) {
expect(uniqueOutputHostnames.has(hostname)).toBe(true);
}
}),
{ numRuns: 100 },
);
});
});
// =============================================================================
// Property P2.B — device.failing_metrics contains no duplicate metric_ids
// =============================================================================
//
// **Validates: Requirements 3.3, 3.5**
//
describe('Property P2.B — device.failing_metrics contains no duplicate metric_ids', () => {
it('P2.B — groupByHostname deduplicates metrics by metric_id', () => {
fc.assert(
fc.property(structuredRowsArb, (rows) => {
if (rows.length === 0) return;
const noteHostnames = new Set();
const devices = groupByHostname(rows, noteHostnames);
for (const device of devices) {
const metricIds = device.failing_metrics.map(m => m.metric_id);
const uniqueMetricIds = new Set(metricIds);
expect(metricIds.length).toBe(uniqueMetricIds.size);
}
}),
{ numRuns: 100 },
);
});
});
// =============================================================================
// Property P2.C — device.seen_count >= every row's seen_count for that hostname
// =============================================================================
//
// **Validates: Requirements 3.3, 3.5**
//
describe('Property P2.C — device.seen_count >= every row seen_count for that hostname', () => {
it('P2.C — groupByHostname picks the maximum seen_count across rows', () => {
fc.assert(
fc.property(structuredRowsArb, (rows) => {
if (rows.length === 0) return;
const noteHostnames = new Set();
const devices = groupByHostname(rows, noteHostnames);
for (const device of devices) {
const rowsForHost = rows.filter(r => r.hostname === device.hostname);
for (const row of rowsForHost) {
expect(device.seen_count).toBeGreaterThanOrEqual(row.seen_count);
}
}
}),
{ numRuns: 100 },
);
});
});
// =============================================================================
// Property P2.D — device.first_seen <= every row's first_seen for that hostname
// =============================================================================
//
// **Validates: Requirements 3.3, 3.5**
//
describe('Property P2.D — device.first_seen <= every row first_seen for that hostname', () => {
it('P2.D — groupByHostname picks the earliest first_seen across rows', () => {
fc.assert(
fc.property(structuredRowsArb, (rows) => {
if (rows.length === 0) return;
const noteHostnames = new Set();
const devices = groupByHostname(rows, noteHostnames);
for (const device of devices) {
const rowsForHost = rows.filter(r => r.hostname === device.hostname);
for (const row of rowsForHost) {
if (row.first_seen && device.first_seen) {
expect(device.first_seen <= row.first_seen).toBe(true);
}
}
}
}),
{ numRuns: 100 },
);
});
});
// =============================================================================
// Property P2.E — device.last_seen >= every row's last_seen for that hostname
// =============================================================================
//
// **Validates: Requirements 3.3, 3.5**
//
describe('Property P2.E — device.last_seen >= every row last_seen for that hostname', () => {
it('P2.E — groupByHostname picks the latest last_seen across rows', () => {
fc.assert(
fc.property(structuredRowsArb, (rows) => {
if (rows.length === 0) return;
const noteHostnames = new Set();
const devices = groupByHostname(rows, noteHostnames);
for (const device of devices) {
const rowsForHost = rows.filter(r => r.hostname === device.hostname);
for (const row of rowsForHost) {
if (row.last_seen && device.last_seen) {
expect(device.last_seen >= row.last_seen).toBe(true);
}
}
}
}),
{ numRuns: 100 },
);
});
});
// =============================================================================
// Property P2.F — device.has_notes matches noteHostnames membership
// =============================================================================
//
// **Validates: Requirements 3.3, 3.5**
//
describe('Property P2.F — device.has_notes matches noteHostnames membership', () => {
it('P2.F — groupByHostname sets has_notes based on noteHostnames Set', () => {
fc.assert(
fc.property(
structuredRowsArb,
fc.array(hostnameArb, { minLength: 0, maxLength: 3 }),
(rows, noteHosts) => {
if (rows.length === 0) return;
// Build noteHostnames set — include some from input, some random
const inputHostnames = [...new Set(rows.map(r => r.hostname))];
const noteHostnames = new Set([
...noteHosts,
// Include some actual hostnames from input to test true case
...inputHostnames.slice(0, Math.ceil(inputHostnames.length / 2)),
]);
const devices = groupByHostname(rows, noteHostnames);
for (const device of devices) {
const expected = noteHostnames.has(device.hostname);
expect(device.has_notes).toBe(expected);
}
},
),
{ numRuns: 100 },
);
});
});

View File

@@ -227,6 +227,7 @@ function groupByHostname(rows, noteHostnames) {
seen_count: row.seen_count || 1, first_seen: row.first_seen || null, seen_count: row.seen_count || 1, first_seen: row.first_seen || null,
last_seen: row.last_seen || null, resolved_on: row.resolved_on || null, last_seen: row.last_seen || null, resolved_on: row.resolved_on || null,
has_notes: noteHostnames.has(row.hostname), has_notes: noteHostnames.has(row.hostname),
resolution_date: null, remediation_plan: null,
}; };
} }
const dev = deviceMap[row.hostname]; const dev = deviceMap[row.hostname];
@@ -237,6 +238,8 @@ function groupByHostname(rows, noteHostnames) {
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count; if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) dev.first_seen = row.first_seen; if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) dev.first_seen = row.first_seen;
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) dev.last_seen = row.last_seen; if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) dev.last_seen = row.last_seen;
if (row.resolution_date && !dev.resolution_date) dev.resolution_date = row.resolution_date;
if (row.remediation_plan && !dev.remediation_plan) dev.remediation_plan = row.remediation_plan;
} }
return Object.values(deviceMap).map(({ _seenMetricIds, ...dev }) => dev); return Object.values(deviceMap).map(({ _seenMetricIds, ...dev }) => dev);
} }
@@ -598,6 +601,7 @@ function createComplianceRouter(upload) {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT DISTINCT ON (ci.hostname, ci.metric_id) `SELECT DISTINCT ON (ci.hostname, ci.metric_id)
ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.seen_count, ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.seen_count,
ci.resolution_date, ci.remediation_plan,
fu.report_date AS first_seen, lu.report_date AS last_seen, ru.report_date AS resolved_on fu.report_date AS first_seen, lu.report_date AS last_seen, ru.report_date AS resolved_on
FROM compliance_items ci FROM compliance_items ci
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
@@ -1736,4 +1740,4 @@ function createComplianceRouter(upload) {
return router; return router;
} }
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall, persistUpload }; module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall, persistUpload, groupByHostname };