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 };