Files
pulse-signage/test/audit-log-route.test.js
T
lzstealth 61d553f74b
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m23s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
Release v2.20.2
2026-10-25 19:08:10 +00:00

212 lines
8.4 KiB
JavaScript

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</option>') < response.body.indexOf('>announcements</option>'));
assert.ok(response.body.indexOf('>announcements</option>') < response.body.indexOf('>api-sources</option>'));
assert.ok(response.body.indexOf('>api-sources</option>') < response.body.indexOf('>canvas-sizes</option>'));
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 Windows</);
assert.doesNotMatch(response.body, /Mozilla\/5\.0 \(Windows NT 10\.0; Win64; x64\)/);
assert.doesNotMatch(response.body, /content\.heading\.value|Old heading|New heading/);
assert.match(response.body, /data-audit-detail-url="\/settings\/audit-log\/1\/details"/);
assert.match(response.body, /id="audit-change-detail-modal"/);
assert.match(response.body, /\/assets\/js\/settings\/audit-log\.js/);
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
assert.match(response.body, />Export</);
assert.ok(handlers['/settings/audit-log/export']);
const pageQuery = queries.find(([sql]) => 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/);
});