372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
/**
|
|
* Unit and Integration Tests: VCL Aggregated Burndown
|
|
*
|
|
* Feature: vcl-aggregated-burndown
|
|
*
|
|
* Tests cover:
|
|
* - deduplicateByHostname edge cases
|
|
* - computeAggregatedBurndown edge cases
|
|
* - GET /burndown endpoint with mocked DB
|
|
* - Empty DB returns zero/empty response
|
|
* - All-blocker scenario
|
|
* - Auth middleware enforcement
|
|
*/
|
|
|
|
const http = require('http');
|
|
const express = require('express');
|
|
|
|
// Mock auth middleware
|
|
jest.mock('../middleware/auth', () => ({
|
|
requireAuth: () => (req, res, next) => {
|
|
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
|
next();
|
|
},
|
|
requireGroup: () => (req, res, next) => next(),
|
|
}));
|
|
|
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
jest.mock('../helpers/ivantiApi', () => ({
|
|
ivantiFormPost: jest.fn(),
|
|
ivantiPost: jest.fn(),
|
|
}));
|
|
|
|
// Mock driftChecker
|
|
jest.mock('../helpers/driftChecker', () => ({
|
|
loadConfig: jest.fn(() => ({})),
|
|
compareSchemaToDrift: jest.fn(() => null),
|
|
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
|
}));
|
|
|
|
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 {
|
|
deduplicateByHostname,
|
|
computeAggregatedBurndown,
|
|
} = require('../helpers/vclHelpers');
|
|
|
|
const { createVCLMultiVerticalRouter } = require('../routes/vclMultiVertical');
|
|
|
|
// --- HTTP helper ---
|
|
|
|
function request(server, method, path, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const addr = server.address();
|
|
const options = {
|
|
hostname: '127.0.0.1',
|
|
port: addr.port,
|
|
path,
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
};
|
|
|
|
const req = http.request(options, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
const rawBody = Buffer.concat(chunks).toString();
|
|
let json;
|
|
try { json = JSON.parse(rawBody); } catch (e) { json = null; }
|
|
resolve({ statusCode: res.statusCode, body: json });
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
if (body) req.write(JSON.stringify(body));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// --- Setup ---
|
|
|
|
let app, server;
|
|
|
|
beforeAll((done) => {
|
|
app = express();
|
|
app.use(express.json());
|
|
|
|
const mockUpload = { array: () => (req, res, next) => next() };
|
|
const router = createVCLMultiVerticalRouter(mockUpload);
|
|
app.use('/api/compliance/vcl-multi', router);
|
|
|
|
server = app.listen(0, '127.0.0.1', done);
|
|
});
|
|
|
|
afterAll((done) => {
|
|
server.close(done);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockPool.query.mockReset();
|
|
mockPool.connect.mockReset();
|
|
mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
|
});
|
|
|
|
// --- deduplicateByHostname unit tests ---
|
|
|
|
describe('deduplicateByHostname', () => {
|
|
it('returns empty array for empty input', () => {
|
|
expect(deduplicateByHostname([])).toEqual([]);
|
|
});
|
|
|
|
it('passes through single item unchanged', () => {
|
|
const items = [{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }];
|
|
const result = deduplicateByHostname(items);
|
|
expect(result).toEqual([{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }]);
|
|
});
|
|
|
|
it('deduplicates by hostname keeping earliest non-null date', () => {
|
|
const items = [
|
|
{ hostname: 'srv-001', resolution_date: '2026-08-15', vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' },
|
|
{ hostname: 'srv-001', resolution_date: '2026-07-10', vertical: 'TSI' },
|
|
];
|
|
const result = deduplicateByHostname(items);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].hostname).toBe('srv-001');
|
|
expect(result[0].resolution_date).toBe('2026-06-01');
|
|
});
|
|
|
|
it('returns null date when all entries for a hostname have null dates', () => {
|
|
const items = [
|
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'SDIT_CISO' },
|
|
];
|
|
const result = deduplicateByHostname(items);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].resolution_date).toBeNull();
|
|
});
|
|
|
|
it('picks earliest non-null date even when some entries are null', () => {
|
|
const items = [
|
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-001', resolution_date: '2026-09-01', vertical: 'SDIT_CISO' },
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' },
|
|
];
|
|
const result = deduplicateByHostname(items);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].resolution_date).toBe('2026-06-15');
|
|
});
|
|
|
|
it('preserves vertical from the first entry', () => {
|
|
const items = [
|
|
{ hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' },
|
|
];
|
|
const result = deduplicateByHostname(items);
|
|
expect(result[0].vertical).toBe('NTS_AEO');
|
|
});
|
|
});
|
|
|
|
// --- computeAggregatedBurndown unit tests ---
|
|
|
|
describe('computeAggregatedBurndown', () => {
|
|
it('returns zero/empty for empty input', () => {
|
|
const result = computeAggregatedBurndown([]);
|
|
expect(result.total).toBe(0);
|
|
expect(result.blockers).toBe(0);
|
|
expect(result.with_dates).toBe(0);
|
|
expect(result.monthly).toEqual({});
|
|
expect(result.projection).toEqual({});
|
|
expect(result.projected_clear_date).toBeNull();
|
|
expect(result.by_vertical).toEqual([]);
|
|
});
|
|
|
|
it('all blockers — with_dates=0, monthly={}, projected_clear_date=null', () => {
|
|
const devices = [
|
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' },
|
|
];
|
|
const result = computeAggregatedBurndown(devices);
|
|
expect(result.total).toBe(3);
|
|
expect(result.blockers).toBe(3);
|
|
expect(result.with_dates).toBe(0);
|
|
expect(result.monthly).toEqual({});
|
|
expect(result.projected_clear_date).toBeNull();
|
|
});
|
|
|
|
it('single device with date — correct monthly bucket and projection', () => {
|
|
const devices = [
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
];
|
|
const result = computeAggregatedBurndown(devices);
|
|
expect(result.total).toBe(1);
|
|
expect(result.blockers).toBe(0);
|
|
expect(result.with_dates).toBe(1);
|
|
expect(result.monthly).toEqual({ '2026-06': 1 });
|
|
expect(result.projection).toEqual({ '2026-06': { remediated: 1, remaining: 0 } });
|
|
expect(result.projected_clear_date).toBe('2026-06');
|
|
});
|
|
|
|
it('mixed blockers and in-progress — projected_clear_date is null', () => {
|
|
const devices = [
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
|
];
|
|
const result = computeAggregatedBurndown(devices);
|
|
expect(result.total).toBe(2);
|
|
expect(result.blockers).toBe(1);
|
|
expect(result.with_dates).toBe(1);
|
|
expect(result.projected_clear_date).toBeNull();
|
|
});
|
|
|
|
it('multiple months — correct cumulative projection', () => {
|
|
const devices = [
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-002', resolution_date: '2026-06-20', vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-003', resolution_date: '2026-07-10', vertical: 'SDIT_CISO' },
|
|
{ hostname: 'srv-004', resolution_date: '2026-08-01', vertical: 'TSI' },
|
|
];
|
|
const result = computeAggregatedBurndown(devices);
|
|
expect(result.total).toBe(4);
|
|
expect(result.monthly).toEqual({ '2026-06': 2, '2026-07': 1, '2026-08': 1 });
|
|
expect(result.projection['2026-06'].remaining).toBe(2); // 4 - 2
|
|
expect(result.projection['2026-07'].remaining).toBe(1); // 4 - 3
|
|
expect(result.projection['2026-08'].remaining).toBe(0); // 4 - 4
|
|
expect(result.projected_clear_date).toBe('2026-08');
|
|
});
|
|
|
|
it('by_vertical sorted descending by total, omits zero-total verticals', () => {
|
|
const devices = [
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' },
|
|
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-003', resolution_date: null, vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-004', resolution_date: '2026-07-01', vertical: 'NTS_AEO' },
|
|
];
|
|
const result = computeAggregatedBurndown(devices);
|
|
expect(result.by_vertical[0].vertical).toBe('NTS_AEO');
|
|
expect(result.by_vertical[0].total).toBe(3);
|
|
expect(result.by_vertical[1].vertical).toBe('TSI');
|
|
expect(result.by_vertical[1].total).toBe(1);
|
|
});
|
|
});
|
|
|
|
// --- GET /burndown endpoint tests ---
|
|
|
|
describe('GET /api/compliance/vcl-multi/burndown', () => {
|
|
it('returns zero/empty response when no active devices exist', async () => {
|
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
|
|
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.total_non_compliant).toBe(0);
|
|
expect(res.body.blockers).toBe(0);
|
|
expect(res.body.with_dates).toBe(0);
|
|
expect(res.body.monthly_forecast).toEqual({});
|
|
expect(res.body.projected_clear_date).toBeNull();
|
|
expect(res.body.by_vertical).toEqual([]);
|
|
});
|
|
|
|
it('returns correct burndown data with mocked DB rows', async () => {
|
|
mockPool.query.mockResolvedValueOnce({
|
|
rows: [
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-002', resolution_date: '2026-07-01', vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' },
|
|
{ hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'SDIT_CISO' }, // duplicate hostname
|
|
],
|
|
});
|
|
|
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
// srv-001 deduplicated: earliest date is 2026-06-15
|
|
expect(res.body.total_non_compliant).toBe(3); // srv-001, srv-002, srv-003
|
|
expect(res.body.blockers).toBe(1); // srv-003
|
|
expect(res.body.with_dates).toBe(2); // srv-001, srv-002
|
|
expect(res.body.monthly_forecast['2026-06']).toBe(1);
|
|
expect(res.body.monthly_forecast['2026-07']).toBe(1);
|
|
expect(res.body.projected_clear_date).toBeNull(); // blockers > 0
|
|
});
|
|
|
|
it('returns all-blocker response correctly', async () => {
|
|
mockPool.query.mockResolvedValueOnce({
|
|
rows: [
|
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
|
{ hostname: 'srv-002', resolution_date: null, vertical: 'SDIT_CISO' },
|
|
],
|
|
});
|
|
|
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body.total_non_compliant).toBe(2);
|
|
expect(res.body.blockers).toBe(2);
|
|
expect(res.body.with_dates).toBe(0);
|
|
expect(res.body.monthly_forecast).toEqual({});
|
|
expect(res.body.projected_clear_date).toBeNull();
|
|
});
|
|
|
|
it('returns 500 on database error', async () => {
|
|
mockPool.query.mockRejectedValueOnce(new Error('Connection refused'));
|
|
|
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.body.error).toBe('Database error');
|
|
});
|
|
|
|
it('response shape matches API contract', async () => {
|
|
mockPool.query.mockResolvedValueOnce({
|
|
rows: [
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
|
],
|
|
});
|
|
|
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toHaveProperty('total_non_compliant');
|
|
expect(res.body).toHaveProperty('blockers');
|
|
expect(res.body).toHaveProperty('with_dates');
|
|
expect(res.body).toHaveProperty('monthly_forecast');
|
|
expect(res.body).toHaveProperty('projected_clear_date');
|
|
expect(res.body).toHaveProperty('by_vertical');
|
|
expect(Array.isArray(res.body.by_vertical)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// --- Auth enforcement test ---
|
|
|
|
describe('GET /burndown — auth enforcement', () => {
|
|
it('returns 401 when auth middleware rejects', async () => {
|
|
// Create a separate app with rejecting auth
|
|
const rejectApp = express();
|
|
rejectApp.use(express.json());
|
|
|
|
// Override requireAuth to reject
|
|
jest.resetModules();
|
|
jest.doMock('../middleware/auth', () => ({
|
|
requireAuth: () => (req, res, next) => {
|
|
res.status(401).json({ error: 'Authentication required' });
|
|
},
|
|
requireGroup: () => (req, res, next) => next(),
|
|
}));
|
|
|
|
const { createVCLMultiVerticalRouter: createRouter } = require('../routes/vclMultiVertical');
|
|
const mockUpload = { array: () => (req, res, next) => next() };
|
|
const router = createRouter(mockUpload);
|
|
rejectApp.use('/api/compliance/vcl-multi', router);
|
|
|
|
const rejectServer = await new Promise((resolve) => {
|
|
const s = rejectApp.listen(0, '127.0.0.1', () => resolve(s));
|
|
});
|
|
|
|
try {
|
|
const res = await request(rejectServer, 'GET', '/api/compliance/vcl-multi/burndown');
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.body.error).toBe('Authentication required');
|
|
} finally {
|
|
await new Promise((resolve) => rejectServer.close(resolve));
|
|
}
|
|
});
|
|
});
|