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().
217 lines
8.4 KiB
JavaScript
217 lines
8.4 KiB
JavaScript
/**
|
|
* 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 }
|
|
);
|
|
});
|
|
});
|