From 61d553f74b8cf599da22391a74923a57db0b5a86 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sun, 25 Oct 2026 19:08:10 +0000 Subject: [PATCH] Release v2.20.2 --- CHANGELOG.md | 6 + build/package.player.json | 2 +- build/package.web.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/web.js | 6 +- src/web/lib/auth/better-auth.js | 7 + src/web/public/css/theme-custom.css | 80 +++++++--- src/web/public/js/settings/audit-log.js | 171 +++++++++++++++++++++ src/web/routes/settings/audit-log.js | 45 +++++- src/web/views/settings/audit-log/index.hbs | 34 ++-- src/web/views/shared/layout.hbs | 2 +- test/audit-log-route.test.js | 108 ++++++------- test/better-auth-password.test.js | 20 ++- 14 files changed, 395 insertions(+), 94 deletions(-) create mode 100644 src/web/public/js/settings/audit-log.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 821ea32..0533a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/build/package.player.json b/build/package.player.json index 2c90e64..f57af25 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -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": { diff --git a/build/package.web.json b/build/package.web.json index d1249f9..ea6fe98 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index cb4321c..ac5f805 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7460496..d4ddb37 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/web.js b/src/web.js index 95e4ae5..1531432 100644 --- a/src/web.js +++ b/src/web.js @@ -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); diff --git a/src/web/lib/auth/better-auth.js b/src/web/lib/auth/better-auth.js index 0b282d2..2aa8fe7 100644 --- a/src/web/lib/auth/better-auth.js +++ b/src/web/lib/auth/better-auth.js @@ -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, diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index d2ac213..33405a9 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -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 */ diff --git a/src/web/public/js/settings/audit-log.js b/src/web/public/js/settings/audit-log.js new file mode 100644 index 0000000..015933d --- /dev/null +++ b/src/web/public/js/settings/audit-log.js @@ -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(); + } + } +}()); \ No newline at end of file diff --git a/src/web/routes/settings/audit-log.js b/src/web/routes/settings/audit-log.js index ac640c8..77d5e07 100644 --- a/src/web/routes/settings/audit-log.js +++ b/src/web/routes/settings/audit-log.js @@ -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); diff --git a/src/web/views/settings/audit-log/index.hbs b/src/web/views/settings/audit-log/index.hbs index ccd1323..7b013f1 100644 --- a/src/web/views/settings/audit-log/index.hbs +++ b/src/web/views/settings/audit-log/index.hbs @@ -49,15 +49,10 @@
{{ip_address}}
{{userAgentLabel}}
{{#if auditDetailView.hasChanges}} -
- {{#each auditDetailView.changeRows}} -
- {{path}} - From: {{from}} - {{#if (eq direction "changed")}}{{else}}{{/if}} - To: {{to}} -
- {{/each}} +
+
{{else}} {{detailsDisplay}} @@ -72,4 +67,25 @@
{{> table-pagination pagination=pagination basePath="/settings/audit-log" alwaysShow=true}} + + + \ No newline at end of file diff --git a/src/web/views/shared/layout.hbs b/src/web/views/shared/layout.hbs index bff3b88..45a9b91 100644 --- a/src/web/views/shared/layout.hbs +++ b/src/web/views/shared/layout.hbs @@ -9,7 +9,7 @@ - + {{#if stylesheets.length}} {{#each stylesheets}} diff --git a/test/audit-log-route.test.js b/test/audit-log-route.test.js index 3292cf1..d39c041 100644 --- a/test/audit-log-route.test.js +++ b/test/audit-log-route.test.js @@ -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 WindowsredblueNew headingExport 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 () => { diff --git a/test/better-auth-password.test.js b/test/better-auth-password.test.js index 8f9844a..06bcfa8 100644 --- a/test/better-auth-password.test.js +++ b/test/better-auth-password.test.js @@ -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');