From d65411b0d7c13e0b1265807283ecf27873669fab Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Wed, 27 May 2026 12:54:31 -0600 Subject: [PATCH] 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(). --- ...n-display-fix.exploration.property.test.js | 216 ++++++++++++ ...-display-fix.preservation.property.test.js | 333 ++++++++++++++++++ backend/routes/compliance.js | 6 +- 3 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js create mode 100644 backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js diff --git a/backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js b/backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js new file mode 100644 index 0000000..7dddc95 --- /dev/null +++ b/backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js @@ -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 } + ); + }); +}); diff --git a/backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js b/backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js new file mode 100644 index 0000000..958a931 --- /dev/null +++ b/backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js @@ -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 }, + ); + }); +}); diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index d05f845..5d2a970 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -227,6 +227,7 @@ function groupByHostname(rows, noteHostnames) { seen_count: row.seen_count || 1, first_seen: row.first_seen || null, last_seen: row.last_seen || null, resolved_on: row.resolved_on || null, has_notes: noteHostnames.has(row.hostname), + resolution_date: null, remediation_plan: null, }; } 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.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.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); } @@ -598,6 +601,7 @@ function createComplianceRouter(upload) { const { rows } = await pool.query( `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.resolution_date, ci.remediation_plan, fu.report_date AS first_seen, lu.report_date AS last_seen, ru.report_date AS resolved_on FROM compliance_items ci LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id @@ -1736,4 +1740,4 @@ function createComplianceRouter(upload) { return router; } -module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall, persistUpload }; +module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall, persistUpload, groupByHostname };