/** * Unit Tests: PATCH /api/compliance/items/:hostname/metadata — Per-Metric Scoping * * Feature: remediation-plan-history (per-metric extension) * * Tests cover: * - Task 8.1: metric_id/metric_ids validation, precedence, non-empty/max 100 chars, active item check * - Task 8.2: Per-metric SELECT, INSERT history per metric, UPDATE only matching rows * - Task 8.3: Hostname-level behavior preserved with NULL metric_id in history * * Validates: Requirements 8, 11, 15 */ 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(), })); // Mock audit log as a no-op jest.mock('../helpers/auditLog', () => jest.fn()); // Mock ivantiApi 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: [] })), })); // Mock the db pool const mockPool = { query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })), connect: jest.fn(), }; jest.mock('../db', () => mockPool); 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()); const mockUpload = { single: () => (req, res, next) => next() }; app.use('/api/compliance', createComplianceRouter(mockUpload)); server = app.listen(0, '127.0.0.1', done); }); afterAll((done) => { server.close(done); }); beforeEach(() => { jest.clearAllMocks(); }); // --- Task 8.1: Validation --- describe('Task 8.1: metric_id/metric_ids validation', () => { it('returns 400 when metric_ids is not an array', async () => { const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_ids: 'not-an-array', }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('metric_ids must be an array'); }); it('returns 400 when metric_ids is empty array', async () => { const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_ids: [], }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('metric_ids must contain at least one entry'); }); it('returns 400 when metric_ids contains empty string', async () => { const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_ids: ['2.1.1', ''], }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('metric_id cannot be empty'); }); it('returns 400 when metric_id exceeds 100 characters', async () => { const longId = 'x'.repeat(101); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_ids: [longId], }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('metric_id exceeds 100 characters'); }); it('returns 400 when single metric_id is empty string', async () => { const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_id: '', }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('metric_id cannot be empty'); }); it('returns 400 when single metric_id exceeds 100 characters', async () => { const longId = 'x'.repeat(101); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_id: longId, }); expect(res.statusCode).toBe(400); expect(res.body.error).toBe('metric_id exceeds 100 characters'); }); it('returns 400 when metric_id does not correspond to active compliance_item', async () => { const mockClient = { query: jest.fn() .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT active metrics — none found .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(mockClient); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_ids: ['nonexistent-metric'], }); expect(res.statusCode).toBe(400); expect(res.body.error).toContain('Invalid metric_id: nonexistent-metric'); }); it('uses metric_ids when both metric_id and metric_ids are provided', async () => { // metric_ids wins — should validate metric_ids, not metric_id const mockClient = { query: jest.fn() .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN .mockResolvedValueOnce({ rows: [{ metric_id: '3.1.1', resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT active metrics .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(mockClient); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_id: '2.1.1', // should be ignored metric_ids: ['3.1.1'], // should be used }); expect(res.statusCode).toBe(200); // Verify the SELECT query used metric_ids value ['3.1.1'], not metric_id '2.1.1' const selectCall = mockClient.query.mock.calls[1]; expect(selectCall[1]).toEqual(['srv-001', ['3.1.1']]); }); }); // --- Task 8.2: Per-metric scoping behavior --- describe('Task 8.2: Per-metric SELECT, INSERT history, UPDATE matching rows', () => { it('selects current values per targeted metric and inserts history per metric', async () => { const mockClient = { query: jest.fn() .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN .mockResolvedValueOnce({ rows: [ { metric_id: '2.1.1', resolution_date: '2026-01-01', remediation_plan: 'Plan A' }, { metric_id: '2.3.2', resolution_date: '2026-02-01', remediation_plan: 'Plan B' }, ], rowCount: 2 }) // SELECT active metrics with current values .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.1.1 resolution_date .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.1.1 remediation_plan .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.3.2 resolution_date .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.3.2 remediation_plan .mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE matching rows .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(mockClient); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', remediation_plan: 'New unified plan', metric_ids: ['2.1.1', '2.3.2'], }); expect(res.statusCode).toBe(200); expect(res.body.updated).toBe(2); // Verify history inserts include metric_id const calls = mockClient.query.mock.calls; // Call [2] = INSERT history for 2.1.1 resolution_date expect(calls[2][0]).toContain('INSERT INTO compliance_item_history'); expect(calls[2][1][1]).toBe('2.1.1'); // metric_id expect(calls[2][1][2]).toBe('2026-01-01'); // old_value expect(calls[2][1][3]).toBe('2026-06-15'); // new_value // Call [3] = INSERT history for 2.1.1 remediation_plan expect(calls[3][1][1]).toBe('2.1.1'); expect(calls[3][1][2]).toBe('Plan A'); // old_value expect(calls[3][1][3]).toBe('New unified plan'); // new_value // Call [4] = INSERT history for 2.3.2 resolution_date expect(calls[4][1][1]).toBe('2.3.2'); expect(calls[4][1][2]).toBe('2026-02-01'); // old_value // Call [5] = INSERT history for 2.3.2 remediation_plan expect(calls[5][1][1]).toBe('2.3.2'); expect(calls[5][1][2]).toBe('Plan B'); // old_value }); it('skips history insert when value is unchanged for a specific metric', async () => { const mockClient = { query: jest.fn() .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN .mockResolvedValueOnce({ rows: [ { metric_id: '2.1.1', resolution_date: '2026-06-15', remediation_plan: null }, ], rowCount: 1 }) // SELECT — already has the target date .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE (no history inserts since value unchanged) .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(mockClient); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_ids: ['2.1.1'], }); expect(res.statusCode).toBe(200); // No INSERT history calls — only BEGIN, SELECT, UPDATE, COMMIT const calls = mockClient.query.mock.calls; expect(calls.length).toBe(4); expect(calls[2][0]).toContain('UPDATE compliance_items'); }); it('updates only matching rows with metric_id = ANY filter', async () => { const mockClient = { query: jest.fn() .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN .mockResolvedValueOnce({ rows: [ { metric_id: '2.1.1', resolution_date: null, remediation_plan: null }, ], rowCount: 1 }) // SELECT .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(mockClient); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', metric_id: '2.1.1', }); expect(res.statusCode).toBe(200); // Verify UPDATE query includes metric_id = ANY filter const updateCall = mockClient.query.mock.calls[3]; expect(updateCall[0]).toContain('metric_id = ANY'); expect(updateCall[0]).toContain("status = 'active'"); expect(updateCall[1]).toContain('srv-001'); expect(updateCall[1]).toEqual(expect.arrayContaining([['2.1.1']])); }); }); // --- Task 8.3: Hostname-level behavior preserved --- describe('Task 8.3: Hostname-level behavior with NULL metric_id', () => { it('updates all active rows when no metric_id/metric_ids provided', async () => { const mockClient = { query: jest.fn() .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN .mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history .mockResolvedValueOnce({ rows: [], rowCount: 5 }) // UPDATE all active rows .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(mockClient); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', }); expect(res.statusCode).toBe(200); expect(res.body.updated).toBe(5); // Verify UPDATE does NOT include metric_id filter const updateCall = mockClient.query.mock.calls[3]; expect(updateCall[0]).not.toContain('metric_id'); expect(updateCall[0]).toContain("status = 'active'"); }); it('inserts history with NULL metric_id when no metric scoping', async () => { const mockClient = { query: jest.fn() .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN .mockResolvedValueOnce({ rows: [{ resolution_date: '2026-01-01', remediation_plan: 'Old plan' }], rowCount: 1 }) // SELECT current .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date) .mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan) .mockResolvedValueOnce({ rows: [], rowCount: 3 }) // UPDATE .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(mockClient); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', remediation_plan: 'New plan', }); expect(res.statusCode).toBe(200); // Verify history INSERT includes NULL for metric_id const historyCall1 = mockClient.query.mock.calls[2]; expect(historyCall1[0]).toContain('INSERT INTO compliance_item_history'); expect(historyCall1[0]).toContain('NULL'); }); it('returns 404 when hostname has no active items (hostname-level path)', async () => { const mockClient = { query: jest.fn() .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT current — empty .mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK release: jest.fn(), }; mockPool.connect.mockResolvedValueOnce(mockClient); const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', { resolution_date: '2026-06-15', }); expect(res.statusCode).toBe(404); expect(res.body.error).toBe('Device not found'); }); });