207 lines
6.5 KiB
JavaScript
207 lines
6.5 KiB
JavaScript
|
|
// 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 },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|