Add VCL compliance reporting: exec report page, device metadata fields, bulk upload

This commit is contained in:
Jordan Ramos
2026-05-11 15:48:10 -06:00
parent 955036145d
commit d093a3d113
10 changed files with 2626 additions and 9 deletions

View File

@@ -0,0 +1,501 @@
/**
* Property-Based Tests: VCL Compliance Reporting
*
* Feature: vcl-compliance-reporting
*
* Tests the pure helper functions used for VCL compliance reporting computations.
*
* Validates: Requirements 2.4, 2.5, 3.2, 3.3, 5.2, 5.3, 6.1, 6.3, 7.5, 8.2, 8.3, 8.4, 8.7, 9.2, 9.3, 9.6
*/
const fc = require('fast-check');
// Mock db pool before importing anything (avoids DATABASE_URL requirement)
jest.mock('../db', () => ({
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
connect: jest.fn(() => Promise.resolve({
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
release: jest.fn(),
})),
}));
// Mock dependencies that the route module imports
jest.mock('../helpers/auditLog', () => jest.fn());
jest.mock('../helpers/ivantiApi', () => ({
ivantiFormPost: jest.fn(),
ivantiPost: jest.fn(),
}));
const {
truncateText,
validateRemediationPlan,
computeVCLStats,
formatPct,
categorizeNonCompliant,
rankHeavyHitters,
computeForecastBurndown,
matchByHostname,
computeBulkDiff,
mapColumnHeaders,
isValidDateString,
} = require('../helpers/vclHelpers');
// --- Generators ---
const hostnameArb = fc.stringMatching(/^[a-zA-Z0-9._-]+$/, { minLength: 1, maxLength: 30 });
const validDateArb = fc.record({
year: fc.integer({ min: 2020, max: 2030 }),
month: fc.integer({ min: 1, max: 12 }),
day: fc.integer({ min: 1, max: 28 }), // 1-28 always valid
}).map(({ year, month, day }) =>
`${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
);
const complianceItemArb = fc.record({
hostname: hostnameArb,
is_compliant: fc.boolean(),
in_scope: fc.constant(true),
});
const nonCompliantItemArb = fc.record({
hostname: hostnameArb,
resolution_date: fc.oneof(fc.constant(null), validDateArb),
});
const verticalArb = fc.record({
vertical: fc.string({ minLength: 1, maxLength: 20 }),
team: fc.string({ minLength: 1, maxLength: 20 }),
non_compliant: fc.integer({ min: 0, max: 1000 }),
});
// --- Property 2: Text Truncation ---
describe('Feature: vcl-compliance-reporting, Property 2: Text Truncation', () => {
/**
* For any string, truncateText(text, 80) should return the original string if its
* length is <= 80, or the first 80 characters followed by "…" if its length exceeds 80.
*
* **Validates: Requirements 2.4**
*/
it('returns original for short strings, truncated + ellipsis for long strings', () => {
fc.assert(
fc.property(
fc.string({ minLength: 0, maxLength: 200 }),
fc.integer({ min: 1, max: 100 }),
(text, maxLen) => {
const result = truncateText(text, maxLen);
if (text.length <= maxLen) {
expect(result).toBe(text);
} else {
expect(result).toBe(text.slice(0, maxLen) + '\u2026');
expect(result.length).toBe(maxLen + 1);
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 3: Remediation Plan Length Validation ---
describe('Feature: vcl-compliance-reporting, Property 3: Remediation Plan Length Validation', () => {
/**
* For any string, validateRemediationPlan(text) should return valid if and only if
* the string length is <= 2000 characters.
*
* **Validates: Requirements 2.5, 9.4**
*/
it('accepts strings <= 2000 chars, rejects longer', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 3000 }),
(text) => {
const result = validateRemediationPlan(text);
if (text.length <= 2000) {
expect(result.valid).toBe(true);
} else {
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 4: Summary Statistics Computation Invariants ---
describe('Feature: vcl-compliance-reporting, Property 4: Summary Statistics Computation Invariants', () => {
/**
* For any set of compliance items, computeVCLStats produces correct arithmetic:
* non_compliant + compliant = in_scope, and correct percentage.
*
* **Validates: Requirements 3.2, 7.3**
*/
it('non_compliant + compliant = in_scope, correct percentage', () => {
fc.assert(
fc.property(
fc.array(complianceItemArb, { minLength: 0, maxLength: 50 }),
fc.integer({ min: 0, max: 100 }),
(items, targetPct) => {
const stats = computeVCLStats(items, targetPct);
// in_scope items are those with in_scope === true
const in_scope = items.filter(i => i.in_scope).length;
const compliant = items.filter(i => i.is_compliant).length;
expect(stats.non_compliant + stats.compliant).toBe(stats.in_scope);
expect(stats.in_scope).toBe(in_scope);
expect(stats.compliant).toBe(compliant);
if (in_scope > 0) {
expect(stats.compliance_pct).toBe(Math.round((compliant / in_scope) * 100));
} else {
expect(stats.compliance_pct).toBe(0);
}
expect(stats.target_pct).toBe(targetPct);
}
),
{ numRuns: 100 }
);
});
});
// --- Property 5: Percentage Formatting ---
describe('Feature: vcl-compliance-reporting, Property 5: Percentage Formatting', () => {
/**
* For any decimal number between 0 and 1, formatPct produces a string matching /^\d{1,3}%$/.
*
* **Validates: Requirements 3.3**
*/
it('produces correct percentage string matching /^\\d{1,3}%$/', () => {
fc.assert(
fc.property(
fc.double({ min: 0, max: 1, noNaN: true }),
(decimal) => {
const result = formatPct(decimal);
expect(result).toMatch(/^\d{1,3}%$/);
expect(result).toBe(Math.round(decimal * 100) + '%');
}
),
{ numRuns: 100 }
);
});
});
// --- Property 6: Non-Compliant Device Categorization Partition ---
describe('Feature: vcl-compliance-reporting, Property 6: Non-Compliant Device Categorization Partition', () => {
/**
* For any array of non-compliant device objects, categorizeNonCompliant produces
* two groups (blocked, in_progress) where blocked.count + in_progress.count = items.length.
*
* **Validates: Requirements 5.2, 5.3**
*/
it('two groups sum to total', () => {
fc.assert(
fc.property(
fc.array(nonCompliantItemArb, { minLength: 0, maxLength: 50 }),
(items) => {
const result = categorizeNonCompliant(items);
expect(result.blocked.count + result.in_progress.count).toBe(items.length);
if (items.length > 0) {
expect(result.blocked.pct).toBe(Math.round((result.blocked.count / items.length) * 100));
expect(result.in_progress.pct).toBe(Math.round((result.in_progress.count / items.length) * 100));
} else {
expect(result.blocked.pct).toBe(0);
expect(result.in_progress.pct).toBe(0);
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 7: Heavy Hitters Descending Sort ---
describe('Feature: vcl-compliance-reporting, Property 7: Heavy Hitters Descending Sort', () => {
/**
* For any array of vertical objects, rankHeavyHitters returns the array sorted
* in non-increasing order by non_compliant.
*
* **Validates: Requirements 6.1, 6.3**
*/
it('sorted non-increasing by non_compliant', () => {
fc.assert(
fc.property(
fc.array(verticalArb, { minLength: 0, maxLength: 30 }),
(verticals) => {
const result = rankHeavyHitters(verticals);
expect(result.length).toBe(verticals.length);
for (let i = 1; i < result.length; i++) {
expect(result[i - 1].non_compliant).toBeGreaterThanOrEqual(result[i].non_compliant);
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 8: Forecasted Burndown Projection ---
describe('Feature: vcl-compliance-reporting, Property 8: Forecasted Burndown Projection', () => {
/**
* For any set of non-compliant devices with resolution_date values,
* computeForecastBurndown produces monthly buckets where the sum of all
* monthly forecast counts equals the number of items with non-null resolution_dates.
*
* **Validates: Requirements 7.5**
*/
it('bucket sum = count of items with non-null resolution_dates', () => {
fc.assert(
fc.property(
fc.array(nonCompliantItemArb, { minLength: 0, maxLength: 50 }),
(items) => {
const buckets = computeForecastBurndown(items);
const bucketSum = Object.values(buckets).reduce((sum, count) => sum + count, 0);
const itemsWithDate = items.filter(i => i.resolution_date != null).length;
expect(bucketSum).toBe(itemsWithDate);
}
),
{ numRuns: 100 }
);
});
});
// --- Property 9: Hostname Matching with Unmatched Flagging ---
describe('Feature: vcl-compliance-reporting, Property 9: Hostname Matching with Unmatched Flagging', () => {
/**
* For any array of uploaded rows and a set of existing hostnames,
* matchByHostname produces matched + unmatched = total, and matched hostnames
* all exist in the set.
*
* **Validates: Requirements 8.2, 8.7**
*/
it('matched + unmatched = total, matched hostnames in set', () => {
fc.assert(
fc.property(
fc.array(fc.record({ hostname: hostnameArb }), { minLength: 0, maxLength: 30 }),
fc.array(hostnameArb, { minLength: 0, maxLength: 20 }),
(rows, existingList) => {
const existingSet = new Set(existingList);
const { matched, unmatched } = matchByHostname(rows, existingSet);
expect(matched.length + unmatched.length).toBe(rows.length);
for (const row of matched) {
expect(existingSet.has(row.hostname)).toBe(true);
}
for (const row of unmatched) {
expect(existingSet.has(row.hostname)).toBe(false);
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 10: Bulk Diff Change Detection ---
describe('Feature: vcl-compliance-reporting, Property 10: Bulk Diff Change Detection', () => {
/**
* For any array of matched row pairs, computeBulkDiff flags a row as "changed"
* if and only if at least one field value differs.
*
* **Validates: Requirements 8.3, 8.4**
*/
it('changed iff at least one field differs', () => {
const fieldValueArb = fc.oneof(fc.constant(null), fc.string({ minLength: 1, maxLength: 20 }));
// When uploaded values match current data exactly, status should be 'unchanged'
fc.assert(
fc.property(
fc.array(hostnameArb, { minLength: 1, maxLength: 20 }).chain(hostnames => {
// Ensure unique hostnames to avoid map overwrite issues
const uniqueHostnames = [...new Set(hostnames)];
return fc.tuple(
...uniqueHostnames.map(h =>
fc.record({
hostname: fc.constant(h),
resolution_date: fieldValueArb,
remediation_plan: fieldValueArb,
notes: fieldValueArb,
})
)
);
}),
(matchedRows) => {
// Build currentData with same values as uploaded
const currentData = new Map();
for (const row of matchedRows) {
currentData.set(row.hostname, {
resolution_date: row.resolution_date,
remediation_plan: row.remediation_plan,
notes: row.notes,
});
}
const results = computeBulkDiff(matchedRows, currentData);
for (const r of results) {
expect(r.status).toBe('unchanged');
}
}
),
{ numRuns: 50 }
);
// When at least one field differs, status should be 'changed'
fc.assert(
fc.property(
hostnameArb,
fc.string({ minLength: 1, maxLength: 20 }),
fc.string({ minLength: 1, maxLength: 20 }),
(hostname, oldVal, newVal) => {
fc.pre(oldVal !== newVal);
const matchedRows = [{ hostname, resolution_date: newVal }];
const currentData = new Map();
currentData.set(hostname, { resolution_date: oldVal, remediation_plan: null, notes: null });
const results = computeBulkDiff(matchedRows, currentData);
expect(results[0].status).toBe('changed');
}
),
{ numRuns: 50 }
);
});
});
// --- Property 11: Column Header Mapping ---
describe('Feature: vcl-compliance-reporting, Property 11: Column Header Mapping', () => {
/**
* mapColumnHeaders correctly identifies known columns case-insensitively.
*
* **Validates: Requirements 9.2**
*/
it('identifies known columns case-insensitively', () => {
const knownHeaders = ['Hostname', 'Resolution Date', 'Remediation Plan', 'Notes',
'hostname', 'resolution_date', 'remediation_plan', 'notes',
'HOSTNAME', 'RESOLUTION DATE', 'REMEDIATION PLAN', 'NOTES'];
const caseVariantArb = fc.constantFrom(...knownHeaders);
const unknownHeaderArb = fc.stringMatching(/^[a-z]{5,10}$/).filter(
s => !['hostname', 'notes'].includes(s.toLowerCase())
);
fc.assert(
fc.property(
fc.array(fc.oneof(caseVariantArb, unknownHeaderArb), { minLength: 1, maxLength: 10 }),
(headers) => {
const mapping = mapColumnHeaders(headers);
// Every mapped key should be a known field
const validKeys = new Set(['hostname', 'resolution_date', 'remediation_plan', 'notes']);
for (const key of Object.keys(mapping)) {
expect(validKeys.has(key)).toBe(true);
}
// Check that known headers are mapped correctly
for (let i = 0; i < headers.length; i++) {
const normalized = headers[i].trim().toLowerCase();
if (normalized === 'hostname') {
expect(mapping.hostname).toBeDefined();
}
if (normalized === 'resolution date' || normalized === 'resolution_date') {
expect(mapping.resolution_date).toBeDefined();
}
if (normalized === 'remediation plan' || normalized === 'remediation_plan') {
expect(mapping.remediation_plan).toBeDefined();
}
if (normalized === 'notes') {
expect(mapping.notes).toBeDefined();
}
}
}
),
{ numRuns: 100 }
);
});
});
// --- Property 12: Date String Validation ---
describe('Feature: vcl-compliance-reporting, Property 12: Date String Validation', () => {
/**
* isValidDateString rejects invalid calendar dates and non-date strings.
* Returns true only for valid YYYY-MM-DD dates.
*
* **Validates: Requirements 9.3**
*/
it('rejects invalid dates and non-date strings', () => {
// Valid dates should return true
fc.assert(
fc.property(validDateArb, (dateStr) => {
expect(isValidDateString(dateStr)).toBe(true);
}),
{ numRuns: 50 }
);
// Invalid dates should return false
const invalidDateArb = fc.oneof(
fc.constant(null),
fc.constant(''),
fc.constant('not-a-date'),
fc.constant('2026-02-30'),
fc.constant('2026-13-01'),
fc.constant('2026-00-15'),
fc.constant('abcd-ef-gh'),
fc.integer().map(n => String(n)),
fc.string({ minLength: 1, maxLength: 5 }),
);
fc.assert(
fc.property(invalidDateArb, (val) => {
expect(isValidDateString(val)).toBe(false);
}),
{ numRuns: 50 }
);
});
});
// --- Property 13: Row Count Arithmetic Invariant ---
describe('Feature: vcl-compliance-reporting, Property 13: Row Count Arithmetic (matched + unmatched = total)', () => {
/**
* For any bulk upload, matched + unmatched = total input rows.
*
* **Validates: Requirements 9.6**
*/
it('matched + unmatched = total invariant holds', () => {
fc.assert(
fc.property(
fc.array(fc.record({ hostname: hostnameArb }), { minLength: 0, maxLength: 50 }),
fc.array(hostnameArb, { minLength: 0, maxLength: 30 }),
(rows, existingList) => {
const existingSet = new Set(existingList);
const { matched, unmatched } = matchByHostname(rows, existingSet);
// Core invariant: matched + unmatched = total
expect(matched.length + unmatched.length).toBe(rows.length);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,316 @@
/**
* 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).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);
});
});

View File

@@ -0,0 +1,220 @@
// Pure helper functions for VCL Compliance Reporting
// No database dependencies — all functions are stateless and testable in isolation.
/**
* Truncates text to maxLen characters with an ellipsis.
* Returns '' for null/undefined input.
*/
function truncateText(text, maxLen = 80) {
if (text == null) return '';
if (text.length <= maxLen) return text;
return text.slice(0, maxLen) + '\u2026';
}
/**
* Validates that a remediation plan does not exceed 2000 characters.
* Null/undefined/empty values are considered valid (no plan documented).
*/
function validateRemediationPlan(text) {
if (text == null || text === '') return { valid: true };
if (text.length > 2000) return { valid: false, error: 'Remediation plan exceeds 2000 characters' };
return { valid: true };
}
/**
* Returns true only for strings parseable as real calendar dates.
* Rejects null, undefined, empty string, and invalid dates like "2026-02-30".
*/
function isValidDateString(str) {
if (str == null || str === '') return false;
if (typeof str !== 'string') return false;
// Expect YYYY-MM-DD format
const match = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return false;
const year = parseInt(match[1], 10);
const month = parseInt(match[2], 10);
const day = parseInt(match[3], 10);
// Month must be 1-12
if (month < 1 || month > 12) return false;
// Create date and verify components match (catches invalid days like Feb 30)
const date = new Date(year, month - 1, day);
return (
date.getFullYear() === year &&
date.getMonth() === month - 1 &&
date.getDate() === day
);
}
/**
* Formats a decimal as a whole-number percentage string.
* Returns '0%' for null, undefined, or NaN input.
*/
function formatPct(decimal) {
if (decimal == null || isNaN(decimal)) return '0%';
return Math.round(decimal * 100) + '%';
}
/**
* Computes VCL summary statistics from an array of device objects.
* Each item should have at least { is_compliant: boolean, in_scope: boolean }.
*/
function computeVCLStats(items, targetPct) {
const total = items.length;
const in_scope = items.filter(item => item.in_scope).length;
const compliant = items.filter(item => item.is_compliant).length;
const non_compliant = in_scope - compliant;
const remediations_required = non_compliant;
const compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0;
return {
total,
in_scope,
compliant,
non_compliant,
remediations_required,
compliance_pct,
target_pct: targetPct,
};
}
/**
* Partitions non-compliant items into "blocked" (no resolution_date) and
* "in_progress" (resolution_date set). Returns counts and percentages.
*/
function categorizeNonCompliant(items) {
const total = items.length;
const blocked = items.filter(item => item.resolution_date == null);
const in_progress = items.filter(item => item.resolution_date != null);
return {
blocked: {
count: blocked.length,
pct: total > 0 ? Math.round((blocked.length / total) * 100) : 0,
},
in_progress: {
count: in_progress.length,
pct: total > 0 ? Math.round((in_progress.length / total) * 100) : 0,
},
};
}
/**
* Sorts verticals by non_compliant count in descending order.
* Returns a new sorted array (does not mutate input).
*/
function rankHeavyHitters(verticalData) {
return [...verticalData].sort((a, b) => b.non_compliant - a.non_compliant);
}
/**
* Buckets non-compliant items by resolution_date month (YYYY-MM).
* Items with null resolution_date are skipped.
* Returns an object like { '2026-05': 3, '2026-06': 7 }.
*/
function computeForecastBurndown(items) {
const buckets = {};
for (const item of items) {
if (item.resolution_date == null) continue;
const dateStr = typeof item.resolution_date === 'string'
? item.resolution_date
: item.resolution_date.toISOString().slice(0, 10);
const month = dateStr.slice(0, 7); // YYYY-MM
buckets[month] = (buckets[month] || 0) + 1;
}
return buckets;
}
/**
* Matches uploaded rows to existing hostnames.
* Returns { matched: [...], unmatched: [...] }.
*/
function matchByHostname(uploadedRows, existingHostnames) {
const matched = [];
const unmatched = [];
for (const row of uploadedRows) {
if (existingHostnames.has(row.hostname)) {
matched.push(row);
} else {
unmatched.push(row);
}
}
return { matched, unmatched };
}
/**
* Compares uploaded row values against current DB values.
* currentData is a Map of hostname -> { resolution_date, remediation_plan, notes }.
* Returns array of { hostname, status: 'changed'|'unchanged', fields: { fieldName: { old, new } } }.
*/
function computeBulkDiff(matchedRows, currentData) {
const results = [];
const COMPARE_FIELDS = ['resolution_date', 'remediation_plan', 'notes'];
for (const row of matchedRows) {
const current = currentData.get(row.hostname) || {};
const fields = {};
let hasChange = false;
for (const field of COMPARE_FIELDS) {
if (field in row) {
const oldVal = current[field] != null ? current[field] : null;
const newVal = row[field] != null ? row[field] : null;
if (oldVal !== newVal) {
fields[field] = { old: oldVal, new: newVal };
hasChange = true;
}
}
}
results.push({
hostname: row.hostname,
status: hasChange ? 'changed' : 'unchanged',
fields,
});
}
return results;
}
/**
* Maps column header strings to known field names (case-insensitive).
* Returns a mapping object like { hostname: 0, resolution_date: 3 } where values are column indices.
*/
function mapColumnHeaders(headers) {
const mapping = {};
const KNOWN_MAPPINGS = {
hostname: 'hostname',
'resolution date': 'resolution_date',
resolution_date: 'resolution_date',
'remediation plan': 'remediation_plan',
remediation_plan: 'remediation_plan',
notes: 'notes',
};
for (let i = 0; i < headers.length; i++) {
const normalized = headers[i].trim().toLowerCase();
if (KNOWN_MAPPINGS[normalized]) {
mapping[KNOWN_MAPPINGS[normalized]] = i;
}
}
return mapping;
}
module.exports = {
truncateText,
validateRemediationPlan,
isValidDateString,
formatPct,
computeVCLStats,
categorizeNonCompliant,
rankHeavyHitters,
computeForecastBurndown,
matchByHostname,
computeBulkDiff,
mapColumnHeaders,
};

View File

@@ -0,0 +1,38 @@
// Migration: Add VCL reporting columns to compliance_items and create compliance_snapshots table
const pool = require('../db');
async function run() {
console.log('Starting VCL reporting migration...');
try {
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS resolution_date DATE DEFAULT NULL`);
console.log('✓ resolution_date column added (or already exists)');
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS remediation_plan TEXT DEFAULT NULL`);
console.log('✓ remediation_plan column added (or already exists)');
await pool.query(`
CREATE TABLE IF NOT EXISTS compliance_snapshots (
id SERIAL PRIMARY KEY,
snapshot_month TEXT NOT NULL,
vertical TEXT NOT NULL,
total_devices INTEGER NOT NULL DEFAULT 0,
compliant INTEGER NOT NULL DEFAULT 0,
non_compliant INTEGER NOT NULL DEFAULT 0,
compliance_pct NUMERIC(5,2) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(snapshot_month, vertical)
)
`);
console.log('✓ compliance_snapshots table created (or already exists)');
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_snapshots_month ON compliance_snapshots(snapshot_month)`);
console.log('✓ idx_compliance_snapshots_month index created (or already exists)');
} catch (err) {
console.error('Migration error:', err.message);
process.exit(1);
}
console.log('Migration complete.');
process.exit(0);
}
run();

View File

@@ -16,6 +16,7 @@ const MIGRATIONS_DIR = __dirname;
const POSTGRES_MIGRATIONS = [
'add_decom_workflow_type.js',
'add_fp_submissions_dismissed.js',
'add_vcl_reporting_columns.js',
];
async function runAll() {

View File

@@ -9,6 +9,7 @@ const { spawn } = require('child_process');
const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth');
const { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker');
const { isValidDateString, validateRemediationPlan, computeVCLStats, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders } = require('../helpers/vclHelpers');
const logAudit = require('../helpers/auditLog');
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
@@ -147,6 +148,39 @@ async function persistUpload({ items, summary, reportDate, filename, userId }) {
);
await client.query('COMMIT');
// Task 7: Create/update compliance_snapshots for the current month
try {
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
// Compute per-vertical compliance percentages from current state
const { rows: verticalStats } = await pool.query(
`SELECT team AS vertical,
COUNT(DISTINCT hostname)::int AS total_devices,
COUNT(DISTINCT CASE WHEN status = 'resolved' THEN hostname END)::int AS compliant,
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
FROM compliance_items
WHERE team IS NOT NULL
GROUP BY team`
);
for (const vs of verticalStats) {
const total = vs.total_devices;
const compPct = total > 0 ? Math.round((vs.compliant / total) * 100 * 100) / 100 : 0;
await pool.query(
`INSERT INTO compliance_snapshots (snapshot_month, vertical, total_devices, compliant, non_compliant, compliance_pct)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (snapshot_month, vertical)
DO UPDATE SET total_devices = $3, compliant = $4, non_compliant = $5, compliance_pct = $6`,
[currentMonth, vs.vertical, total, vs.compliant, vs.non_compliant, compPct]
);
}
} catch (snapshotErr) {
// Snapshot creation is non-critical — log but don't fail the upload
console.error('[Compliance] Snapshot creation error:', snapshotErr.message);
}
return { uploadId, newCount, recurringCount, resolvedCount };
} catch (err) {
await client.query('ROLLBACK');
@@ -669,6 +703,425 @@ function createComplianceRouter(upload) {
}
});
// -----------------------------------------------------------------------
// PATCH /items/:hostname/metadata — Update resolution_date / remediation_plan
// -----------------------------------------------------------------------
router.patch('/items/:hostname/metadata', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const hostname = req.params.hostname;
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
const { resolution_date, remediation_plan } = req.body;
// Validate resolution_date: must be a valid ISO date string or null
if (resolution_date !== undefined && resolution_date !== null) {
if (!isValidDateString(resolution_date)) {
return res.status(400).json({ error: 'Invalid resolution_date format' });
}
}
// Validate remediation_plan: must be <= 2000 chars or null
if (remediation_plan !== undefined && remediation_plan !== null) {
const planValidation = validateRemediationPlan(remediation_plan);
if (!planValidation.valid) {
return res.status(400).json({ error: planValidation.error });
}
}
try {
// Build dynamic SET clause for provided fields only
const setClauses = [];
const values = [];
let paramIdx = 1;
if (resolution_date !== undefined) {
setClauses.push(`resolution_date = $${paramIdx++}`);
values.push(resolution_date);
}
if (remediation_plan !== undefined) {
setClauses.push(`remediation_plan = $${paramIdx++}`);
values.push(remediation_plan);
}
if (setClauses.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
values.push(hostname);
const result = await pool.query(
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
values
);
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Device not found' });
}
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'compliance_metadata_update',
entityType: 'compliance_item',
entityId: hostname,
details: { resolution_date, remediation_plan },
ipAddress: req.ip,
});
res.json({ updated: result.rowCount });
} catch (err) {
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
res.status(500).json({ error: 'Failed to update device metadata' });
}
});
// -----------------------------------------------------------------------
// GET /vcl/stats — VCL executive summary statistics
// -----------------------------------------------------------------------
const VCL_TARGET_PCT = parseInt(process.env.VCL_TARGET_PCT, 10) || 95;
router.get('/vcl/stats', async (req, res) => {
try {
// Fetch all active compliance items
const { rows: items } = await pool.query(
`SELECT hostname, team, status, resolution_date, remediation_plan,
CASE WHEN status = 'resolved' THEN true ELSE false END AS is_compliant,
true AS in_scope
FROM compliance_items WHERE status = 'active'`
);
// For stats computation, all active items are non-compliant (they are findings)
// We need total in-scope devices (active + resolved from latest upload)
const { rows: latestUploadRows } = await pool.query(
`SELECT id FROM compliance_uploads ORDER BY id DESC LIMIT 1`
);
let allDeviceItems = [];
if (latestUploadRows.length > 0) {
const { rows: allItems } = await pool.query(
`SELECT hostname, team, status, resolution_date, remediation_plan,
CASE WHEN status = 'resolved' THEN true ELSE false END AS is_compliant,
true AS in_scope
FROM compliance_items`
);
// Deduplicate by hostname — a device is compliant if it has no active findings
const deviceMap = new Map();
for (const item of allItems) {
const existing = deviceMap.get(item.hostname);
if (!existing) {
deviceMap.set(item.hostname, { ...item, is_compliant: item.status !== 'active', in_scope: true });
} else if (item.status === 'active') {
existing.is_compliant = false;
}
}
allDeviceItems = Array.from(deviceMap.values());
}
const stats = computeVCLStats(allDeviceItems, VCL_TARGET_PCT);
// Donut: categorize non-compliant items by resolution_date presence
const nonCompliantItems = items.filter(i => i.status === 'active');
const donut = categorizeNonCompliant(nonCompliantItems);
// Heavy hitters: group by team, count non-compliant per team
const teamCounts = {};
for (const item of nonCompliantItems) {
const team = item.team || 'Unknown';
if (!teamCounts[team]) {
teamCounts[team] = { vertical: team, team: team, non_compliant: 0, compliance_date: null, notes: '' };
}
teamCounts[team].non_compliant++;
// Use the latest resolution_date as the team's compliance_date
if (item.resolution_date && (!teamCounts[team].compliance_date || item.resolution_date > teamCounts[team].compliance_date)) {
teamCounts[team].compliance_date = item.resolution_date;
}
}
const heavy_hitters = rankHeavyHitters(Object.values(teamCounts));
// Vertical breakdown with burndown
const verticalBreakdown = [];
for (const team of Object.keys(teamCounts)) {
const teamItems = nonCompliantItems.filter(i => (i.team || 'Unknown') === team);
const teamAllDevices = allDeviceItems.filter(i => (i.team || 'Unknown') === team);
const teamTotal = teamAllDevices.length;
const teamCompliant = teamAllDevices.filter(i => i.is_compliant).length;
const compliance_pct = teamTotal > 0 ? Math.round((teamCompliant / teamTotal) * 100) : 0;
const actual_burndown = computeForecastBurndown(teamItems.filter(i => i.resolution_date));
const forecast_burndown = computeForecastBurndown(teamItems.filter(i => i.resolution_date));
const blockers = teamItems.filter(i => !i.resolution_date).length;
verticalBreakdown.push({
vertical: team,
compliance_pct,
team: team,
non_compliant: teamItems.length,
actual_burndown,
forecast_burndown,
blockers,
risk_acceptances: 0,
notes: '',
});
}
res.json({ stats, donut, heavy_hitters, vertical_breakdown: verticalBreakdown });
} catch (err) {
console.error('[Compliance] GET /vcl/stats error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// GET /vcl/trend — Monthly compliance trend with forecast
// -----------------------------------------------------------------------
router.get('/vcl/trend', async (req, res) => {
try {
const { rows: snapshots } = await pool.query(
`SELECT snapshot_month, SUM(compliant)::int AS compliant_count,
CASE WHEN SUM(total_devices) > 0
THEN ROUND((SUM(compliant)::numeric / SUM(total_devices)::numeric) * 100, 1)
ELSE 0 END AS compliance_pct
FROM compliance_snapshots
GROUP BY snapshot_month
ORDER BY snapshot_month ASC`
);
// Build months array with actuals
const months = snapshots.map(s => ({
month: s.snapshot_month,
compliant_count: s.compliant_count,
compliance_pct: parseFloat(s.compliance_pct),
forecast_pct: null,
target_pct: VCL_TARGET_PCT,
}));
// Compute forecast using linear regression if we have 3+ months
if (months.length >= 3) {
const n = months.length;
// Use last data points for regression
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += months[i].compliance_pct;
sumXY += i * months[i].compliance_pct;
sumX2 += i * i;
}
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
// Project forward 3 months
for (let i = 0; i < 3; i++) {
const futureIdx = n + i;
const forecastPct = Math.min(100, Math.max(0, Math.round((slope * futureIdx + intercept) * 10) / 10));
// Compute the future month string
const lastMonth = months[months.length - 1].month;
const [year, mon] = lastMonth.split('-').map(Number);
const futureDate = new Date(year, mon - 1 + i + 1, 1);
const futureMonth = `${futureDate.getFullYear()}-${String(futureDate.getMonth() + 1).padStart(2, '0')}`;
months.push({
month: futureMonth,
compliant_count: null,
compliance_pct: null,
forecast_pct: forecastPct,
target_pct: VCL_TARGET_PCT,
});
}
// Also add forecast_pct to the last actual month as the starting point
if (months.length > 0 && n > 0) {
months[n - 1].forecast_pct = months[n - 1].compliance_pct;
}
}
res.json({ months });
} catch (err) {
console.error('[Compliance] GET /vcl/trend error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// POST /vcl/bulk-preview — Bulk upload diff preview
// -----------------------------------------------------------------------
router.post('/vcl/bulk-preview', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { rows, headers } = req.body;
// Validate: require rows array
if (!rows || !Array.isArray(rows)) {
return res.status(400).json({ error: 'rows array is required' });
}
// Enforce 2000 row limit
if (rows.length === 0) {
return res.status(400).json({ error: 'File contains no data rows' });
}
if (rows.length > 2000) {
return res.status(400).json({ error: 'File exceeds maximum of 2000 rows' });
}
// Map column headers if provided
let columnMapping = {};
if (headers && Array.isArray(headers)) {
columnMapping = mapColumnHeaders(headers);
}
// Require hostname field
const hasHostname = rows.every(r => r.hostname != null && r.hostname !== '');
if (!hasHostname) {
return res.status(400).json({ error: 'File must contain a Hostname column' });
}
// Check for updatable fields (resolution_date, remediation_plan, or notes)
const sampleRow = rows[0] || {};
const updatableFields = ['resolution_date', 'remediation_plan', 'notes'];
const hasUpdatableFields = updatableFields.some(f => f in sampleRow);
if (!hasUpdatableFields && headers) {
// Check via column mapping
const mappedFields = Object.keys(columnMapping).filter(k => k !== 'hostname');
if (mappedFields.length === 0) {
return res.status(400).json({ error: 'No updatable fields found (need Resolution Date, Remediation Plan, or Notes)' });
}
} else if (!hasUpdatableFields && !headers) {
return res.status(400).json({ error: 'No updatable fields found (need Resolution Date, Remediation Plan, or Notes)' });
}
try {
// Get existing hostnames from DB
const { rows: existingRows } = await pool.query(
`SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active'`
);
const existingHostnames = new Set(existingRows.map(r => r.hostname));
// Match by hostname
const { matched, unmatched } = matchByHostname(rows, existingHostnames);
// Validate fields on matched rows
const validRows = [];
const invalidRows = [];
for (const row of matched) {
const errors = [];
if (row.resolution_date !== undefined && row.resolution_date !== null && row.resolution_date !== '') {
if (!isValidDateString(row.resolution_date)) {
errors.push('resolution_date: invalid date format');
}
}
if (row.remediation_plan !== undefined && row.remediation_plan !== null) {
const planCheck = validateRemediationPlan(row.remediation_plan);
if (!planCheck.valid) {
errors.push('remediation_plan: ' + planCheck.error);
}
}
if (errors.length > 0) {
invalidRows.push({ hostname: row.hostname, errors });
} else {
validRows.push(row);
}
}
// Get current data for diff computation
const { rows: currentRows } = await pool.query(
`SELECT DISTINCT ON (hostname) hostname, resolution_date, remediation_plan
FROM compliance_items WHERE status = 'active' AND hostname = ANY($1)
ORDER BY hostname, id DESC`,
[validRows.map(r => r.hostname)]
);
const currentData = new Map();
for (const row of currentRows) {
currentData.set(row.hostname, {
resolution_date: row.resolution_date ? row.resolution_date.toISOString?.().slice(0, 10) || String(row.resolution_date).slice(0, 10) : null,
remediation_plan: row.remediation_plan || null,
notes: null,
});
}
// Compute diff
const diffResults = computeBulkDiff(validRows, currentData);
const changedRows = diffResults.filter(r => r.status === 'changed');
res.json({
matched: matched.length,
unmatched: unmatched.length,
changes: changedRows.length,
invalid: invalidRows.length,
details: diffResults,
unmatched_rows: unmatched.map(r => r.hostname),
invalid_rows: invalidRows,
});
} catch (err) {
console.error('[Compliance] POST /vcl/bulk-preview error:', err.message);
res.status(500).json({ error: 'Failed to process bulk preview' });
}
});
// -----------------------------------------------------------------------
// POST /vcl/bulk-commit — Commit validated bulk changes
// -----------------------------------------------------------------------
router.post('/vcl/bulk-commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { changes } = req.body;
if (!changes || !Array.isArray(changes) || changes.length === 0) {
return res.status(400).json({ error: 'changes array is required' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
let committedCount = 0;
for (const change of changes) {
const setClauses = [];
const values = [];
let paramIdx = 1;
if (change.resolution_date !== undefined) {
setClauses.push(`resolution_date = $${paramIdx++}`);
values.push(change.resolution_date);
}
if (change.remediation_plan !== undefined) {
setClauses.push(`remediation_plan = $${paramIdx++}`);
values.push(change.remediation_plan);
}
if (change.notes !== undefined) {
// Notes are stored separately in compliance_notes, but we can update a field if it exists
// For now, skip notes in the direct update
}
if (setClauses.length === 0) continue;
values.push(change.hostname);
const result = await client.query(
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
values
);
if (result.rowCount > 0) committedCount++;
}
await client.query('COMMIT');
logAudit({
userId: req.user.id,
username: req.user.username,
action: 'compliance_bulk_update',
entityType: 'compliance_items',
entityId: null,
details: { rows_updated: committedCount, total_changes: changes.length },
ipAddress: req.ip,
});
res.json({ committed: committedCount });
} catch (err) {
await client.query('ROLLBACK');
console.error('[Compliance] POST /vcl/bulk-commit error:', err.message);
res.status(500).json({ error: 'Failed to commit changes' });
} finally {
client.release();
}
});
return router;
}