/** * 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 () => { mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 2 }); 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 () => { mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); 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(); expect(res.body.stats.total_devices).toBe(0); 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 .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); }); });