Add Atlas metrics reporting, security audit tracker, and spec documents
This commit is contained in:
@@ -507,6 +507,229 @@ function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atlas Donut Charts — Coverage, Plan Type, Plan Status
|
||||
// ---------------------------------------------------------------------------
|
||||
const PLAN_TYPE_DEFS = [
|
||||
{ key: 'decommission', label: 'Decommission', color: '#EF4444' },
|
||||
{ key: 'remediation', label: 'Remediation', color: '#0EA5E9' },
|
||||
{ key: 'false_positive', label: 'False Positive', color: '#A855F7' },
|
||||
{ key: 'risk_acceptance', label: 'Risk Acceptance', color: '#F59E0B' },
|
||||
{ key: 'scan_exclusion', label: 'Scan Exclusion', color: '#64748B' },
|
||||
];
|
||||
|
||||
function getStatusColor(status) {
|
||||
if (status === 'active') return '#10B981';
|
||||
if (status === 'expired') return '#EF4444';
|
||||
if (status === 'completed') return '#0EA5E9';
|
||||
return '#64748B';
|
||||
}
|
||||
|
||||
function AtlasCoverageDonut({ hostsWithPlans, hostsWithoutPlans, totalHosts }) {
|
||||
const SIZE = 180;
|
||||
const CX = SIZE / 2;
|
||||
const CY = SIZE / 2;
|
||||
const OUTER = 72;
|
||||
const INNER = 48;
|
||||
|
||||
if (totalHosts === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — run Atlas Sync</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const segments = [
|
||||
{ label: 'With Plans', count: hostsWithPlans, color: '#10B981', start: 0, end: (hostsWithPlans / totalHosts) * 360 },
|
||||
{ label: 'Without Plans', count: hostsWithoutPlans, color: '#F59E0B', start: (hostsWithPlans / totalHosts) * 360, end: 360 },
|
||||
].filter((s) => s.count > 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||
{segments.map((seg) => (
|
||||
<path
|
||||
key={seg.label}
|
||||
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||
fill={seg.color}
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{totalHosts.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
HOSTS
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{segments.map((seg) => (
|
||||
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{seg.label}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
|
||||
{seg.count.toLocaleString()}
|
||||
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
|
||||
({((seg.count / totalHosts) * 100).toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AtlasPlanTypeDonut({ plansByType, totalPlans }) {
|
||||
const SIZE = 180;
|
||||
const CX = SIZE / 2;
|
||||
const CY = SIZE / 2;
|
||||
const OUTER = 72;
|
||||
const INNER = 48;
|
||||
|
||||
if (totalPlans === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
const segments = PLAN_TYPE_DEFS.map((def) => {
|
||||
const count = plansByType[def.key] || 0;
|
||||
const start = cursor;
|
||||
const end = count > 0 ? cursor + (count / totalPlans) * 360 : cursor;
|
||||
if (count > 0) cursor = end;
|
||||
return { ...def, count, start, end };
|
||||
}).filter(s => s.count > 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||
{segments.map((seg) => (
|
||||
<path
|
||||
key={seg.key}
|
||||
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||
fill={seg.color}
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{totalPlans.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
PLANS
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{segments.map((seg) => (
|
||||
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{seg.label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
{seg.count}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
({((seg.count / totalPlans) * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AtlasPlanStatusDonut({ plansByStatus, totalPlans }) {
|
||||
const SIZE = 180;
|
||||
const CX = SIZE / 2;
|
||||
const CY = SIZE / 2;
|
||||
const OUTER = 72;
|
||||
const INNER = 48;
|
||||
|
||||
if (totalPlans === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(plansByStatus).filter(([, count]) => count > 0);
|
||||
|
||||
let cursor = 0;
|
||||
const segments = entries.map(([status, count]) => {
|
||||
const start = cursor;
|
||||
const end = cursor + (count / totalPlans) * 360;
|
||||
cursor = end;
|
||||
return {
|
||||
key: status,
|
||||
label: status.charAt(0).toUpperCase() + status.slice(1),
|
||||
color: getStatusColor(status),
|
||||
count,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||
{segments.map((seg) => (
|
||||
<path
|
||||
key={seg.key}
|
||||
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||
fill={seg.color}
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{totalPlans.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
STATUS
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{segments.map((seg) => (
|
||||
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{seg.label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
{seg.count}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
({((seg.count / totalPlans) * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIcon({ colKey, sort }) {
|
||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||
return sort.dir === 'asc'
|
||||
@@ -3632,6 +3855,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const hoverTimerRef = useRef(null);
|
||||
|
||||
// Atlas action plan state
|
||||
const [metricsTab, setMetricsTab] = useState('ivanti');
|
||||
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
||||
const [atlasSyncing, setAtlasSyncing] = useState(false);
|
||||
const [atlasError, setAtlasError] = useState(null);
|
||||
@@ -3640,6 +3864,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const [atlasSelectedHostName, setAtlasSelectedHostName] = useState(null);
|
||||
const [atlasSelectedFindingId, setAtlasSelectedFindingId] = useState(null);
|
||||
|
||||
// Atlas metrics state (for Atlas Coverage tab donut charts)
|
||||
const [atlasMetrics, setAtlasMetrics] = useState(null);
|
||||
const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false);
|
||||
const [atlasMetricsError, setAtlasMetricsError] = useState(null);
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
@@ -3758,6 +3987,25 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAtlasMetrics = useCallback(async () => {
|
||||
setAtlasMetricsLoading(true);
|
||||
setAtlasMetricsError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/atlas/metrics`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAtlasMetrics(data);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setAtlasMetricsError(err.error || 'Failed to fetch Atlas metrics');
|
||||
}
|
||||
} catch (err) {
|
||||
setAtlasMetricsError(err.message);
|
||||
} finally {
|
||||
setAtlasMetricsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFindings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -3799,6 +4047,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
fetchQueue();
|
||||
fetchFpSubmissions();
|
||||
fetchAtlasStatus();
|
||||
fetchAtlasMetrics();
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Set/clear a single column filter
|
||||
@@ -4234,7 +4483,41 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245,158,11,0.4)', margin: 0 }}>
|
||||
Metric Graphs
|
||||
</h2>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }} role="tablist">
|
||||
{[{ key: 'ivanti', label: 'Ivanti Findings' }, { key: 'atlas', label: 'Atlas Coverage' }].map(tab => {
|
||||
const isActive = metricsTab === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
onClick={() => setMetricsTab(tab.key)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setMetricsTab(tab.key); }}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: isActive ? '2px solid #F59E0B' : '2px solid transparent',
|
||||
color: isActive ? '#F59E0B' : '#64748B',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
padding: '0.375rem 0.75rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s, color 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'rgba(245, 158, 11, 0.06)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel">
|
||||
{metricsTab === 'ivanti' && (
|
||||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{/* Open vs Closed donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
@@ -4289,12 +4572,68 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
<FPWorkflowDonut counts={fpCounts.idCounts} total={fpCounts.idTotal} centerLabel="FP TICKETS" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metricsTab === 'atlas' && (
|
||||
(atlasMetricsLoading || (!atlasMetrics && !atlasMetricsError)) ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 0', gap: '0.5rem' }}>
|
||||
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
) : atlasMetricsError ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem', gap: '0.375rem' }}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>{atlasMetricsError}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{/* Host Coverage donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Host Coverage
|
||||
</div>
|
||||
<AtlasCoverageDonut
|
||||
hostsWithPlans={atlasMetrics.hostsWithPlans}
|
||||
hostsWithoutPlans={atlasMetrics.hostsWithoutPlans}
|
||||
totalHosts={atlasMetrics.totalHosts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||
|
||||
{/* Plan Types donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Plan Types
|
||||
</div>
|
||||
<AtlasPlanTypeDonut
|
||||
plansByType={atlasMetrics.plansByType}
|
||||
totalPlans={atlasMetrics.totalPlans}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||
|
||||
{/* Plan Status donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Plan Status
|
||||
</div>
|
||||
<AtlasPlanStatusDonut
|
||||
plansByStatus={atlasMetrics.plansByStatus}
|
||||
totalPlans={atlasMetrics.totalPlans}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
Panel 1.5 — Open vs Closed trend over time
|
||||
---------------------------------------------------------------- */}
|
||||
<IvantiCountsChart />
|
||||
{metricsTab === 'ivanti' && <IvantiCountsChart />}
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
Panel 2 — Findings table
|
||||
@@ -4483,6 +4822,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
throw new Error(data.error || 'Atlas sync failed');
|
||||
}
|
||||
await fetchAtlasStatus();
|
||||
await fetchAtlasMetrics();
|
||||
} catch (err) {
|
||||
setAtlasError(err.message);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Feature: atlas-metrics-report, Property 1: Metrics aggregation correctness
|
||||
|
||||
import fc from 'fast-check';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock backend dependencies so we can import the pure function
|
||||
// without pulling in Express, SQLite, etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
jest.mock('express', () => ({ Router: jest.fn(() => ({ get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn() })) }));
|
||||
jest.mock('../../../../../backend/middleware/auth', () => ({ requireGroup: jest.fn() }), { virtual: true });
|
||||
jest.mock('../../../../../backend/helpers/auditLog', () => jest.fn(), { virtual: true });
|
||||
jest.mock('../../../../../backend/helpers/atlasApi', () => ({
|
||||
isConfigured: false,
|
||||
atlasGet: jest.fn(),
|
||||
atlasPut: jest.fn(),
|
||||
atlasPatch: jest.fn(),
|
||||
atlasPost: jest.fn(),
|
||||
}), { virtual: true });
|
||||
|
||||
// Now import the pure function
|
||||
const { aggregateAtlasMetrics } = require('../../../../../backend/routes/atlas');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const PLAN_STATUSES = ['active', 'expired', 'completed'];
|
||||
|
||||
/** Generate a single plan object with plan_type and status */
|
||||
const planArb = fc.record({
|
||||
plan_type: fc.constantFrom(...PLAN_TYPES),
|
||||
status: fc.constantFrom(...PLAN_STATUSES),
|
||||
});
|
||||
|
||||
/** Generate a valid plans_json string (JSON array of plan objects) */
|
||||
const validPlansJsonArb = fc
|
||||
.array(planArb, { minLength: 0, maxLength: 10 })
|
||||
.map((plans) => JSON.stringify(plans));
|
||||
|
||||
/** Generate an invalid JSON string that will fail JSON.parse */
|
||||
const invalidPlansJsonArb = fc.constantFrom(
|
||||
'{bad json',
|
||||
'not json at all',
|
||||
'{{[',
|
||||
'',
|
||||
'undefined',
|
||||
);
|
||||
|
||||
/** Generate a plans_json value — either valid JSON or invalid */
|
||||
const plansJsonArb = fc.oneof(
|
||||
{ weight: 3, arbitrary: validPlansJsonArb },
|
||||
{ weight: 1, arbitrary: invalidPlansJsonArb },
|
||||
);
|
||||
|
||||
/** Generate a single cache row */
|
||||
const cacheRowArb = fc.record({
|
||||
has_action_plan: fc.constantFrom(0, 1),
|
||||
plans_json: plansJsonArb,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: manually compute expected metrics for comparison
|
||||
// ---------------------------------------------------------------------------
|
||||
function computeExpected(rows) {
|
||||
const expected = {
|
||||
totalHosts: rows.length,
|
||||
hostsWithPlans: 0,
|
||||
hostsWithoutPlans: 0,
|
||||
plansByType: {},
|
||||
plansByStatus: {},
|
||||
totalPlans: 0,
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.has_action_plan === 1) {
|
||||
expected.hostsWithPlans++;
|
||||
} else {
|
||||
expected.hostsWithoutPlans++;
|
||||
}
|
||||
|
||||
let plans;
|
||||
try {
|
||||
plans = JSON.parse(row.plans_json);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(plans)) continue;
|
||||
|
||||
for (const plan of plans) {
|
||||
expected.totalPlans++;
|
||||
if (plan.plan_type) {
|
||||
expected.plansByType[plan.plan_type] = (expected.plansByType[plan.plan_type] || 0) + 1;
|
||||
}
|
||||
if (plan.status) {
|
||||
expected.plansByStatus[plan.status] = (expected.plansByStatus[plan.status] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expected;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 1: Metrics aggregation correctness
|
||||
// Validates: Requirements 1.3, 1.4, 1.5
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 1: Metrics aggregation correctness', () => {
|
||||
test('totalHosts equals rows.length', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
expect(result.totalHosts).toBe(rows.length);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('hostsWithPlans + hostsWithoutPlans equals totalHosts', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
expect(result.hostsWithPlans + result.hostsWithoutPlans).toBe(result.totalHosts);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('hostsWithPlans equals count of rows where has_action_plan === 1', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
const expectedWithPlans = rows.filter((r) => r.has_action_plan === 1).length;
|
||||
expect(result.hostsWithPlans).toBe(expectedWithPlans);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('totalPlans equals sum of valid plan array lengths', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
const expected = computeExpected(rows);
|
||||
expect(result.totalPlans).toBe(expected.totalPlans);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('plansByType and plansByStatus counts match individual plan fields', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(cacheRowArb, { minLength: 0, maxLength: 30 }),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
const expected = computeExpected(rows);
|
||||
expect(result.plansByType).toEqual(expected.plansByType);
|
||||
expect(result.plansByStatus).toEqual(expected.plansByStatus);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('rows with invalid JSON are counted in host totals but excluded from plan counts', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
has_action_plan: fc.constantFrom(0, 1),
|
||||
plans_json: invalidPlansJsonArb,
|
||||
}),
|
||||
{ minLength: 1, maxLength: 20 },
|
||||
),
|
||||
(rows) => {
|
||||
const result = aggregateAtlasMetrics(rows);
|
||||
|
||||
// Host totals should still be correct
|
||||
expect(result.totalHosts).toBe(rows.length);
|
||||
expect(result.hostsWithPlans + result.hostsWithoutPlans).toBe(rows.length);
|
||||
|
||||
// No plans should be counted since all JSON is invalid
|
||||
expect(result.totalPlans).toBe(0);
|
||||
expect(result.plansByType).toEqual({});
|
||||
expect(result.plansByStatus).toEqual({});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
// Feature: atlas-metrics-report
|
||||
// Property tests for Atlas donut chart data correctness and color assignment
|
||||
|
||||
import fc from 'fast-check';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Since the donut components and getStatusColor are defined inline in
|
||||
// ReportingPage.js and not exported, we replicate the exact data
|
||||
// transformation logic here and test the mathematical properties directly.
|
||||
// This validates that the formulas used in the components are correct
|
||||
// for all valid inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replicated logic from ReportingPage.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Coverage donut segment computation — mirrors AtlasCoverageDonut logic.
|
||||
*/
|
||||
function computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans) {
|
||||
const totalHosts = hostsWithPlans + hostsWithoutPlans;
|
||||
if (totalHosts === 0) return { totalHosts, segments: [] };
|
||||
|
||||
const segments = [
|
||||
{ label: 'With Plans', count: hostsWithPlans, color: '#10B981', percentage: ((hostsWithPlans / totalHosts) * 100).toFixed(1) },
|
||||
{ label: 'Without Plans', count: hostsWithoutPlans, color: '#F59E0B', percentage: ((hostsWithoutPlans / totalHosts) * 100).toFixed(1) },
|
||||
].filter((s) => s.count > 0);
|
||||
|
||||
return { totalHosts, segments };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan type definitions — mirrors PLAN_TYPE_DEFS in ReportingPage.js.
|
||||
*/
|
||||
const PLAN_TYPE_DEFS = [
|
||||
{ key: 'decommission', label: 'Decommission', color: '#EF4444' },
|
||||
{ key: 'remediation', label: 'Remediation', color: '#0EA5E9' },
|
||||
{ key: 'false_positive', label: 'False Positive', color: '#A855F7' },
|
||||
{ key: 'risk_acceptance', label: 'Risk Acceptance', color: '#F59E0B' },
|
||||
{ key: 'scan_exclusion', label: 'Scan Exclusion', color: '#64748B' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Plan type donut segment computation — mirrors AtlasPlanTypeDonut logic.
|
||||
*/
|
||||
function computePlanTypeDonutData(plansByType) {
|
||||
const totalPlans = Object.values(plansByType).reduce((sum, c) => sum + c, 0);
|
||||
if (totalPlans === 0) return { totalPlans, segments: [] };
|
||||
|
||||
const segments = PLAN_TYPE_DEFS.map((def) => {
|
||||
const count = plansByType[def.key] || 0;
|
||||
return { ...def, count, percentage: ((count / totalPlans) * 100).toFixed(0) };
|
||||
}).filter((s) => s.count > 0);
|
||||
|
||||
return { totalPlans, segments };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan status color assignment — mirrors getStatusColor in ReportingPage.js.
|
||||
*/
|
||||
function getStatusColor(status) {
|
||||
if (status === 'active') return '#10B981';
|
||||
if (status === 'expired') return '#EF4444';
|
||||
if (status === 'completed') return '#0EA5E9';
|
||||
return '#64748B';
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan status donut segment computation — mirrors AtlasPlanStatusDonut logic.
|
||||
*/
|
||||
function computePlanStatusDonutData(plansByStatus) {
|
||||
const totalPlans = Object.values(plansByStatus).reduce((sum, c) => sum + c, 0);
|
||||
if (totalPlans === 0) return { totalPlans, segments: [] };
|
||||
|
||||
const entries = Object.entries(plansByStatus).filter(([, count]) => count > 0);
|
||||
const segments = entries.map(([status, count]) => ({
|
||||
key: status,
|
||||
label: status.charAt(0).toUpperCase() + status.slice(1),
|
||||
color: getStatusColor(status),
|
||||
count,
|
||||
percentage: ((count / totalPlans) * 100).toFixed(0),
|
||||
}));
|
||||
|
||||
return { totalPlans, segments };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KNOWN_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||
const KNOWN_STATUSES = ['active', 'expired', 'completed'];
|
||||
|
||||
/**
|
||||
* Generate a pair of non-negative integers where at least one is > 0.
|
||||
*/
|
||||
const coveragePairArb = fc
|
||||
.tuple(
|
||||
fc.nat({ max: 10000 }),
|
||||
fc.nat({ max: 10000 }),
|
||||
)
|
||||
.filter(([a, b]) => a + b > 0);
|
||||
|
||||
/**
|
||||
* Generate a plansByType object with 1–5 known plan type keys mapped to positive integers.
|
||||
*/
|
||||
const plansByTypeArb = fc
|
||||
.subarray(KNOWN_PLAN_TYPES, { minLength: 1, maxLength: 5 })
|
||||
.chain((keys) =>
|
||||
fc.tuple(...keys.map(() => fc.integer({ min: 1, max: 5000 }))).map((counts) => {
|
||||
const obj = {};
|
||||
keys.forEach((key, i) => { obj[key] = counts[i]; });
|
||||
return obj;
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate a plansByStatus object with 1–3 known status keys mapped to positive integers.
|
||||
* Also allows arbitrary unknown status strings.
|
||||
*/
|
||||
const statusKeyArb = fc.oneof(
|
||||
{ weight: 3, arbitrary: fc.constantFrom(...KNOWN_STATUSES) },
|
||||
{ weight: 1, arbitrary: fc.stringMatching(/^[a-z_]{2,15}$/).filter((s) => !KNOWN_STATUSES.includes(s)) },
|
||||
);
|
||||
|
||||
const plansByStatusArb = fc
|
||||
.array(
|
||||
fc.tuple(statusKeyArb, fc.integer({ min: 1, max: 5000 })),
|
||||
{ minLength: 1, maxLength: 4 },
|
||||
)
|
||||
.map((pairs) => {
|
||||
const obj = {};
|
||||
for (const [key, count] of pairs) {
|
||||
// Use first occurrence if duplicate keys generated
|
||||
if (!(key in obj)) obj[key] = count;
|
||||
}
|
||||
return obj;
|
||||
})
|
||||
.filter((obj) => Object.keys(obj).length >= 1);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 2: Coverage donut data correctness
|
||||
// Validates: Requirements 3.3, 3.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 2: Coverage donut data correctness', () => {
|
||||
test('center text (totalHosts) equals hostsWithPlans + hostsWithoutPlans', () => {
|
||||
fc.assert(
|
||||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||||
const { totalHosts } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||||
expect(totalHosts).toBe(hostsWithPlans + hostsWithoutPlans);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend percentages equal (count / totalHosts) * 100 for each segment', () => {
|
||||
fc.assert(
|
||||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||||
const totalHosts = hostsWithPlans + hostsWithoutPlans;
|
||||
const { segments } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||||
|
||||
for (const seg of segments) {
|
||||
const expectedPct = ((seg.count / totalHosts) * 100).toFixed(1);
|
||||
expect(seg.percentage).toBe(expectedPct);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('segments only include entries with count > 0', () => {
|
||||
fc.assert(
|
||||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||||
const { segments } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||||
|
||||
for (const seg of segments) {
|
||||
expect(seg.count).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// If one value is 0, only one segment should appear
|
||||
if (hostsWithPlans === 0) {
|
||||
expect(segments.length).toBe(1);
|
||||
expect(segments[0].label).toBe('Without Plans');
|
||||
} else if (hostsWithoutPlans === 0) {
|
||||
expect(segments.length).toBe(1);
|
||||
expect(segments[0].label).toBe('With Plans');
|
||||
} else {
|
||||
expect(segments.length).toBe(2);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('segment percentages sum to approximately 100', () => {
|
||||
fc.assert(
|
||||
fc.property(coveragePairArb, ([hostsWithPlans, hostsWithoutPlans]) => {
|
||||
const { segments } = computeCoverageDonutData(hostsWithPlans, hostsWithoutPlans);
|
||||
const totalPct = segments.reduce((sum, s) => sum + parseFloat(s.percentage), 0);
|
||||
// Allow small rounding tolerance due to toFixed(1)
|
||||
expect(totalPct).toBeCloseTo(100, 0);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 3: Plan type donut data correctness
|
||||
// Validates: Requirements 4.3, 4.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 3: Plan type donut data correctness', () => {
|
||||
test('center text (totalPlans) equals sum of all plan type counts', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByTypeArb, (plansByType) => {
|
||||
const expectedTotal = Object.values(plansByType).reduce((s, c) => s + c, 0);
|
||||
const { totalPlans } = computePlanTypeDonutData(plansByType);
|
||||
expect(totalPlans).toBe(expectedTotal);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend entries match input — only types with count > 0 appear', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByTypeArb, (plansByType) => {
|
||||
const { segments } = computePlanTypeDonutData(plansByType);
|
||||
|
||||
// Every segment should have count > 0
|
||||
for (const seg of segments) {
|
||||
expect(seg.count).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Every key in plansByType with count > 0 should appear in segments
|
||||
const segmentKeys = new Set(segments.map((s) => s.key));
|
||||
for (const [key, count] of Object.entries(plansByType)) {
|
||||
if (count > 0 && KNOWN_PLAN_TYPES.includes(key)) {
|
||||
expect(segmentKeys.has(key)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Every segment key should be in the input
|
||||
for (const seg of segments) {
|
||||
expect(plansByType[seg.key]).toBeDefined();
|
||||
expect(plansByType[seg.key]).toBe(seg.count);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend percentages equal (count / totalPlans) * 100 for each entry', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByTypeArb, (plansByType) => {
|
||||
const { totalPlans, segments } = computePlanTypeDonutData(plansByType);
|
||||
|
||||
for (const seg of segments) {
|
||||
const expectedPct = ((seg.count / totalPlans) * 100).toFixed(0);
|
||||
expect(seg.percentage).toBe(expectedPct);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 4: Plan status donut data correctness
|
||||
// Validates: Requirements 5.3, 5.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 4: Plan status donut data correctness', () => {
|
||||
test('center text (totalPlans) equals sum of all status counts', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||||
const expectedTotal = Object.values(plansByStatus).reduce((s, c) => s + c, 0);
|
||||
const { totalPlans } = computePlanStatusDonutData(plansByStatus);
|
||||
expect(totalPlans).toBe(expectedTotal);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend entries match input — only statuses with count > 0 appear', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||||
const { segments } = computePlanStatusDonutData(plansByStatus);
|
||||
|
||||
// Every segment should have count > 0
|
||||
for (const seg of segments) {
|
||||
expect(seg.count).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Every key in plansByStatus with count > 0 should appear in segments
|
||||
const segmentKeys = new Set(segments.map((s) => s.key));
|
||||
for (const [key, count] of Object.entries(plansByStatus)) {
|
||||
if (count > 0) {
|
||||
expect(segmentKeys.has(key)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Every segment key should be in the input with matching count
|
||||
for (const seg of segments) {
|
||||
expect(plansByStatus[seg.key]).toBeDefined();
|
||||
expect(plansByStatus[seg.key]).toBe(seg.count);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('legend percentages equal (count / totalPlans) * 100 for each entry', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||||
const { totalPlans, segments } = computePlanStatusDonutData(plansByStatus);
|
||||
|
||||
for (const seg of segments) {
|
||||
const expectedPct = ((seg.count / totalPlans) * 100).toFixed(0);
|
||||
expect(seg.percentage).toBe(expectedPct);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('segment labels are capitalized versions of status keys', () => {
|
||||
fc.assert(
|
||||
fc.property(plansByStatusArb, (plansByStatus) => {
|
||||
const { segments } = computePlanStatusDonutData(plansByStatus);
|
||||
|
||||
for (const seg of segments) {
|
||||
const expectedLabel = seg.key.charAt(0).toUpperCase() + seg.key.slice(1);
|
||||
expect(seg.label).toBe(expectedLabel);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 5: Plan status color assignment
|
||||
// Validates: Requirements 5.2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Property 5: Plan status color assignment', () => {
|
||||
test('known statuses return their specified colors', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom('active', 'expired', 'completed'),
|
||||
(status) => {
|
||||
const color = getStatusColor(status);
|
||||
if (status === 'active') expect(color).toBe('#10B981');
|
||||
if (status === 'expired') expect(color).toBe('#EF4444');
|
||||
if (status === 'completed') expect(color).toBe('#0EA5E9');
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('arbitrary unknown strings return the fallback color #64748B', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 0, maxLength: 50 }).filter(
|
||||
(s) => s !== 'active' && s !== 'expired' && s !== 'completed',
|
||||
),
|
||||
(status) => {
|
||||
const color = getStatusColor(status);
|
||||
expect(color).toBe('#64748B');
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
test('mixed known and unknown statuses all return correct colors', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.constantFrom('active', 'expired', 'completed'),
|
||||
fc.string({ minLength: 0, maxLength: 30 }),
|
||||
),
|
||||
(status) => {
|
||||
const color = getStatusColor(status);
|
||||
const expected =
|
||||
status === 'active' ? '#10B981' :
|
||||
status === 'expired' ? '#EF4444' :
|
||||
status === 'completed' ? '#0EA5E9' :
|
||||
'#64748B';
|
||||
expect(color).toBe(expected);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user