const test = require('node:test'); const assert = require('node:assert/strict'); require('../src/common'); const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, buildAuditChanges, buildSlideAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log'); const { getDefaultAppSettings } = require('../src/data/app-settings'); function createPool(settings) { const inserts = []; return { inserts, async query(sql, params) { if (String(sql).includes('SELECT id, setting_key, setting_value FROM o_app_settings')) { return [[ { setting_key: 'audit.enabled', setting_value: JSON.stringify(settings.enabled) }, { setting_key: 'audit.categories', setting_value: JSON.stringify(settings.categories) }, { setting_key: 'audit.screen_control_commands', setting_value: JSON.stringify(settings.screenControlCommands || []) }, { setting_key: 'audit.include_request_metadata', setting_value: JSON.stringify(settings.includeMetadata) } ]]; } if (String(sql).includes('INSERT INTO o_audit_events')) { inserts.push(params); } return [[]]; } }; } const request = { ip: '192.0.2.10', headers: { 'user-agent': 'audit-test' } }; test('audit categories include weather data sources', () => { assert.ok(AUDIT_CATEGORY_KEYS.includes('weather')); assert.equal(AUDIT_CATEGORY_LABELS.weather, 'Weather'); assert.ok(AUDIT_CATEGORY_KEYS.includes('media-assets')); assert.equal(AUDIT_CATEGORY_LABELS['media-assets'], 'Media Assets'); }); test('screen control auditing is disabled by default', () => { assert.equal(getDefaultAppSettings()['audit.categories'].includes('screen-controls'), false); assert.deepEqual(getDefaultAppSettings()['audit.screen_control_commands'], []); }); test('screen control auditing filters commands and groups navigation', async () => { const pool = createPool({ enabled: true, categories: ['screen-controls'], screenControlCommands: ['navigation'], includeMetadata: false }); await recordRequestAuditEvent(pool, request, { category: 'screen-controls', eventType: 'screen-control.previous' }); await recordRequestAuditEvent(pool, request, { category: 'screen-controls', eventType: 'screen-control.pause' }); assert.equal(pool.inserts.length, 1); assert.equal(pool.inserts[0][1], 'screen-control.previous'); }); test('audit writer skips disabled categories', async () => { const pool = createPool({ enabled: true, categories: ['security'], includeMetadata: true }); await recordRequestAuditEvent(pool, request, { category: 'authentication', eventType: 'login.success' }); assert.equal(pool.inserts.length, 0); }); test('audit writer omits request metadata when disabled', async () => { const pool = createPool({ enabled: true, categories: ['authentication'], includeMetadata: false }); await recordRequestAuditEvent(pool, request, { category: 'authentication', eventType: 'login.success' }); assert.equal(pool.inserts.length, 1); assert.equal(pool.inserts[0][6], null); assert.equal(pool.inserts[0][7], null); }); test('audit writer skips empty update diffs for content but preserves action events and other details', async () => { const categories = ['slides', 'templates', 'playlists', 'screens', 'canvas-sizes', 'weather']; const pool = createPool({ enabled: true, categories, includeMetadata: false }); for (const category of categories) { await recordRequestAuditEvent(pool, request, { category, eventType: 'content.updated', details: { changes: {} } }); } assert.equal(pool.inserts.length, 0); await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.created' }); await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.deleted' }); await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.updated', details: { changes: {}, promotedSlideId: 7 } }); await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.updated', details: { changes: { 'regions.heading.x': { from: 1, to: 2 } } } }); assert.equal(pool.inserts.length, 4); }); test('audit changes include only fields with different from and to values', () => { assert.deepEqual(buildAuditChanges({ name: 'Old name', playlistId: 4, roleIds: [1, 2] }, { name: 'New name', playlistId: 4, roleIds: [2, 3] }), { name: { from: 'Old name', to: 'New name' }, roleIds: { from: [1, 2], to: [2, 3] } }); }); test('slide audit stores only changed leaf fields without unchanged content', () => { const previous = { title: 'Slide', content: { heading: { value: 'Old', font_size: 32 }, qr: { qr_svg: '' + 'x'.repeat(100000) + '' } } }; const next = structuredClone(previous); next.content.heading.value = 'New'; const changes = buildSlideAuditChanges(previous, next); assert.deepEqual(changes, { 'content.heading.value': { from: 'Old', to: 'New' } }); assert.ok(JSON.stringify(changes).length < 100); }); test('slide audit ignores unchanged saves and object key order', () => { assert.deepEqual(buildSlideAuditChanges({ content: { body: { value: 'Same', size: 32 } } }, { content: { body: { size: 32, value: 'Same' } } }), {}); }); test('slide audit preserves added and removed fields across JSON serialization', () => { const changes = buildSlideAuditChanges({ content: { removed: { value: 'Old' }, body: { muted: false } } }, { content: { added: { value: 'New' }, body: { muted: true } } }); assert.deepEqual(JSON.parse(JSON.stringify(changes)), { 'content.removed.value': { from: 'Old', to: null }, 'content.body.muted': { from: false, to: true }, 'content.added.value': { from: null, to: 'New' } }); }); test('slide audit fingerprints generated QR payloads including added and removed assets', () => { const previous = { content: { qr: { qr_svg: '' + 'x'.repeat(100000) + '', qr_preview: 'data:image/png;base64,' + 'y'.repeat(100000), value: 'https://example.com/old' } } }; const next = { content: { qr: { qr_svg: 'new', value: 'https://example.com/new' } } }; const changes = buildSlideAuditChanges(previous, next); assert.match(changes['content.qr.qr_svg'].from, /^\[Generated asset: 100011 bytes; SHA-256 [a-f0-9]{64}\]$/); assert.notEqual(changes['content.qr.qr_svg'].from, changes['content.qr.qr_svg'].to); assert.equal(changes['content.qr.qr_preview'].to, null); assert.deepEqual(changes['content.qr.value'], { from: 'https://example.com/old', to: 'https://example.com/new' }); assert.ok(JSON.stringify(changes).length < 1000); const added = buildSlideAuditChanges({}, next); assert.equal(added['content.qr.qr_svg'].from, null); assert.match(added['content.qr.qr_svg'].to, /SHA-256/); assert.equal(previous.content.qr.qr_svg.length, 100011); }); test('slide save skips no-op audit events while retaining save side effects', async () => { const registerContentRoutes = require('../src/web/routes/admin/content'); for (const changed of [false, true]) { const handlers = {}; const calls = []; const events = []; const content = { heading: { type: 'text', value: 'Old' } }; const nextContent = { heading: { type: 'text', value: changed ? 'New' : 'Old' } }; const noop = async () => {}; const connection = { beginTransaction: noop, async commit() { calls.push('commit'); }, rollback: noop, release() {}, async query(sql) { if (sql.startsWith('SELECT id FROM c_slides')) return [[{ id: 7 }]]; if (sql.startsWith('UPDATE c_slides')) calls.push('update'); return [[]]; } }; registerContentRoutes({ get() {}, post(route, ...actions) { handlers[route] = actions; } }, { pool: { async query() { return [[]]; }, async getConnection() { return connection; } }, common: { fetchSlideById: async () => ({ id: 7, title: 'Slide', template_id: 2, content }), buildSlidePayload: async () => ({ title: 'Slide', templateId: 2, contentJson: JSON.stringify(nextContent) }), fetchDuplicateName: async () => null, parseJsonSafe: JSON.parse }, pages: {}, upload: { any: () => noop, single: () => noop }, fetchScreensBySlideId: async () => [], fetchScreensByTemplateId: async () => [], collectUploadReferencesFromSlide: () => [], collectUploadReferencesFromTemplate: () => [], collectUploadReferencesFromPayload: () => [], removeUnusedUploadFiles: noop, syncPlaylistUploadsOnChange: async () => { calls.push('sync'); }, getAuditUserId: () => 9, recordRequestAuditEvent: async (_pool, _req, event) => { events.push(event); }, redirectAfterSave: () => { calls.push('redirect'); }, notifyPlayerScreens: noop, broadcastDashboardState: async () => { calls.push('broadcast'); }, backgroundTaskQueue: { enqueueTask: async () => { calls.push('thumbnail'); } }, getSlideDeleteBlockMessage: noop, getTemplateDeleteBlockMessage: noop, getCanvasSizeDeleteBlockMessage: noop, hasAnyPermission: () => false, requirePermission: () => noop }); await handlers['/slides/:id'].at(-1)({ params: { id: '7' }, files: [] }, {}, error => { throw error; }); assert.deepEqual(calls, ['update', 'commit', 'sync', 'broadcast', 'thumbnail', 'redirect']); assert.equal(events.length, changed ? 1 : 0); if (changed) { assert.equal(events[0].eventType, 'slide.updated'); assert.deepEqual(events[0].details.changes, { 'content.heading.value': { from: 'Old', to: 'New' } }); } } }); test('shared audit changes compact nested content across categories', () => { assert.deepEqual(buildAuditChanges({ settings: { text: 'Old', keep: 'Same' } }, { settings: { text: 'New', keep: 'Same' } }), { 'settings.text': { from: 'Old', to: 'New' } }); }); test('template auditing matches regions by stable key and summarizes default QR assets', () => { const heading = { region_key: 'heading', x: 10, default_content: { value: 'Same' } }; const qr = { region_key: 'qr', default_content: { qr_svg: 'old' } }; assert.deepEqual(buildAuditChanges({ regions: [heading, qr] }, { regions: [qr, heading] }), {}); const changes = buildAuditChanges({ regions: [heading, qr] }, { regions: [qr, { ...heading, x: 20 }] }); assert.deepEqual(changes, { 'regions.heading.x': { from: 10, to: 20 } }); const assets = buildAuditChanges({ regions: [qr] }, { regions: [{ ...qr, default_content: { qr_svg: 'new' } }] }); assert.match(assets['regions.qr.default_content.qr_svg'].from, /SHA-256/); assert.match(assets['regions.qr.default_content.qr_svg'].to, /SHA-256/); }); test('ordered content collections retain reordering and added or removed item differences', () => { const first = { slideId: 1, duration: 10 }; const second = { slideId: 2, duration: 10 }; assert.deepEqual(buildAuditChanges({ items: [first, second] }, { items: [second, first] }), { 'items[0].slideId': { from: 1, to: 2 }, 'items[1].slideId': { from: 2, to: 1 } }); assert.deepEqual(buildAuditChanges({ items: [first, second] }, { items: [first] }), { 'items[1].slideId': { from: 2, to: null }, 'items[1].duration': { from: 10, to: null } }); });