Release v2.20.2
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

This commit is contained in:
lzstealth committed 2026-10-25 19:08:10 +00:00
1 parent 220c3f401e
commit 61d553f74b
14 files changed
+395 -94

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file.
## 2.20.2 - 2026-10-25
### Changed
- Audit-log entries summarize changed items and open detailed before/after values in synchronized side-by-side panes with differences highlighted.
## 2.20.1 - 2026-10-25
### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.20.1",
"version": "2.20.2",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.20.1",
"version": "2.20.2",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.20.1",
"version": "2.20.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.20.1",
"version": "2.20.2",
"dependencies": {
"@better-auth/passkey": "^1.7.13",
"animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.20.1",
"version": "2.20.2",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
+2 -4
View File
@@ -24,7 +24,7 @@ const { rbacData } = require('#src/web/lib/auth/index');
const { createPlayerActionService } = require('#src/web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth/index');
const { createBetterAuth, getRoleTwoFactorAuthError } = require('#src/web/lib/auth/better-auth');
const { createBetterAuth, ensureBetterAuthClientIp, getRoleTwoFactorAuthError } = require('#src/web/lib/auth/better-auth');
const { fetchAppSettings } = require('#src/data/app-settings');
const { sendAccountEmail } = require('#src/data/mailer');
const { recordRequestAuditEvent } = require('#src/data/audit-log');
@@ -121,6 +121,7 @@ async function start() {
const setAuthMessageCookie = sessionService.setAuthMessageCookie;
const consumeAuthMessageCookie = sessionService.consumeAuthMessageCookie;
const loadCurrentUser = async function (pool, req) {
ensureBetterAuthClientIp(req);
const authSession = await betterAuth.api.getSession({ headers: fromNodeHeaders(req.headers) });
if (!authSession || !authSession.session || !authSession.session.token) return null;
req.betterAuthSession = authSession;
@@ -138,9 +139,6 @@ async function start() {
if (currentUser && currentUser.isViewingAs && (authPath.startsWith('/api/auth/two-factor/') || authPath.startsWith('/api/auth/passkey/'))) {
return res.status(403).json({ message: 'Exit View as before changing authentication settings.' });
}
if (!req.headers['x-forwarded-for'] && req.socket && req.socket.remoteAddress) {
req.headers['x-forwarded-for'] = req.socket.remoteAddress;
}
return betterAuthHandler(req, res, next);
} catch (error) {
next(error);
+7
View File
@@ -6,6 +6,12 @@ const { formatAccountEmailExpiry, normalizeOtpEmailBody, renderAccountEmailTempl
const LOGIN_RATE_LIMIT_PATHS = ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code'];
function ensureBetterAuthClientIp(req) {
if (!req.headers['x-forwarded-for'] && req.socket && req.socket.remoteAddress) {
req.headers['x-forwarded-for'] = req.socket.remoteAddress;
}
}
function getRoleTwoFactorAuthError(currentUser, requestPath) {
const actor = currentUser && (currentUser.authenticatedUser || currentUser);
if (!actor || !actor.requiresTwoFactor) return null;
@@ -311,6 +317,7 @@ async function createBetterAuth(pool, options) {
module.exports = {
createBetterAuth,
ensureBetterAuthClientIp,
createPasswordCallbacks,
createAccountLockSessionHook,
createLoginRateLimitResetHook,
+62 -18
View File
@@ -649,33 +649,77 @@
gap: 0.5rem;
}
/* Audit log change comparisons */
/* Audit log change details */
.audit-change-list {
.audit-change-summary {
display: grid;
padding: 0.08rem 0.3rem;
border-radius: 0.2rem;
gap: 0.2rem;
min-width: 18rem;
gap: 0.4rem;
min-width: 12rem;
}
.audit-change-row {
display: grid;
grid-template-columns: minmax(8rem, 0.7fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
gap: 0.35rem;
.audit-change-paths {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: baseline;
font-size: 0.78rem;
}
.audit-change-to {
.audit-change-path {
font-size: 0.72rem;
overflow-wrap: anywhere;
}
.audit-diff-changes {
display: grid;
gap: 1rem;
}
.audit-diff-change {
min-width: 0;
padding-bottom: 1rem;
border-bottom: 1px solid var(--bs-border-color);
}
.audit-diff-path {
overflow-wrap: anywhere;
}
.audit-diff-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.audit-diff-column {
min-width: 0;
}
.audit-diff-label {
margin-bottom: 0.25rem;
color: var(--bs-secondary-color);
font-size: 0.8rem;
font-weight: 600;
}
.audit-diff-pane {
max-height: 24rem;
min-height: 8rem;
margin: 0;
overflow: auto;
padding: 0.75rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.25rem;
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
font-size: 0.8rem;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.audit-change-from {
overflow-wrap: anywhere;
white-space: pre-wrap;
.audit-diff-removed {
background: var(--bs-danger-bg-subtle);
color: var(--bs-danger-text-emphasis);
text-decoration: line-through;
}
.audit-change-from del,
.audit-change-to ins {
text-decoration-thickness: 2px;
.audit-diff-added {
background: var(--bs-success-bg-subtle);
color: var(--bs-success-text-emphasis);
text-decoration: none;
}
@media (max-width: 767.98px) {
.audit-diff-pair {
grid-template-columns: minmax(0, 1fr);
}
}
/* Template preview states and permission tables */
+171
View File
@@ -0,0 +1,171 @@
(function () {
function buildDiffSegments(beforeValue, afterValue) {
var before = Array.from(String(beforeValue == null ? '' : beforeValue));
var after = Array.from(String(afterValue == null ? '' : afterValue));
var prefixLength = 0;
var suffixLength = 0;
while (prefixLength < before.length && prefixLength < after.length && before[prefixLength] === after[prefixLength]) {
prefixLength += 1;
}
while (suffixLength < before.length - prefixLength && suffixLength < after.length - prefixLength &&
before[before.length - suffixLength - 1] === after[after.length - suffixLength - 1]) {
suffixLength += 1;
}
function toSegments(characters) {
var segments = [];
var changedEnd = characters.length - suffixLength;
if (prefixLength) segments.push({ text: characters.slice(0, prefixLength).join(''), changed: false });
if (changedEnd > prefixLength) segments.push({ text: characters.slice(prefixLength, changedEnd).join(''), changed: true });
if (suffixLength) segments.push({ text: characters.slice(changedEnd).join(''), changed: false });
return segments;
}
return { before: toSegments(before), after: toSegments(after) };
}
function appendSegments(element, segments, changeClass) {
segments.forEach(function (segment) {
if (!segment.changed) {
element.appendChild(document.createTextNode(segment.text));
return;
}
var mark = document.createElement(changeClass === 'audit-diff-removed' ? 'del' : 'ins');
mark.className = changeClass;
mark.textContent = segment.text;
element.appendChild(mark);
});
}
function synchronizeScroll(beforePane, afterPane) {
var isSyncing = false;
function sync(source, target) {
if (isSyncing) return;
isSyncing = true;
var sourceVerticalRange = source.scrollHeight - source.clientHeight;
var targetVerticalRange = target.scrollHeight - target.clientHeight;
var sourceHorizontalRange = source.scrollWidth - source.clientWidth;
var targetHorizontalRange = target.scrollWidth - target.clientWidth;
target.scrollTop = sourceVerticalRange > 0 ? source.scrollTop / sourceVerticalRange * targetVerticalRange : 0;
target.scrollLeft = sourceHorizontalRange > 0 ? source.scrollLeft / sourceHorizontalRange * targetHorizontalRange : 0;
window.requestAnimationFrame(function () { isSyncing = false; });
}
beforePane.addEventListener('scroll', function () { sync(beforePane, afterPane); }, { passive: true });
afterPane.addEventListener('scroll', function () { sync(afterPane, beforePane); }, { passive: true });
}
function createDiffPane(label, value, segments, changeClass) {
var paneWrapper = document.createElement('section');
paneWrapper.className = 'audit-diff-column';
var heading = document.createElement('div');
heading.className = 'audit-diff-label';
heading.textContent = label;
var pane = document.createElement('pre');
pane.className = 'audit-diff-pane';
pane.tabIndex = 0;
pane.setAttribute('aria-label', label + ' value');
if (segments) {
appendSegments(pane, segments, changeClass);
} else {
pane.textContent = value == null ? '' : String(value);
}
paneWrapper.appendChild(heading);
paneWrapper.appendChild(pane);
return { wrapper: paneWrapper, pane: pane };
}
function createChangeBlock(change) {
var block = document.createElement('section');
block.className = 'audit-diff-change';
var path = document.createElement('h4');
path.className = 'audit-diff-path h6';
path.textContent = String(change.path || 'Changed value');
var pair = document.createElement('div');
pair.className = 'audit-diff-pair';
var segments = buildDiffSegments(change.from, change.to);
var before = createDiffPane('Before', change.from, segments.before, 'audit-diff-removed');
var after = createDiffPane('After', change.to, segments.after, 'audit-diff-added');
pair.appendChild(before.wrapper);
pair.appendChild(after.wrapper);
synchronizeScroll(before.pane, after.pane);
block.appendChild(path);
block.appendChild(pair);
return block;
}
function renderRows(container, changeRows) {
container.replaceChildren();
changeRows.forEach(function (change) {
container.appendChild(createChangeBlock(change));
});
}
function initializeAuditDetails() {
var modal = document.getElementById('audit-change-detail-modal');
if (!modal) return;
var status = modal.querySelector('[data-audit-detail-status]');
var content = modal.querySelector('[data-audit-detail-content]');
var rows = content;
var subtitle = modal.querySelector('[data-audit-detail-subtitle]');
var requestNumber = 0;
document.addEventListener('click', function (event) {
var button = event.target.closest('[data-audit-detail-url]');
if (!button) return;
var currentRequest = ++requestNumber;
var detailUrl = button.getAttribute('data-audit-detail-url');
rows.replaceChildren();
content.hidden = true;
status.textContent = 'Loading change details...';
status.hidden = false;
subtitle.textContent = button.getAttribute('data-audit-detail-title') || '';
if (!window.pulseModal || !window.pulseModal.show(modal)) {
status.textContent = 'Change details are unavailable.';
return;
}
button.disabled = true;
fetch(detailUrl, {
credentials: 'same-origin',
headers: { Accept: 'application/json' }
}).then(function (response) {
if (!response.ok) throw new Error('Audit details request failed.');
return response.json();
}).then(function (result) {
if (currentRequest !== requestNumber) return;
var changeRows = result && Array.isArray(result.changeRows) ? result.changeRows : [];
renderRows(rows, changeRows);
if (changeRows.length) {
content.hidden = false;
status.hidden = true;
} else {
status.textContent = 'No change details are available.';
}
}).catch(function () {
if (currentRequest === requestNumber) {
status.textContent = 'Unable to load change details.';
}
}).finally(function () {
button.disabled = false;
});
});
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { buildDiffSegments: buildDiffSegments };
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeAuditDetails);
} else {
initializeAuditDetails();
}
}
}());
+41 -4
View File
@@ -150,7 +150,7 @@ function collectAuditChangeRows(previousValue, nextValue, path, rows) {
function buildAuditDetailView(details) {
if (!isRecord(details) || !isRecord(details.changes)) {
return { hasChanges: false, changeRows: [] };
return { hasChanges: false, changeRows: [], summaryRows: [], remainingChangeCount: 0 };
}
const rows = [];
@@ -160,7 +160,12 @@ function buildAuditDetailView(details) {
collectAuditChangeRows(change.from, change.to, key, rows);
}
});
return { hasChanges: rows.length > 0, changeRows: rows };
return {
hasChanges: rows.length > 0,
changeRows: rows,
summaryRows: rows.slice(0, 3),
remainingChangeCount: Math.max(0, rows.length - 3)
};
}
function mapAuditRow(row, formatDashboardDate) {
@@ -175,6 +180,9 @@ function mapAuditRow(row, formatDashboardDate) {
}
}
const occurredAt = new Date(row.occurred_at);
const auditDetailView = Object.prototype.hasOwnProperty.call(row, 'has_changes')
? { hasChanges: Number(row.has_changes) === 1, summaryRows: [], remainingChangeCount: 0 }
: buildAuditDetailView(parsedDetails);
return Object.assign({}, row, {
occurredAtValue: Number.isNaN(occurredAt.getTime()) ? '' : occurredAt.toISOString(),
occurredAtLabel: formatDashboardDate(row.occurred_at),
@@ -183,7 +191,7 @@ function mapAuditRow(row, formatDashboardDate) {
eventLabel: String(row.event_type || '').replace(/[._-]+/g, ' '),
targetLabelDisplay: row.target_label || (row.target_type && row.target_id ? row.target_type + ' #' + row.target_id : ''),
detailsDisplay: details,
auditDetailView: buildAuditDetailView(parsedDetails)
auditDetailView: auditDetailView
});
}
@@ -248,8 +256,11 @@ async function fetchAuditEventTypes(pool, filters) {
}
async function fetchAuditRows(pool, filters, page) {
const detailsColumns = page
? "JSON_CONTAINS_PATH(e.details_json, 'one', '$.changes') AS has_changes, JSON_REMOVE(e.details_json, '$.changes') AS details_json,"
: 'e.details_json,';
let sql = `SELECT e.id, e.occurred_at, e.category, e.event_type, e.target_type, e.target_id,
e.target_label, e.ip_address, e.user_agent, e.details_json,
e.target_label, e.ip_address, e.user_agent, ${detailsColumns}
u.name AS actor_name, u.username AS actor_username
${AUDIT_FROM_SQL}${filters.whereSql}${buildAuditOrderBy(filters)}`;
const params = filters.params.slice();
@@ -301,6 +312,7 @@ module.exports = function registerAuditLogRoutes(app, deps) {
res.send(renderView('settings/audit-log/index', {
title: 'Audit log',
active: 'audit-log',
scripts: ['js/lib/modal.js', 'js/settings/audit-log.js'],
currentUser: req.currentUser,
events: events,
categories: SORTED_AUDIT_CATEGORY_KEYS,
@@ -318,6 +330,31 @@ module.exports = function registerAuditLogRoutes(app, deps) {
}
});
app.get('/settings/audit-log/:id/details', requireAuditLogAccess(deps.setAuthMessageCookie), async function (req, res, next) {
try {
const auditEventId = Number(req.params.id);
if (!Number.isSafeInteger(auditEventId) || auditEventId < 1) {
return res.status(400).json({ error: 'Audit event id is invalid.' });
}
const [rows] = await pool.query('SELECT e.details_json FROM o_audit_events e WHERE e.id = ? LIMIT 1', [auditEventId]);
const row = rows && rows[0];
if (!row) {
return res.status(404).json({ error: 'Audit event not found.' });
}
let details = row.details_json;
if (typeof details === 'string') {
try {
details = JSON.parse(details);
} catch (_error) {
details = null;
}
}
res.json({ changeRows: buildAuditDetailView(details).changeRows });
} catch (error) {
next(error);
}
});
app.get('/settings/audit-log/export', requireAuditExportAccess(deps.setAuthMessageCookie), async function (req, res, next) {
try {
const filters = buildAuditFilters(req);
+25 -9
View File
@@ -49,15 +49,10 @@
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{userAgentLabel}}</div></td>
<td class="text-break">
{{#if auditDetailView.hasChanges}}
<div class="audit-change-list">
{{#each auditDetailView.changeRows}}
<div class="audit-change-row">
<code class="audit-change-path">{{path}}</code>
<span class="audit-change-from"><span class="visually-hidden">From: </span>{{from}}</span>
{{#if (eq direction "changed")}}<span class="audit-change-arrow" aria-hidden="true">&rarr;</span>{{else}}<span></span>{{/if}}
<span class="audit-change-to"><span class="visually-hidden">To: </span>{{to}}</span>
</div>
{{/each}}
<div class="audit-change-summary">
<button type="button" class="btn btn-sm btn-outline-secondary align-self-start" data-audit-detail-url="/settings/audit-log/{{id}}/details" data-audit-detail-title="{{eventLabel}} - {{targetLabelDisplay}}">
<i class="bi bi-list-ul me-1" aria-hidden="true"></i>View details
</button>
</div>
{{else}}
<small>{{detailsDisplay}}</small>
@@ -73,3 +68,24 @@
</div>
{{> table-pagination pagination=pagination basePath="/settings/audit-log" alwaysShow=true}}
</div>
<div class="modal fade" id="audit-change-detail-modal" tabindex="-1" aria-labelledby="audit-change-detail-title" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h3 class="modal-title h5" id="audit-change-detail-title">Change details</h3>
<p class="text-muted small mb-0" data-audit-detail-subtitle></p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-muted" data-audit-detail-status role="status" aria-live="polite"></div>
<div class="audit-diff-changes" data-audit-detail-content hidden></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}&ui=20261025.2" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}&ui=20261025.3" />
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
{{#if stylesheets.length}}
{{#each stylesheets}}
+56 -52
View File
@@ -4,6 +4,26 @@ 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 = {};
@@ -19,6 +39,16 @@ test('audit log uses its own read permission and shared pagination partial', asy
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 }]];
}
@@ -34,42 +64,8 @@ test('audit log uses its own read permission and shared pagination partial', asy
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: JSON.stringify({
changes: {
content: {
from: { title: 'Same', body: { color: 'red', keep: 'same' } },
to: { title: 'Same', body: { color: 'blue', keep: 'same' } }
},
permissions: {
from: ['dashboard.read', 'screens.read'],
to: ['screens.read', 'playlists.read']
},
backgroundColor: {
from: null,
to: '#111111'
},
logoPath: {
from: '',
to: '/media/logo.png'
},
'content.heading.value': {
from: 'Old heading',
to: 'New heading'
},
'content.added.value': {
from: null,
to: 'Added text'
},
'content.removed.value': {
from: 'Removed text',
to: null
},
'content.qr.qr_svg': {
from: null,
to: '[Generated asset: 13 bytes; SHA-256 fingerprint]'
}
}
})
details_json: '{}',
has_changes: 1
},
{
id: 2,
@@ -77,7 +73,8 @@ test('audit log uses its own read permission and shared pagination partial', asy
category: 'authentication',
event_type: 'login.failed',
target_label: 'older',
details_json: null
details_json: null,
has_changes: 0
}
]];
}
@@ -114,31 +111,32 @@ test('audit log uses its own read permission and shared pagination partial', asy
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.match(response.body, /content\.body\.color/);
assert.match(response.body, />red</);
assert.match(response.body, />blue</);
assert.doesNotMatch(response.body, /content\.body\.keep/);
assert.match(response.body, /permissions removed/);
assert.match(response.body, /permissions added/);
assert.match(response.body, /dashboard\.read/);
assert.match(response.body, /playlists\.read/);
assert.match(response.body, /backgroundColor added/);
assert.match(response.body, /logoPath added/);
assert.match(response.body, /content\.heading\.value/);
assert.match(response.body, />New heading</);
assert.match(response.body, /content\.added\.value added/);
assert.match(response.body, /content\.removed\.value removed/);
assert.match(response.body, /Generated asset: 13 bytes; SHA-256 fingerprint/);
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; },
@@ -153,6 +151,12 @@ test('audit log uses its own read permission and shared pagination partial', asy
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 () => {
+19 -1
View File
@@ -1,7 +1,25 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { hashPassword, encodePasswordRecord, verifyEncodedPassword, hashScryptPassword, verifyCredentialPassword } = require('../src/auth');
const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError } = require('../src/web/lib/auth/better-auth');
const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError, ensureBetterAuthClientIp } = require('../src/web/lib/auth/better-auth');
test('Better Auth client IP falls back to the socket address for direct requests', () => {
for (const address of ['192.0.2.1', '::1', '::ffff:192.0.2.1']) {
const req = { headers: {}, socket: { remoteAddress: address } };
ensureBetterAuthClientIp(req);
assert.equal(req.headers['x-forwarded-for'], address);
}
});
test('Better Auth client IP preserves forwarded addresses and does not invent a missing address', () => {
const req = { headers: { 'x-forwarded-for': '192.0.2.1, 192.0.2.2' }, socket: { remoteAddress: '192.0.2.3' } };
ensureBetterAuthClientIp(req);
assert.equal(req.headers['x-forwarded-for'], '192.0.2.1, 192.0.2.2');
for (const directReq of [{ headers: {} }, { headers: {}, socket: {} }]) {
ensureBetterAuthClientIp(directReq);
assert.equal(directReq.headers['x-forwarded-for'], undefined);
}
});
test('passkey RP ID uses localhost for local HTTP-IP configuration and the canonical hostname otherwise', () => {
assert.equal(resolvePasskeyRpID('http://192.168.0.80:8080'), 'localhost');