Files
pulse-signage/test/audit-log-settings.test.js

220 lines
11 KiB
JavaScript

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: '<svg>' + 'x'.repeat(100000) + '</svg>' } } };
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: '<svg>' + 'x'.repeat(100000) + '</svg>', qr_preview: 'data:image/png;base64,' + 'y'.repeat(100000), value: 'https://example.com/old' } } };
const next = { content: { qr: { qr_svg: '<svg>new</svg>', 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: '<svg>old</svg>' } };
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: '<svg>new</svg>' } }] });
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 }
});
});