220 lines
11 KiB
JavaScript
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 }
|
|
});
|
|
}); |