Add per-metric remediation plans and improve CI pipeline
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
This commit is contained in:
380
backend/__tests__/compliance-per-metric-metadata.test.js
Normal file
380
backend/__tests__/compliance-per-metric-metadata.test.js
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
83
backend/__tests__/migrations-idempotency.integration.test.js
Normal file
83
backend/__tests__/migrations-idempotency.integration.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// Migration Idempotency Integration Test
|
||||
// This test requires a running PostgreSQL instance with DATABASE_URL configured in backend/.env.
|
||||
// It runs ALL Postgres migrations twice (via run-all.js) to verify they are idempotent (safe to re-run),
|
||||
// then checks that key tables and columns exist.
|
||||
//
|
||||
// Run separately: npx jest backend/__tests__/migrations-idempotency.integration.test.js --forceExit
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
// The real pool — NOT mocked. This hits the actual database.
|
||||
const pool = require('../db');
|
||||
|
||||
const BACKEND_DIR = path.join(__dirname, '..');
|
||||
|
||||
function runAllMigrations() {
|
||||
execSync('node migrations/run-all.js', {
|
||||
cwd: BACKEND_DIR,
|
||||
stdio: 'pipe',
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
describe('Migration Idempotency', () => {
|
||||
it('runs all migrations twice without errors (idempotent)', () => {
|
||||
// First run
|
||||
runAllMigrations();
|
||||
// Second run — should not throw if migrations are truly idempotent
|
||||
runAllMigrations();
|
||||
}, 30000);
|
||||
|
||||
it('key tables exist after migrations', async () => {
|
||||
const expectedTables = [
|
||||
'compliance_items',
|
||||
'compliance_item_history',
|
||||
'compliance_notes',
|
||||
'jira_tickets',
|
||||
'ivanti_fp_submissions',
|
||||
];
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ANY($1)
|
||||
`, [expectedTables]);
|
||||
|
||||
const foundTables = rows.map(r => r.table_name);
|
||||
for (const table of expectedTables) {
|
||||
expect(foundTables).toContain(table);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('compliance_item_history has expected columns', async () => {
|
||||
const expectedColumns = [
|
||||
'id',
|
||||
'hostname',
|
||||
'field_name',
|
||||
'old_value',
|
||||
'new_value',
|
||||
'change_reason',
|
||||
'changed_by',
|
||||
'changed_at',
|
||||
'metric_id',
|
||||
];
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'compliance_item_history'
|
||||
`);
|
||||
|
||||
const foundColumns = rows.map(r => r.column_name);
|
||||
for (const col of expectedColumns) {
|
||||
expect(foundColumns).toContain(col);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
42
backend/migrations/add_compliance_history_metric_id.js
Normal file
42
backend/migrations/add_compliance_history_metric_id.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting compliance_item_history metric_id column migration...');
|
||||
try {
|
||||
// Idempotent: only add column if it doesn't already exist
|
||||
const { rows } = await pool.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'compliance_item_history'
|
||||
AND column_name = 'metric_id'
|
||||
`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
await pool.query(`
|
||||
ALTER TABLE compliance_item_history
|
||||
ADD COLUMN metric_id TEXT
|
||||
`);
|
||||
console.log('✓ metric_id column added to compliance_item_history');
|
||||
} else {
|
||||
console.log('✓ metric_id column already exists (skipped)');
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_metric
|
||||
ON compliance_item_history(hostname, metric_id)
|
||||
`);
|
||||
console.log('✓ hostname/metric_id index created');
|
||||
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
// Self-execute when run directly
|
||||
if (require.main === module) {
|
||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const POSTGRES_MIGRATIONS = [
|
||||
'add_flexible_jira_ticket_creation.js',
|
||||
'add_multi_item_jira_ticket.js',
|
||||
'drop_jira_status_check_constraint.js',
|
||||
'add_compliance_history_metric_id.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
|
||||
@@ -665,7 +665,7 @@ function createComplianceRouter(upload) {
|
||||
let history = [];
|
||||
try {
|
||||
const { rows: historyRows } = await pool.query(
|
||||
`SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||
`SELECT id, metric_id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||
FROM compliance_item_history WHERE hostname = $1 ORDER BY changed_at DESC LIMIT 10`,
|
||||
[hostname]
|
||||
);
|
||||
@@ -943,13 +943,14 @@ function createComplianceRouter(upload) {
|
||||
|
||||
/**
|
||||
* PATCH /items/:hostname/metadata
|
||||
* Updates resolution_date and/or remediation_plan for all active compliance items matching a hostname.
|
||||
* Updates resolution_date and/or remediation_plan for active compliance items matching a hostname.
|
||||
* Supports optional per-metric scoping via metric_id (single) or metric_ids (array).
|
||||
* Records field-level change history in compliance_item_history for each modified field.
|
||||
*
|
||||
* @param hostname — the device hostname
|
||||
* @body { resolution_date?: string|null, remediation_plan?: string|null, change_reason?: string|null }
|
||||
* @body { resolution_date?: string|null, remediation_plan?: string|null, change_reason?: string|null, metric_id?: string, metric_ids?: string[] }
|
||||
* @response 200 { updated: number }
|
||||
* @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, change_reason exceeds 500 chars, or no fields provided
|
||||
* @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, change_reason exceeds 500 chars, no fields provided, or invalid metric_id
|
||||
* @response 404 { error } — device not found
|
||||
* @response 500 { error } — update failure
|
||||
*/
|
||||
@@ -957,7 +958,7 @@ function createComplianceRouter(upload) {
|
||||
const hostname = req.params.hostname;
|
||||
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
||||
|
||||
const { resolution_date, remediation_plan, change_reason } = req.body;
|
||||
const { resolution_date, remediation_plan, change_reason, metric_id, metric_ids } = req.body;
|
||||
|
||||
// Validate resolution_date: must be a valid ISO date string or null
|
||||
if (resolution_date !== undefined && resolution_date !== null) {
|
||||
@@ -979,6 +980,31 @@ function createComplianceRouter(upload) {
|
||||
return res.status(400).json({ error: 'Change reason exceeds 500 characters' });
|
||||
}
|
||||
|
||||
// Resolve metric scoping: metric_ids takes precedence over metric_id
|
||||
let resolvedMetricIds = null; // null means hostname-level (no metric scoping)
|
||||
if (metric_ids !== undefined) {
|
||||
if (!Array.isArray(metric_ids)) return res.status(400).json({ error: 'metric_ids must be an array' });
|
||||
if (metric_ids.length === 0) return res.status(400).json({ error: 'metric_ids must contain at least one entry' });
|
||||
for (let i = 0; i < metric_ids.length; i++) {
|
||||
const mid = metric_ids[i];
|
||||
if (!mid || typeof mid !== 'string' || mid.length === 0) {
|
||||
return res.status(400).json({ error: 'metric_id cannot be empty' });
|
||||
}
|
||||
if (mid.length > 100) {
|
||||
return res.status(400).json({ error: 'metric_id exceeds 100 characters' });
|
||||
}
|
||||
}
|
||||
resolvedMetricIds = metric_ids;
|
||||
} else if (metric_id !== undefined && metric_id !== null) {
|
||||
if (typeof metric_id !== 'string' || metric_id.length === 0) {
|
||||
return res.status(400).json({ error: 'metric_id cannot be empty' });
|
||||
}
|
||||
if (metric_id.length > 100) {
|
||||
return res.status(400).json({ error: 'metric_id exceeds 100 characters' });
|
||||
}
|
||||
resolvedMetricIds = [metric_id];
|
||||
}
|
||||
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
let paramIdx = 1;
|
||||
@@ -1000,69 +1026,148 @@ function createComplianceRouter(upload) {
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Get current values before updating
|
||||
const { rows: currentRows } = await client.query(
|
||||
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
||||
ORDER BY hostname, id DESC LIMIT 1`,
|
||||
[hostname]
|
||||
);
|
||||
|
||||
if (currentRows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
return res.status(404).json({ error: 'Device not found' });
|
||||
}
|
||||
|
||||
const current = currentRows[0];
|
||||
const currentResDate = current.resolution_date
|
||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||
: null;
|
||||
const currentPlan = current.remediation_plan || null;
|
||||
const reasonText = change_reason && change_reason.trim() ? change_reason.trim() : null;
|
||||
|
||||
// Insert history for each changed field
|
||||
if (resolution_date !== undefined) {
|
||||
const newVal = resolution_date || null;
|
||||
if (currentResDate !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, 'resolution_date', $2, $3, $4, $5)`,
|
||||
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
||||
);
|
||||
if (resolvedMetricIds !== null) {
|
||||
// --- Per-metric scoping path ---
|
||||
// Validate that each metric_id corresponds to an active compliance_item for this hostname
|
||||
const { rows: activeMetricRows } = await client.query(
|
||||
`SELECT metric_id, resolution_date, remediation_plan
|
||||
FROM compliance_items
|
||||
WHERE hostname = $1 AND metric_id = ANY($2) AND status = 'active'`,
|
||||
[hostname, resolvedMetricIds]
|
||||
);
|
||||
|
||||
const activeMetricMap = new Map();
|
||||
for (const row of activeMetricRows) {
|
||||
activeMetricMap.set(row.metric_id, row);
|
||||
}
|
||||
}
|
||||
if (remediation_plan !== undefined) {
|
||||
const newVal = remediation_plan || null;
|
||||
if (currentPlan !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, 'remediation_plan', $2, $3, $4, $5)`,
|
||||
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
||||
);
|
||||
|
||||
// Check for invalid metric_ids
|
||||
for (const mid of resolvedMetricIds) {
|
||||
if (!activeMetricMap.has(mid)) {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
return res.status(400).json({ error: `Invalid metric_id: ${mid} — no active compliance item found` });
|
||||
}
|
||||
}
|
||||
|
||||
// Insert history per metric per changed field
|
||||
for (const mid of resolvedMetricIds) {
|
||||
const current = activeMetricMap.get(mid);
|
||||
const currentResDate = current.resolution_date
|
||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||
: null;
|
||||
const currentPlan = current.remediation_plan || null;
|
||||
|
||||
if (resolution_date !== undefined) {
|
||||
const newVal = resolution_date || null;
|
||||
if (currentResDate !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, $2, 'resolution_date', $3, $4, $5, $6)`,
|
||||
[hostname, mid, currentResDate, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (remediation_plan !== undefined) {
|
||||
const newVal = remediation_plan || null;
|
||||
if (currentPlan !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, $2, 'remediation_plan', $3, $4, $5, $6)`,
|
||||
[hostname, mid, currentPlan, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update only matching rows
|
||||
values.push(hostname);
|
||||
values.push(resolvedMetricIds);
|
||||
const result = await client.query(
|
||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND metric_id = ANY($${paramIdx + 1}) AND status = 'active'`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_metadata_update',
|
||||
entityType: 'compliance_item',
|
||||
entityId: hostname,
|
||||
details: { resolution_date, remediation_plan, change_reason: reasonText, metric_ids: resolvedMetricIds },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ updated: result.rowCount });
|
||||
} else {
|
||||
// --- Hostname-level path (backward compatible, NULL metric_id in history) ---
|
||||
// Get current values before updating (pick one representative row)
|
||||
const { rows: currentRows } = await client.query(
|
||||
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
||||
ORDER BY hostname, id DESC LIMIT 1`,
|
||||
[hostname]
|
||||
);
|
||||
|
||||
if (currentRows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
return res.status(404).json({ error: 'Device not found' });
|
||||
}
|
||||
|
||||
const current = currentRows[0];
|
||||
const currentResDate = current.resolution_date
|
||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||
: null;
|
||||
const currentPlan = current.remediation_plan || null;
|
||||
|
||||
// Insert history for each changed field with NULL metric_id
|
||||
if (resolution_date !== undefined) {
|
||||
const newVal = resolution_date || null;
|
||||
if (currentResDate !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, NULL, 'resolution_date', $2, $3, $4, $5)`,
|
||||
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (remediation_plan !== undefined) {
|
||||
const newVal = remediation_plan || null;
|
||||
if (currentPlan !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, NULL, 'remediation_plan', $2, $3, $4, $5)`,
|
||||
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update all active items for hostname
|
||||
values.push(hostname);
|
||||
const result = await client.query(
|
||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_metadata_update',
|
||||
entityType: 'compliance_item',
|
||||
entityId: hostname,
|
||||
details: { resolution_date, remediation_plan, change_reason: reasonText },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ updated: result.rowCount });
|
||||
}
|
||||
|
||||
// Update the items
|
||||
values.push(hostname);
|
||||
const result = await client.query(
|
||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_metadata_update',
|
||||
entityType: 'compliance_item',
|
||||
entityId: hostname,
|
||||
details: { resolution_date, remediation_plan, change_reason: reasonText },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ updated: result.rowCount });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
|
||||
|
||||
Reference in New Issue
Block a user