Per-metric remediation plan scoping (GitLab issue #19): - Add metric_id column to compliance_item_history table (migration) - Extend PATCH /items/:hostname/metadata to accept metric_id/metric_ids for targeting specific metrics instead of all active items - Add MetricChipSelector UI in detail panel for choosing which metrics to apply resolution_date and remediation_plan changes to - Display per-metric labels (MetricChip or 'All metrics') on history entries - Backward compatible: omitting metric_ids preserves hostname-level behavior CI/CD pipeline improvements: - Add migration idempotency integration test (runs against real Postgres) - Add post-deploy smoke tests for compliance and VCL endpoints - Bump lint --max-warnings from 10 to 25 - Configure varsIgnorePattern for _ prefix convention on unused vars Closes #19
381 lines
16 KiB
JavaScript
381 lines
16 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|