2026-05-11 15:48:10 -06:00
|
|
|
/**
|
|
|
|
|
* Unit and Integration Tests: VCL Compliance Reporting
|
|
|
|
|
*
|
|
|
|
|
* Feature: vcl-compliance-reporting
|
|
|
|
|
*
|
|
|
|
|
* Tests cover:
|
|
|
|
|
* - PATCH /items/:hostname/metadata (happy path, invalid date, plan too long, not found)
|
|
|
|
|
* - GET /vcl/stats with no data (zero/empty response)
|
|
|
|
|
* - Bulk preview with all unmatched hostnames
|
|
|
|
|
* - Bulk preview with mixed valid/invalid rows
|
|
|
|
|
* - Integration test for full bulk flow (preview → commit)
|
|
|
|
|
* - Trend endpoint with < 2 months (no forecast)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const http = require('http');
|
|
|
|
|
const express = require('express');
|
|
|
|
|
|
|
|
|
|
// Mock auth middleware to bypass real session checks
|
|
|
|
|
jest.mock('../middleware/auth', () => ({
|
|
|
|
|
requireAuth: () => (req, res, next) => {
|
|
|
|
|
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
|
|
|
|
next();
|
|
|
|
|
},
|
|
|
|
|
requireGroup: () => (req, res, next) => next(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Mock audit log as a no-op
|
|
|
|
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
|
|
|
|
|
|
|
|
|
// Mock ivantiApi to avoid real network calls
|
|
|
|
|
jest.mock('../helpers/ivantiApi', () => ({
|
|
|
|
|
ivantiFormPost: jest.fn(),
|
|
|
|
|
ivantiPost: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Mock the db pool
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// Mock driftChecker to avoid file system dependencies
|
|
|
|
|
jest.mock('../helpers/driftChecker', () => ({
|
|
|
|
|
loadConfig: jest.fn(() => ({})),
|
|
|
|
|
compareSchemaToDrift: jest.fn(() => null),
|
|
|
|
|
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const { createComplianceRouter } = require('../routes/compliance');
|
|
|
|
|
|
|
|
|
|
// --- 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());
|
|
|
|
|
|
|
|
|
|
// Mock multer upload middleware
|
|
|
|
|
const mockUpload = { single: () => (req, res, next) => next() };
|
|
|
|
|
const router = createComplianceRouter(mockUpload);
|
|
|
|
|
app.use('/api/compliance', 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 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- 18.1: PATCH /items/:hostname/metadata ---
|
|
|
|
|
|
|
|
|
|
describe('PATCH /items/:hostname/metadata', () => {
|
|
|
|
|
it('happy path — updates resolution_date and remediation_plan', async () => {
|
2026-05-15 10:53:14 -06:00
|
|
|
// Mock client.query: first call = SELECT current values, second+ = INSERT history / UPDATE
|
|
|
|
|
const mockClient = {
|
|
|
|
|
query: jest.fn()
|
|
|
|
|
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // BEGIN
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
|
|
|
|
release: jest.fn(),
|
|
|
|
|
};
|
|
|
|
|
// Override connect to return our mock client
|
|
|
|
|
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
|
|
|
// The first call from the handler is BEGIN, then SELECT, then inserts, then UPDATE, then COMMIT
|
|
|
|
|
mockClient.query = jest.fn()
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
|
|
|
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current values
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
2026-05-11 15:48:10 -06:00
|
|
|
|
|
|
|
|
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
|
|
|
resolution_date: '2026-06-15',
|
|
|
|
|
remediation_plan: 'Patch in next maintenance window',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.body.updated).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 400 for invalid date format', async () => {
|
|
|
|
|
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
|
|
|
resolution_date: 'not-a-date',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
expect(res.body.error).toContain('Invalid resolution_date format');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 400 when remediation plan exceeds 2000 characters', async () => {
|
|
|
|
|
const longPlan = 'x'.repeat(2001);
|
|
|
|
|
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
|
|
|
|
remediation_plan: longPlan,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
expect(res.body.error).toContain('2000 characters');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 404 when hostname not found', async () => {
|
2026-05-15 10:53:14 -06:00
|
|
|
const mockClient = {
|
|
|
|
|
query: jest.fn()
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT current values — empty = not found
|
|
|
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK
|
|
|
|
|
release: jest.fn(),
|
|
|
|
|
};
|
|
|
|
|
mockPool.connect.mockResolvedValueOnce(mockClient);
|
2026-05-11 15:48:10 -06:00
|
|
|
|
|
|
|
|
const res = await request(server, 'PATCH', '/api/compliance/items/nonexistent-host/metadata', {
|
|
|
|
|
resolution_date: '2026-06-15',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
expect(res.body.error).toBe('Device not found');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- 18.2: GET /vcl/stats with no data ---
|
|
|
|
|
|
|
|
|
|
describe('GET /vcl/stats with no data', () => {
|
|
|
|
|
it('returns zero/empty response when no compliance data exists', async () => {
|
|
|
|
|
// First query: active items
|
|
|
|
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
|
|
|
|
// Second query: latest upload
|
|
|
|
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
|
|
|
|
|
|
|
|
|
const res = await request(server, 'GET', '/api/compliance/vcl/stats');
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.body.stats).toBeDefined();
|
2026-05-13 09:56:30 -06:00
|
|
|
expect(res.body.stats.total_devices).toBe(0);
|
2026-05-11 15:48:10 -06:00
|
|
|
expect(res.body.stats.in_scope).toBe(0);
|
|
|
|
|
expect(res.body.stats.compliant).toBe(0);
|
|
|
|
|
expect(res.body.stats.non_compliant).toBe(0);
|
|
|
|
|
expect(res.body.stats.compliance_pct).toBe(0);
|
|
|
|
|
expect(res.body.donut).toBeDefined();
|
|
|
|
|
expect(res.body.heavy_hitters).toEqual([]);
|
|
|
|
|
expect(res.body.vertical_breakdown).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- 18.3: Bulk preview with all unmatched hostnames ---
|
|
|
|
|
|
|
|
|
|
describe('POST /vcl/bulk-preview — all unmatched', () => {
|
|
|
|
|
it('returns all rows as unmatched when no hostnames exist in DB', async () => {
|
|
|
|
|
// Query for existing hostnames returns empty
|
|
|
|
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
|
|
|
|
|
|
|
|
|
const res = await request(server, 'POST', '/api/compliance/vcl/bulk-preview', {
|
|
|
|
|
rows: [
|
|
|
|
|
{ hostname: 'unknown-1', resolution_date: '2026-06-15' },
|
|
|
|
|
{ hostname: 'unknown-2', resolution_date: '2026-07-01' },
|
|
|
|
|
{ hostname: 'unknown-3', resolution_date: '2026-08-01' },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.body.matched).toBe(0);
|
|
|
|
|
expect(res.body.unmatched).toBe(3);
|
|
|
|
|
expect(res.body.changes).toBe(0);
|
|
|
|
|
expect(res.body.unmatched_rows).toEqual(['unknown-1', 'unknown-2', 'unknown-3']);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- 18.4: Bulk preview with mixed valid/invalid rows ---
|
|
|
|
|
|
|
|
|
|
describe('POST /vcl/bulk-preview — mixed valid/invalid', () => {
|
|
|
|
|
it('correctly classifies valid and invalid rows', async () => {
|
|
|
|
|
// Query for existing hostnames
|
|
|
|
|
mockPool.query
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
rows: [
|
|
|
|
|
{ hostname: 'srv-001' },
|
|
|
|
|
{ hostname: 'srv-002' },
|
|
|
|
|
{ hostname: 'srv-003' },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
// Query for current data (DISTINCT ON)
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
rows: [
|
|
|
|
|
{ hostname: 'srv-001', resolution_date: null, remediation_plan: null },
|
|
|
|
|
{ hostname: 'srv-003', resolution_date: null, remediation_plan: null },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await request(server, 'POST', '/api/compliance/vcl/bulk-preview', {
|
|
|
|
|
rows: [
|
|
|
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15' }, // valid, matched
|
|
|
|
|
{ hostname: 'srv-002', resolution_date: 'bad-date' }, // invalid date, matched
|
|
|
|
|
{ hostname: 'srv-003', resolution_date: '2026-07-01' }, // valid, matched
|
|
|
|
|
{ hostname: 'unknown-1', resolution_date: '2026-08-01' }, // unmatched
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.body.matched).toBe(3);
|
|
|
|
|
expect(res.body.unmatched).toBe(1);
|
|
|
|
|
expect(res.body.invalid).toBe(1);
|
|
|
|
|
expect(res.body.invalid_rows[0].hostname).toBe('srv-002');
|
|
|
|
|
expect(res.body.invalid_rows[0].errors[0]).toContain('invalid date');
|
|
|
|
|
expect(res.body.unmatched_rows).toEqual(['unknown-1']);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- 18.5: Integration test for full bulk flow ---
|
|
|
|
|
|
|
|
|
|
describe('Integration: full bulk upload flow (preview → commit)', () => {
|
|
|
|
|
it('preview shows changes, commit updates DB', async () => {
|
|
|
|
|
// --- Preview phase ---
|
|
|
|
|
// Query for existing hostnames
|
|
|
|
|
mockPool.query
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
rows: [{ hostname: 'srv-001' }, { hostname: 'srv-002' }],
|
|
|
|
|
})
|
|
|
|
|
// Query for current data
|
|
|
|
|
.mockResolvedValueOnce({
|
|
|
|
|
rows: [
|
|
|
|
|
{ hostname: 'srv-001', resolution_date: null, remediation_plan: null },
|
|
|
|
|
{ hostname: 'srv-002', resolution_date: '2026-01-01', remediation_plan: 'Old plan' },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const previewRes = await request(server, 'POST', '/api/compliance/vcl/bulk-preview', {
|
|
|
|
|
rows: [
|
|
|
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', remediation_plan: 'New plan' },
|
|
|
|
|
{ hostname: 'srv-002', resolution_date: '2026-01-01', remediation_plan: 'Old plan' }, // unchanged
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(previewRes.statusCode).toBe(200);
|
|
|
|
|
expect(previewRes.body.matched).toBe(2);
|
|
|
|
|
expect(previewRes.body.changes).toBe(1); // only srv-001 changed
|
|
|
|
|
|
|
|
|
|
// --- Commit phase ---
|
|
|
|
|
const mockClient = {
|
|
|
|
|
query: jest.fn(),
|
|
|
|
|
release: jest.fn(),
|
|
|
|
|
};
|
|
|
|
|
mockClient.query
|
|
|
|
|
.mockResolvedValueOnce({}) // BEGIN
|
2026-05-15 10:53:14 -06:00
|
|
|
.mockResolvedValueOnce({ rows: [{ hostname: 'srv-001', resolution_date: null, remediation_plan: null }] }) // SELECT current values for all hostnames
|
|
|
|
|
.mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (resolution_date)
|
|
|
|
|
.mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (remediation_plan)
|
2026-05-11 15:48:10 -06:00
|
|
|
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE srv-001
|
|
|
|
|
.mockResolvedValueOnce({}); // COMMIT
|
|
|
|
|
|
|
|
|
|
mockPool.connect.mockResolvedValueOnce(mockClient);
|
|
|
|
|
|
|
|
|
|
const commitRes = await request(server, 'POST', '/api/compliance/vcl/bulk-commit', {
|
|
|
|
|
changes: [
|
|
|
|
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', remediation_plan: 'New plan' },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(commitRes.statusCode).toBe(200);
|
|
|
|
|
expect(commitRes.body.committed).toBe(1);
|
|
|
|
|
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
|
|
|
|
|
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- 18.6: Trend endpoint with < 2 months (no forecast) ---
|
|
|
|
|
|
|
|
|
|
describe('GET /vcl/trend — fewer than 2 months', () => {
|
|
|
|
|
it('returns data without forecast when < 2 months exist', async () => {
|
|
|
|
|
mockPool.query.mockResolvedValueOnce({
|
|
|
|
|
rows: [
|
|
|
|
|
{ snapshot_month: '2026-01', compliant_count: 900, compliance_pct: '82.0' },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await request(server, 'GET', '/api/compliance/vcl/trend');
|
|
|
|
|
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.body.months).toHaveLength(1);
|
|
|
|
|
expect(res.body.months[0].month).toBe('2026-01');
|
|
|
|
|
expect(res.body.months[0].forecast_pct).toBeNull();
|
|
|
|
|
expect(res.body.months[0].target_pct).toBe(95);
|
|
|
|
|
});
|
|
|
|
|
});
|