diff --git a/backend/__tests__/vcl-compliance-reporting.property.test.js b/backend/__tests__/vcl-compliance-reporting.property.test.js new file mode 100644 index 0000000..f07eeb1 --- /dev/null +++ b/backend/__tests__/vcl-compliance-reporting.property.test.js @@ -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 } + ); + }); +}); diff --git a/backend/__tests__/vcl-compliance-reporting.test.js b/backend/__tests__/vcl-compliance-reporting.test.js new file mode 100644 index 0000000..80bde06 --- /dev/null +++ b/backend/__tests__/vcl-compliance-reporting.test.js @@ -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); + }); +}); diff --git a/backend/helpers/vclHelpers.js b/backend/helpers/vclHelpers.js new file mode 100644 index 0000000..2a8392e --- /dev/null +++ b/backend/helpers/vclHelpers.js @@ -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, +}; diff --git a/backend/migrations/add_vcl_reporting_columns.js b/backend/migrations/add_vcl_reporting_columns.js new file mode 100644 index 0000000..8e66ec4 --- /dev/null +++ b/backend/migrations/add_vcl_reporting_columns.js @@ -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(); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index e5470b8..738fa21 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -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() { diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index fc4a388..4e4f0dd 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -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; } diff --git a/frontend/src/components/pages/BulkUploadModal.js b/frontend/src/components/pages/BulkUploadModal.js new file mode 100644 index 0000000..8cb41fc --- /dev/null +++ b/frontend/src/components/pages/BulkUploadModal.js @@ -0,0 +1,463 @@ +import React, { useState, useRef } from 'react'; +import { X, Upload, AlertCircle, Loader, CheckCircle, FileSpreadsheet } from 'lucide-react'; +import * as XLSX from 'xlsx'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; +const TEAL = '#14B8A6'; +const RED = '#EF4444'; +const AMBER = '#F59E0B'; +const EMERALD = '#10B981'; + +function mapColumnHeaders(headers) { + const mapping = {}; + for (const h of headers) { + const lower = h.toLowerCase().trim(); + if (lower === 'hostname') mapping.hostname = h; + else if (lower === 'resolution date' || lower === 'resolution_date') mapping.resolution_date = h; + else if (lower === 'remediation plan' || lower === 'remediation_plan') mapping.remediation_plan = h; + else if (lower === 'notes') mapping.notes = h; + } + return mapping; +} + +function isValidDateString(str) { + if (!str || typeof str !== 'string' || str.trim() === '') return false; + const d = new Date(str); + if (isNaN(d.getTime())) return false; + // Check it's a real date by comparing parts + const parts = str.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!parts) return false; + const [, y, m, day] = parts; + return d.getFullYear() === parseInt(y) && (d.getMonth() + 1) === parseInt(m) && d.getDate() === parseInt(day); +} + +export default function BulkUploadModal({ onClose }) { + const fileRef = useRef(null); + const [step, setStep] = useState('upload'); // upload, preview, committing, done + const [parsedRows, setParsedRows] = useState([]); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [commitLoading, setCommitLoading] = useState(false); + const [error, setError] = useState(null); + const [commitResult, setCommitResult] = useState(null); + + const handleFileSelect = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + setError(null); + + try { + const data = await file.arrayBuffer(); + const workbook = XLSX.read(data, { type: 'array' }); + const sheetName = workbook.SheetNames[0]; + const sheet = workbook.Sheets[sheetName]; + const jsonData = XLSX.utils.sheet_to_json(sheet, { defval: '' }); + + if (jsonData.length === 0) { + setError('File contains no data rows'); + return; + } + + const headers = Object.keys(jsonData[0]); + const colMap = mapColumnHeaders(headers); + + if (!colMap.hostname) { + setError('File must contain a Hostname column'); + return; + } + + const hasUpdatableFields = colMap.resolution_date || colMap.remediation_plan || colMap.notes; + if (!hasUpdatableFields) { + setError('No updatable fields found (need Resolution Date, Remediation Plan, or Notes)'); + return; + } + + // Build rows for API + const rows = jsonData.map(row => { + const mapped = { hostname: String(row[colMap.hostname] || '').trim() }; + if (colMap.resolution_date) { + const val = String(row[colMap.resolution_date] || '').trim(); + mapped.resolution_date = val || null; + } + if (colMap.remediation_plan) { + const val = String(row[colMap.remediation_plan] || '').trim(); + mapped.remediation_plan = val || null; + } + if (colMap.notes) { + const val = String(row[colMap.notes] || '').trim(); + mapped.notes = val || null; + } + return mapped; + }).filter(r => r.hostname); + + // Client-side validation + const validatedRows = rows.map(row => { + const errors = []; + if (row.resolution_date && !isValidDateString(row.resolution_date)) { + errors.push('Invalid date format for Resolution Date'); + } + if (row.remediation_plan && row.remediation_plan.length > 2000) { + errors.push('Remediation Plan exceeds 2000 characters'); + } + return { ...row, _clientErrors: errors }; + }); + + setParsedRows(validatedRows); + + // Call bulk-preview API + setPreviewLoading(true); + setStep('preview'); + + const res = await fetch(`${API_BASE}/compliance/vcl/bulk-preview`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rows: validatedRows.map(({ _clientErrors, ...r }) => r) }), + }); + const previewData = await res.json(); + if (!res.ok) throw new Error(previewData.error || 'Preview failed'); + + setPreview(previewData); + } catch (err) { + setError(err.message); + setStep('upload'); + } finally { + setPreviewLoading(false); + } + }; + + const handleCommit = async () => { + if (!preview || !preview.details) return; + + setCommitLoading(true); + setError(null); + + try { + const changes = preview.details + .filter(d => d.status === 'changed') + .map(d => { + const change = { hostname: d.hostname }; + if (d.fields.resolution_date) change.resolution_date = d.fields.resolution_date.new; + if (d.fields.remediation_plan) change.remediation_plan = d.fields.remediation_plan.new; + if (d.fields.notes) change.notes = d.fields.notes.new; + return change; + }); + + const res = await fetch(`${API_BASE}/compliance/vcl/bulk-commit`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ changes }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Commit failed'); + + setCommitResult(data); + setStep('done'); + } catch (err) { + setError(err.message); + } finally { + setCommitLoading(false); + } + }; + + const handleCancel = () => { + setParsedRows([]); + setPreview(null); + setError(null); + onClose(); + }; + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +