diff --git a/frontend/src/__tests__/compliance-nonmetric-color-resolution.property.test.js b/frontend/src/__tests__/compliance-nonmetric-color-resolution.property.test.js new file mode 100644 index 0000000..b3210a8 --- /dev/null +++ b/frontend/src/__tests__/compliance-nonmetric-color-resolution.property.test.js @@ -0,0 +1,71 @@ +/** + * Property-Based Test: Color resolution with fallback + * + * Feature: compliance-nonmetric-filters, Property 4: Color resolution with fallback + * **Validates: Requirements 6.2, 6.3** + * + * For any non-metric category metric_id, the resolved color equals + * CATEGORY_COLORS[metricCategoriesConfig[metricId]] when both lookups succeed, + * else #94A3B8. + */ +import fc from 'fast-check'; +import { deriveNonMetricCategories, CATEGORY_COLORS } from '../components/pages/CompliancePage'; + +// The color resolution logic extracted for direct testing +function resolveColor(metricId, categoriesConfig) { + const categoryName = categoriesConfig[metricId] || null; + return (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8'; +} + +// Generators +const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/); + +const knownCategories = Object.keys(CATEGORY_COLORS); +const categoryNameArb = fc.oneof( + fc.constantFrom(...knownCategories), + fc.string({ minLength: 1, maxLength: 20 }) // may not be in CATEGORY_COLORS +); + +const categoriesConfigArb = fc.dictionary(metricIdArb, categoryNameArb); + +describe('Compliance Non-Metric Filter — Property 4: Color resolution with fallback', () => { + it('color equals CATEGORY_COLORS[config[metricId]] when both lookups succeed, else #94A3B8', () => { + fc.assert( + fc.property(metricIdArb, categoriesConfigArb, (metricId, config) => { + const result = resolveColor(metricId, config); + + const categoryName = config[metricId]; + if (categoryName && CATEGORY_COLORS[categoryName]) { + expect(result).toBe(CATEGORY_COLORS[categoryName]); + } else { + expect(result).toBe('#94A3B8'); + } + }), + { numRuns: 100 } + ); + }); + + it('deriveNonMetricCategories produces correct colors for all returned categories', () => { + const deviceWithMetricArb = metricIdArb.map(id => ({ + hostname: 'host-' + id, + failing_metrics: [{ metric_id: id, metric_desc: '', category: '' }], + })); + + fc.assert( + fc.property( + fc.array(deviceWithMetricArb, { minLength: 1, maxLength: 15 }), + categoriesConfigArb, + (devices, config) => { + // Empty summary means all device metric_ids are non-metric + const result = deriveNonMetricCategories(devices, [], config); + + for (const item of result) { + const expectedColor = resolveColor(item.metricId, config); + expect(item.color).toBe(expectedColor); + } + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/frontend/src/__tests__/compliance-nonmetric-derivation.property.test.js b/frontend/src/__tests__/compliance-nonmetric-derivation.property.test.js new file mode 100644 index 0000000..b463bbe --- /dev/null +++ b/frontend/src/__tests__/compliance-nonmetric-derivation.property.test.js @@ -0,0 +1,112 @@ +/** + * Property-Based Test: Non-metric category derivation is the set difference with accurate counts + * + * Feature: compliance-nonmetric-filters, Property 1: Non-metric category derivation + * **Validates: Requirements 1.1, 1.2, 2.2, 2.4** + * + * For any set of devices and any set of summary entries for a team, + * deriveNonMetricCategories returns exactly the metric_ids present in at least + * one device's failing_metrics array that do not appear in any summary entry's + * metric_id — deduplicated, sorted alphabetically by metricId, and each with a + * count equal to the number of devices whose failing_metrics contains that metric_id. + */ +import fc from 'fast-check'; +import { deriveNonMetricCategories } from '../components/pages/CompliancePage'; + +// Generators +const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/); + +const failingMetricArb = fc.record({ + metric_id: metricIdArb, + metric_desc: fc.constant(''), + category: fc.constant(''), +}); + +const deviceArb = fc.record({ + hostname: fc.string({ minLength: 1, maxLength: 30 }), + failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }), +}); + +const summaryEntryArb = fc.record({ + metric_id: metricIdArb, + team: fc.constant('STEAM'), + compliance_pct: fc.double({ min: 0, max: 1 }), + status: fc.constantFrom('Meets/Exceeds Target', 'Within 15% of Target', 'Below 15% of Target'), +}); + +const categoriesConfigArb = fc.dictionary(metricIdArb, fc.constantFrom( + 'Vulnerability Management', 'Access & MFA', 'Logging & Monitoring', + 'Asset Data Quality', 'Endpoint Protection', 'Application Security', + 'Disaster Recovery', 'Decommissioned Assets' +)); + +describe('Compliance Non-Metric Derivation — Property 1: Set difference with accurate counts', () => { + it('returned metric_ids are exactly the set difference of device metric_ids minus summary metric_ids', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 20 }), + fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }), + categoriesConfigArb, + (devices, summaryEntries, config) => { + const result = deriveNonMetricCategories(devices, summaryEntries, config); + + const summaryIds = new Set(summaryEntries.map(e => e.metric_id)); + const deviceMetricIds = new Set(); + for (const d of devices) { + for (const m of (d.failing_metrics || [])) { + if (m.metric_id) deviceMetricIds.add(m.metric_id); + } + } + const expectedIds = new Set([...deviceMetricIds].filter(id => !summaryIds.has(id))); + + const resultIds = new Set(result.map(r => r.metricId)); + expect(resultIds).toEqual(expectedIds); + } + ), + { numRuns: 100 } + ); + }); + + it('returned list is deduplicated and sorted alphabetically by metricId', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 20 }), + fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }), + categoriesConfigArb, + (devices, summaryEntries, config) => { + const result = deriveNonMetricCategories(devices, summaryEntries, config); + + // Deduplicated + const ids = result.map(r => r.metricId); + expect(ids.length).toBe(new Set(ids).size); + + // Sorted alphabetically + const sorted = [...ids].sort((a, b) => a.localeCompare(b)); + expect(ids).toEqual(sorted); + } + ), + { numRuns: 100 } + ); + }); + + it('each count equals the number of devices whose failing_metrics contains that metric_id', () => { + fc.assert( + fc.property( + fc.array(deviceArb, { minLength: 0, maxLength: 20 }), + fc.array(summaryEntryArb, { minLength: 0, maxLength: 10 }), + categoriesConfigArb, + (devices, summaryEntries, config) => { + const result = deriveNonMetricCategories(devices, summaryEntries, config); + + for (const { metricId, count } of result) { + const expectedCount = devices.filter(d => + (d.failing_metrics || []).some(m => m.metric_id === metricId) + ).length; + expect(count).toBe(expectedCount); + } + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/frontend/src/__tests__/compliance-nonmetric-filter-predicate.property.test.js b/frontend/src/__tests__/compliance-nonmetric-filter-predicate.property.test.js new file mode 100644 index 0000000..1ccbf2b --- /dev/null +++ b/frontend/src/__tests__/compliance-nonmetric-filter-predicate.property.test.js @@ -0,0 +1,85 @@ +/** + * Property-Based Test: Filter predicate correctness + * + * Feature: compliance-nonmetric-filters, Property 2: Filter predicate correctness + * **Validates: Requirements 3.1, 5.2, 5.3, 5.4** + * + * For any filter state and any set of devices: when null, all devices pass; + * when metric, exactly devices with matching metric_id in ids pass; + * when nonmetric, exactly devices with matching metric_id pass. + */ +import fc from 'fast-check'; + +// Replicate the filter predicate logic from CompliancePage +function applyFilter(devices, filterState) { + return devices.filter(d => { + if (!filterState) return true; + if (filterState.type === 'metric') return d.failing_metrics.some(m => filterState.ids.includes(m.metric_id)); + if (filterState.type === 'nonmetric') return d.failing_metrics.some(m => m.metric_id === filterState.id); + return true; + }); +} + +// Generators +const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/); + +const failingMetricArb = fc.record({ + metric_id: metricIdArb, + metric_desc: fc.constant(''), + category: fc.constant(''), +}); + +const deviceArb = fc.record({ + hostname: fc.string({ minLength: 1, maxLength: 30 }), + failing_metrics: fc.array(failingMetricArb, { minLength: 0, maxLength: 8 }), +}); + +const devicesArb = fc.array(deviceArb, { minLength: 0, maxLength: 20 }); + +const filterStateArb = fc.oneof( + fc.constant(null), + fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })), + metricIdArb.map(id => ({ type: 'nonmetric', id })) +); + +describe('Compliance Non-Metric Filter — Property 2: Filter predicate correctness', () => { + it('when filterState is null, all devices pass', () => { + fc.assert( + fc.property(devicesArb, (devices) => { + const result = applyFilter(devices, null); + expect(result.length).toBe(devices.length); + }), + { numRuns: 100 } + ); + }); + + it('when filterState is metric, exactly devices with a matching metric_id pass', () => { + fc.assert( + fc.property(devicesArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (devices, ids) => { + const filterState = { type: 'metric', ids }; + const result = applyFilter(devices, filterState); + + const expected = devices.filter(d => + d.failing_metrics.some(m => ids.includes(m.metric_id)) + ); + expect(result).toEqual(expected); + }), + { numRuns: 100 } + ); + }); + + it('when filterState is nonmetric, exactly devices with that metric_id pass', () => { + fc.assert( + fc.property(devicesArb, metricIdArb, (devices, id) => { + const filterState = { type: 'nonmetric', id }; + const result = applyFilter(devices, filterState); + + const expected = devices.filter(d => + d.failing_metrics.some(m => m.metric_id === id) + ); + expect(result).toEqual(expected); + }), + { numRuns: 100 } + ); + }); +}); diff --git a/frontend/src/__tests__/compliance-nonmetric-mutual-exclusivity.property.test.js b/frontend/src/__tests__/compliance-nonmetric-mutual-exclusivity.property.test.js new file mode 100644 index 0000000..1184be6 --- /dev/null +++ b/frontend/src/__tests__/compliance-nonmetric-mutual-exclusivity.property.test.js @@ -0,0 +1,128 @@ +/** + * Property-Based Test: Filter state mutual exclusivity + * + * Feature: compliance-nonmetric-filters, Property 3: Filter state mutual exclusivity + * **Validates: Requirements 3.4, 3.5, 5.1** + * + * For any prior filter state and any filter action (metric card click, chip click, + * or clear), the resulting state is exactly one of: null, { type: 'metric', ids: [...] }, + * or { type: 'nonmetric', id: string } — never undefined, never both. + */ +import fc from 'fast-check'; + +// Replicate the filter state transition logic from CompliancePage +function applyFilterAction(currentState, action) { + if (action.type === 'clear') { + return null; + } + if (action.type === 'metric_click') { + const familyIds = action.ids; + // If currently active metric with same ids, toggle off + if (currentState?.type === 'metric' && + currentState.ids.length === familyIds.length && + familyIds.every(id => currentState.ids.includes(id))) { + return null; + } + return { type: 'metric', ids: familyIds }; + } + if (action.type === 'chip_click') { + const metricId = action.id; + // If currently active nonmetric with same id, toggle off + if (currentState?.type === 'nonmetric' && currentState.id === metricId) { + return null; + } + return { type: 'nonmetric', id: metricId }; + } + return currentState; +} + +function isValidFilterState(state) { + if (state === null) return true; + if (state && state.type === 'metric' && Array.isArray(state.ids) && state.ids.length > 0) return true; + if (state && state.type === 'nonmetric' && typeof state.id === 'string' && state.id.length > 0) return true; + return false; +} + +// Generators +const metricIdArb = fc.stringMatching(/^[A-Za-z0-9_.]{1,20}$/); + +const filterStateArb = fc.oneof( + fc.constant(null), + fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric', ids })), + metricIdArb.map(id => ({ type: 'nonmetric', id })) +); + +const filterActionArb = fc.oneof( + fc.constant({ type: 'clear' }), + fc.array(metricIdArb, { minLength: 1, maxLength: 5 }).map(ids => ({ type: 'metric_click', ids })), + metricIdArb.map(id => ({ type: 'chip_click', id })) +); + +describe('Compliance Non-Metric Filter — Property 3: Filter state mutual exclusivity', () => { + it('resulting state is always exactly one of null, metric, or nonmetric — never undefined or combined', () => { + fc.assert( + fc.property(filterStateArb, filterActionArb, (priorState, action) => { + const result = applyFilterAction(priorState, action); + + expect(result).not.toBeUndefined(); + expect(isValidFilterState(result)).toBe(true); + + // Never has both metric and nonmetric properties + if (result !== null) { + if (result.type === 'metric') { + expect(result).not.toHaveProperty('id'); + } + if (result.type === 'nonmetric') { + expect(result).not.toHaveProperty('ids'); + } + } + }), + { numRuns: 100 } + ); + }); + + it('clear action always results in null regardless of prior state', () => { + fc.assert( + fc.property(filterStateArb, (priorState) => { + const result = applyFilterAction(priorState, { type: 'clear' }); + expect(result).toBeNull(); + }), + { numRuns: 100 } + ); + }); + + it('metric click replaces any prior state with metric filter or null (toggle off)', () => { + fc.assert( + fc.property(filterStateArb, fc.array(metricIdArb, { minLength: 1, maxLength: 5 }), (priorState, ids) => { + const result = applyFilterAction(priorState, { type: 'metric_click', ids }); + + if (result === null) { + // Toggled off — prior must have been metric with same ids + expect(priorState?.type).toBe('metric'); + } else { + expect(result.type).toBe('metric'); + expect(result.ids).toEqual(ids); + } + }), + { numRuns: 100 } + ); + }); + + it('chip click replaces any prior state with nonmetric filter or null (toggle off)', () => { + fc.assert( + fc.property(filterStateArb, metricIdArb, (priorState, id) => { + const result = applyFilterAction(priorState, { type: 'chip_click', id }); + + if (result === null) { + // Toggled off — prior must have been nonmetric with same id + expect(priorState?.type).toBe('nonmetric'); + expect(priorState?.id).toBe(id); + } else { + expect(result.type).toBe('nonmetric'); + expect(result.id).toBe(id); + } + }), + { numRuns: 100 } + ); + }); +}); diff --git a/frontend/src/components/pages/CompliancePage.js b/frontend/src/components/pages/CompliancePage.js index db379d2..df27581 100644 --- a/frontend/src/components/pages/CompliancePage.js +++ b/frontend/src/components/pages/CompliancePage.js @@ -7,6 +7,7 @@ import ComplianceChartsPanel from './ComplianceChartsPanel'; import MetricInfoPanel from './MetricInfoPanel'; import VCLReportPage from './VCLReportPage'; import metricDefinitionsRaw from '../../data/metricDefinitions.json'; +import metricCategoriesConfig from '../../data/complianceCategories.json'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const TEAL = '#14B8A6'; @@ -87,6 +88,32 @@ function groupByMetricFamily(allEntries, team) { })); } +// --------------------------------------------------------------------------- +// Non-metric category derivation +// --------------------------------------------------------------------------- +function deriveNonMetricCategories(devices, summaryEntries, categoriesConfig) { + const summaryIds = new Set(summaryEntries.map(e => e.metric_id)); + const countMap = new Map(); + + for (const device of devices) { + if (!device.failing_metrics) continue; + const seen = new Set(); + for (const m of device.failing_metrics) { + if (!m.metric_id || summaryIds.has(m.metric_id) || seen.has(m.metric_id)) continue; + seen.add(m.metric_id); + countMap.set(m.metric_id, (countMap.get(m.metric_id) || 0) + 1); + } + } + + return [...countMap.entries()] + .map(([metricId, count]) => { + const categoryName = categoriesConfig[metricId] || null; + const color = (categoryName && CATEGORY_COLORS[categoryName]) || '#94A3B8'; + return { metricId, count, category: categoryName || 'Unknown', color }; + }) + .sort((a, b) => a.metricId.localeCompare(b.metricId)); +} + // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- @@ -246,6 +273,71 @@ function SeenBadge({ count }) { ); } +function FilterChip({ metricId, count, color, active, dimmed, onClick }) { + const label = metricId.length > 24 ? metricId.slice(0, 24) + '…' : metricId; + return ( + + ); +} + +function CategoryFilterBar({ categories, activeFilter, onFilterSelect, onClear, dimmed }) { + if (!categories || categories.length === 0) return null; + return ( +
+
+ Non-Metric Categories + {activeFilter && ( + + )} +
+
+ {categories.map(cat => ( + onFilterSelect(cat.metricId)} + /> + ))} +
+
+ ); +} + // --------------------------------------------------------------------------- // Main Page // --------------------------------------------------------------------------- @@ -256,7 +348,7 @@ export default function CompliancePage({ onNavigate }) { const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM'); const [activeTab, setActiveTab] = useState('active'); const [vclView, setVclView] = useState(false); - const [metricFilter, setMetricFilter] = useState(null); + const [filterState, setFilterState] = useState(null); const [hostSearch, setHostSearch] = useState(''); const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null }); const [devices, setDevices] = useState([]); @@ -297,7 +389,7 @@ export default function CompliancePage({ onNavigate }) { }, []); useEffect(() => { - setMetricFilter(null); + setFilterState(null); setHostSearch(''); setSelectedHost(null); fetchSummary(activeTeam); @@ -313,7 +405,7 @@ export default function CompliancePage({ onNavigate }) { }, [adminScope]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - setMetricFilter(null); + setFilterState(null); fetchDevices(activeTeam, activeTab); }, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps @@ -346,7 +438,12 @@ export default function CompliancePage({ onNavigate }) { // In-memory filters const filteredDevices = devices - .filter(d => !metricFilter || d.failing_metrics.some(m => metricFilter.includes(m.metric_id))) + .filter(d => { + if (!filterState) return true; + if (filterState.type === 'metric') return d.failing_metrics.some(m => filterState.ids.includes(m.metric_id)); + if (filterState.type === 'nonmetric') return d.failing_metrics.some(m => m.metric_id === filterState.id); + return true; + }) .filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase())); const families = groupByMetricFamily(summary.entries, activeTeam); @@ -495,8 +592,8 @@ export default function CompliancePage({ onNavigate }) {
Metric Health — click to filter - {metricFilter && ( - @@ -505,7 +602,7 @@ export default function CompliancePage({ onNavigate }) {
{families.map(family => { const familyIds = family.entries.map(e => e.metric_id); - const isActive = metricFilter !== null && metricFilter.length === familyIds.length && familyIds.every(id => metricFilter.includes(id)); + const isActive = filterState?.type === 'metric' && filterState.ids.length === familyIds.length && familyIds.every(id => filterState.ids.includes(id)); return (
setMetricFilter(isActive ? null : familyIds)} + onClick={() => setFilterState(isActive ? null : { type: 'metric', ids: familyIds })} onInfoClick={(metricId) => setInfoMetric(metricId)} definitionLookup={METRIC_DEFINITIONS} /> @@ -591,6 +688,27 @@ export default function CompliancePage({ onNavigate }) {
) : null} + {/* ── Non-metric category filter bar ─────────────────────── */} + {!vclView && !loading && (() => { + const nonMetricCategories = deriveNonMetricCategories(devices, summary.entries.filter(e => e.team === activeTeam), metricCategoriesConfig); + if (nonMetricCategories.length === 0) return null; + return ( + { + if (filterState?.type === 'nonmetric' && filterState.id === metricId) { + setFilterState(null); + } else { + setFilterState({ type: 'nonmetric', id: metricId }); + } + }} + onClear={() => setFilterState(null)} + dimmed={filterState?.type === 'metric'} + /> + ); + })()} + {/* ── Historical trend charts ──────────────────────────────── */} {!vclView && } @@ -677,7 +795,7 @@ export default function CompliancePage({ onNavigate }) {
) : filteredDevices.length === 0 ? (
- {lastUpload === null ? 'No reports uploaded yet' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'} + {lastUpload === null ? 'No reports uploaded yet' : filterState ? 'No devices match the selected filter' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
) : ( filteredDevices.map(device => ( @@ -906,4 +1024,4 @@ function DeviceRow({ device, selected, onClick }) { } // Named exports for testing -export { computeWorstStatus, groupByMetricFamily }; +export { computeWorstStatus, groupByMetricFamily, deriveNonMetricCategories, CATEGORY_COLORS }; diff --git a/frontend/src/data/complianceCategories.json b/frontend/src/data/complianceCategories.json new file mode 100644 index 0000000..c7fb779 --- /dev/null +++ b/frontend/src/data/complianceCategories.json @@ -0,0 +1,25 @@ +{ + "1.1.1": "Logging & Monitoring", + "1.1.3": "Logging & Monitoring", + "1.4.1": "Logging & Monitoring", + "2.3.4i": "Vulnerability Management", + "2.3.6i": "Vulnerability Management", + "2.3.8i": "Vulnerability Management", + "5.2.4": "Access & MFA", + "5.2.5": "Access & MFA", + "5.2.6": "Access & MFA", + "5.2.7": "Access & MFA", + "5.2.8": "Access & MFA", + "5.3.4": "Endpoint Protection", + "5.5.4i": "Vulnerability Management", + "5.5.5": "Decommissioned Assets", + "5.8.1": "Application Security", + "7.1.1": "Logging & Monitoring", + "7.1.4": "Logging & Monitoring", + "7.6.13": "Disaster Recovery", + "7.6.16": "Disaster Recovery", + "Missing_AppID": "Asset Data Quality", + "Missing_DF": "Asset Data Quality", + "Missing_OS": "Asset Data Quality", + "5.5.2": "Other" +}