const test = require('node:test'); const assert = require('node:assert/strict'); require('../src/common'); const registerAuditLogRoutes = require('../src/web/routes/settings/audit-log'); const { buildDiffSegments } = require('../src/web/public/js/settings/audit-log'); test('audit diff highlights only the differing middle span', () => { assert.deepEqual(buildDiffSegments('prefix old suffix', 'prefix new suffix'), { before: [ { text: 'prefix ', changed: false }, { text: 'old', changed: true }, { text: ' suffix', changed: false } ], after: [ { text: 'prefix ', changed: false }, { text: 'new', changed: true }, { text: ' suffix', changed: false } ] }); assert.deepEqual(buildDiffSegments('', 'added'), { before: [], after: [{ text: 'added', changed: true }] }); }); test('audit log uses its own read permission and shared pagination partial', async () => { const handlers = {}; const queries = []; const app = { get(path, ...routeHandlers) { handlers[path] = routeHandlers; } }; registerAuditLogRoutes(app, { pool: { async query(sql, params) { const query = String(sql); queries.push([query, params || []]); if (query.includes('SELECT e.details_json FROM o_audit_events e WHERE e.id = ?')) { return [[{ details_json: JSON.stringify({ changes: { 'content.heading.value': { from: 'Old heading', to: 'New heading' }, 'content.qr.qr_svg': { from: '[Generated asset: 13 bytes; SHA-256 old]', to: '[Generated asset: 13 bytes; SHA-256 new]' } } }) }]]; } if (query.includes('SELECT COUNT(*) AS count')) { return [[{ count: 2 }]]; } if (query.includes('SELECT DISTINCT e.event_type')) { return [[{ event_type: 'login.failed' }, { event_type: 'login.success' }]]; } if (query.includes('FROM o_audit_events')) { return [[ { id: 1, occurred_at: '2026-08-16T12:00:00.000Z', category: 'authentication', event_type: 'login.success', target_label: 'newer', user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36', details_json: '{}', has_changes: 1 }, { id: 2, occurred_at: '2026-08-15T12:00:00.000Z', category: 'authentication', event_type: 'login.failed', target_label: 'older', details_json: null, has_changes: 0 } ]]; } return [[]]; } }, formatDashboardDate() { return 'Aug 16, 2026'; } }); assert.ok(handlers['/settings/audit-log']); const request = { currentUser: { id: 7, permissions: ['audit-log.read', 'audit-log.allow'] }, query: {} }; let continued = false; handlers['/settings/audit-log'][0](request, {}, function () { continued = true; }); assert.equal(continued, true); const response = { send(value) { this.body = value; } }; await handlers['/settings/audit-log'][1](request, response, function (error) { throw error; }); assert.match(response.body, /Audit log/); assert.ok(response.body.indexOf('>All categories') < response.body.indexOf('>announcements')); assert.ok(response.body.indexOf('>announcements') < response.body.indexOf('>api-sources')); assert.ok(response.body.indexOf('>api-sources') < response.body.indexOf('>canvas-sizes')); assert.match(response.body, /table-pagination/); assert.match(response.body, /data-local-datetime/); assert.match(response.body, /audit-event-type-options/); assert.match(response.body, /login\.success/); assert.match(response.body, />Chrome 150\.0\.0\.0 on WindowsExport sql.includes('LIMIT ? OFFSET ?')); assert.ok(pageQuery); assert.match(pageQuery[0], /ORDER BY e\.occurred_at DESC, e\.id DESC LIMIT \? OFFSET \?/); assert.match(pageQuery[0], /JSON_CONTAINS_PATH\(e\.details_json, 'one', '\$\.changes'\) AS has_changes/); assert.deepEqual(pageQuery[1].slice(-2), [25, 0]); assert.ok(queries.some(([sql]) => sql.includes('SELECT COUNT(*) AS count'))); assert.ok(queries.some(([sql]) => sql.includes('SELECT DISTINCT e.event_type') && /WHERE e\.event_type IS NOT NULL/.test(sql))); let popupPayload; await handlers['/settings/audit-log/:id/details'][1]({ params: { id: '1' } }, { json(value) { popupPayload = value; } }, function (error) { throw error; }); assert.deepEqual(popupPayload.changeRows.map(row => [row.path, row.from, row.to]), [ ['content.heading.value', 'Old heading', 'New heading'], ['content.qr.qr_svg', '[Generated asset: 13 bytes; SHA-256 old]', '[Generated asset: 13 bytes; SHA-256 new]'] ]); const exportResponse = { headers: {}, setHeader(name, value) { this.headers[name] = value; }, send(value) { this.body = value; } }; await handlers['/settings/audit-log/export'][1](request, exportResponse, function (error) { throw error; }); assert.match(exportResponse.headers['Content-Type'], /text\/csv/); assert.match(exportResponse.body, /Occurred At \(UTC\)/); assert.match(exportResponse.body, /login.success/); const exportQuery = queries.filter(([sql]) => sql.includes('FROM o_audit_events') && !sql.includes('SELECT COUNT(*)') && !sql.includes('SELECT DISTINCT')).at(-1); assert.ok(exportQuery); assert.doesNotMatch(exportQuery[0], /LIMIT \?/); assert.match(exportQuery[0], /e\.details_json/); assert.doesNotMatch(exportQuery[0], /JSON_REMOVE/); const listQuery = queries.find(([sql]) => sql.includes('LIMIT ? OFFSET ?')); assert.ok(listQuery); assert.match(listQuery[0], /JSON_CONTAINS_PATH\(e\.details_json, 'one', '\$\.changes'\) AS has_changes/); assert.match(listQuery[0], /JSON_REMOVE\(e\.details_json, '\$\.changes'\) AS details_json/); }); test('audit log applies SQL filters, escaped search, and selected sort before pagination', async () => { const handlers = {}; const queries = []; const app = { get(path, ...routeHandlers) { handlers[path] = routeHandlers; } }; registerAuditLogRoutes(app, { pool: { async query(sql, params) { const query = String(sql); queries.push([query, params || []]); if (query.includes('SELECT COUNT(*) AS count')) return [[{ count: 40 }]]; return [[]]; } }, formatDashboardDate() { return ''; } }); await handlers['/settings/audit-log'][1]({ currentUser: { id: 7, permissions: ['audit-log.read'] }, query: { category: 'Authentication', event_type: 'Login.Success', search: ' Alice%_ ', sort: 'actor', direction: 'desc', page: '2' } }, { send() {} }, function (error) { throw error; }); const countQuery = queries.find(([sql]) => sql.includes('SELECT COUNT(*) AS count')); const pageQuery = queries.find(([sql]) => sql.includes('LIMIT ? OFFSET ?')); const eventTypesQuery = queries.find(([sql]) => sql.includes('SELECT DISTINCT e.event_type')); assert.ok(countQuery); assert.ok(pageQuery); assert.ok(eventTypesQuery); assert.match(countQuery[0], /e\.category = \? AND e\.event_type = \?/); assert.match(countQuery[0], /LOWER\(COALESCE\(CAST\(e\.category AS CHAR\)/); assert.deepEqual(countQuery[1].slice(0, 2), ['authentication', 'login.success']); assert.ok(countQuery[1].includes('%alice\\%\\_%')); assert.match(pageQuery[0], /ORDER BY COALESCE\(NULLIF\(u\.name, ''\), u\.username, 'System'\) DESC, e\.occurred_at DESC, e\.id DESC/); assert.deepEqual(pageQuery[1].slice(-2), [25, 25]); assert.match(eventTypesQuery[0], /AND e\.event_type IS NOT NULL/); });