Files
cve-dashboard/frontend/src/components/pages/__tests__/atlasMetricsDonuts.property.test.js

401 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 15 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 13 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 },
);
});
});