Release v2.11.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m47s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
2026-09-04 15:43:55 +01:00
parent 2c150b5b2e
commit 98f969ca0f
104 changed files with 3559 additions and 447 deletions
+192
View File
@@ -0,0 +1,192 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users');
test('pending invitations route loads active invitations with display fields', async () => {
const handlers = {};
let invitationQuery = '';
const rendered = {};
const createdAt = new Date('2026-09-03T10:00:00Z');
const expiresAt = new Date('2026-09-04T10:00:00Z');
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post() {}
};
const requiredPermissions = [];
const deps = {
pool: { async query() { return [[]]; } },
common: {
getSearchQuery: () => '',
getSortQuery: () => '',
getSortDirectionQuery: () => 'asc'
},
pages: {
renderInvitationsPage(data, currentUser) {
rendered.invitations = data.invitations;
rendered.pagination = data.pagination;
rendered.currentUser = currentUser;
return '';
}
},
formatDashboardDate(value) {
return value === createdAt ? 'sent label' : 'expiry label';
},
rbacData: {
fetchRoles: async () => [{ id: 2, name: 'Viewer' }],
fetchInvitationsPage: async (_pool, page, pageSize, search, sort, direction) => {
invitationQuery = { page, pageSize, search, sort, direction };
return {
invitations: [{
id: 7,
email: 'person@example.com',
name: 'Person',
role_ids_json: '[2]',
created_at: createdAt,
expires_at: expiresAt,
created_by_username: 'admin'
}],
totalItems: 26,
currentPage: 2
};
}
},
requirePermission(permission) {
requiredPermissions.push(permission);
return function (_req, _res, next) {
next();
};
}
};
registerUsersRoutes(app, deps);
const routeHandlers = handlers['/settings/invitations'];
assert.ok(requiredPermissions.includes('invitations.read'));
const currentUser = { id: 1 };
const res = {
send(value) {
this.body = value;
}
};
await routeHandlers[1]({ currentUser, query: {} }, res, function (error) {
throw error;
});
assert.deepEqual(invitationQuery, { page: 1, pageSize: 25, search: '', sort: '', direction: 'asc' });
assert.equal(rendered.currentUser, currentUser);
assert.equal(rendered.pagination.totalItems, 26);
assert.deepEqual(rendered.invitations, [{
id: 7,
email: 'person@example.com',
name: 'Person',
role_ids_json: '[2]',
created_at: createdAt,
expires_at: expiresAt,
created_by_username: 'admin',
roleNames: 'Viewer',
createdAtLabel: 'sent label',
expiresAtLabel: 'expiry label',
createdByLabel: 'admin'
}]);
});
test('user invite route stores the invitation and sends an acceptance link', async () => {
const handlers = {};
const queries = [];
let sentEmail = null;
const app = {
get() {},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
const deps = {
pool: {
async query(sql, args) {
queries.push({ sql, args });
if (sql.includes('o_app_settings')) return [[{ setting_key: 'email.smtp_enabled', setting_value: 'true' }]];
if (sql.includes('SELECT id FROM a_users WHERE email')) return [[]];
if (sql.includes('INSERT INTO a_user_invitations')) return [{ insertId: 4 }];
if (sql.includes('COUNT(*) AS invite_count')) return [[{ invite_count: 0 }]];
return [[]];
}
},
common: {
validateMaxLength(value) { return String(value || '').trim(); }
},
pages: {
renderUsersInvitePage() { return ''; }
},
formatDashboardDate: () => '',
getAuditUserId: () => 9,
sendAccountEmail: async (_settings, message) => { sentEmail = message; },
createOneTimeToken: () => 'invite-token',
hashSessionToken: (value) => 'hash:' + value,
getRequestOrigin: () => 'https://signage.example.com',
readArrayField: () => ['2'],
rbacData: {
fetchRoles: async () => [{ id: 2, name: 'Viewer' }]
},
requirePermission() {
return function (_req, _res, next) { next(); };
}
};
registerUsersRoutes(app, deps);
const res = {
redirect(url) { this.redirectedTo = url; },
status() { return this; },
send() { return this; }
};
let routeError = null;
await handlers['/settings/users/invite'][1]({
body: { email: ' Person@Example.COM ', name: 'Person' },
currentUser: { id: 9 }
}, res, (error) => { routeError = error; });
assert.equal(routeError, null);
const insert = queries.find((query) => query.sql.includes('INSERT INTO a_user_invitations'));
assert.deepEqual(insert.args, ['person@example.com', 'Person', '[2]', 'hash:invite-token', 9]);
assert.equal(sentEmail.to, 'person@example.com');
assert.match(sentEmail.html, /accept-invite\?token=invite-token/);
assert.equal(res.redirectedTo, '/settings/users?message=Invitation%20sent.');
});
test('user invite route rejects invalid email before sending', async () => {
const handlers = {};
const app = {
get() {},
post(path, ...routeHandlers) { handlers[path] = routeHandlers; }
};
let renderedMessage = '';
const deps = {
pool: { async query() { return [[]]; } },
common: {
validateMaxLength(value) { return String(value || '').trim(); }
},
pages: {
renderUsersInvitePage(message) { renderedMessage = message; return ''; }
},
formatDashboardDate: () => '',
getAuditUserId: () => 1,
readArrayField: () => [],
rbacData: { fetchRoles: async () => [] },
requirePermission() {
return function (_req, _res, next) { next(); };
}
};
registerUsersRoutes(app, deps);
const res = { status() { return this; }, send() {} };
await handlers['/settings/users/invite'][1]({ body: { email: 'invalid', name: 'Person' }, currentUser: { id: 1 } }, res, () => {});
assert.equal(renderedMessage, 'Email address is invalid.');
});
+54
View File
@@ -23,6 +23,9 @@ test('API source payload accepts POST request and token-login settings', () => {
token_url: 'https://example.com/login',
token_request_body_json: '{"username":"demo","password":"secret"}',
token_response_path: 'data.accessToken',
token_refresh_url: 'https://example.com/refresh',
token_refresh_request_body_json: '{"grant_type":"refresh_token","refresh_token":"{{refresh_token}}"}',
token_refresh_response_path: 'data.refreshToken',
token_header_name: 'Authorization',
token_header_prefix: 'Bearer',
update_interval_value: '5',
@@ -35,6 +38,8 @@ test('API source payload accepts POST request and token-login settings', () => {
assert.equal(payload.authMethod, 'token_login');
assert.equal(payload.tokenUrl, 'https://example.com/login');
assert.equal(payload.tokenResponsePath, 'data.accessToken');
assert.equal(payload.tokenRefreshUrl, 'https://example.com/refresh');
assert.equal(payload.tokenRefreshResponsePath, 'data.refreshToken');
});
test('API source sends login POST, uses token, and refreshes once after 401', async () => {
@@ -81,3 +86,52 @@ test('API source sends login POST, uses token, and refreshes once after 401', as
global.fetch = originalFetch;
}
});
test('API source reuses an unexpired token and refreshes it after expiry', async () => {
const originalFetch = global.fetch;
const originalDateNow = Date.now;
const calls = [];
let currentTime = 1000000;
Date.now = () => currentTime;
global.fetch = async function (url, options) {
calls.push({ url, options });
if (url === 'https://example.com/login') {
return jsonResponse(200, { access_token: 'initial-token', refresh_token: 'initial-refresh', expires_in: 120 });
}
if (url === 'https://example.com/refresh') {
return jsonResponse(200, { access_token: 'refreshed-token', refresh_token: 'rotated-refresh', expires_in: 300 });
}
return jsonResponse(200, { items: [] });
};
const source = {
id: 987655,
api_url: 'https://example.com/report',
auth_method: 'token_login',
token_url: 'https://example.com/login',
token_request_body_json: JSON.stringify({ username: 'demo', password: 'secret' }),
token_refresh_url: 'https://example.com/refresh',
token_refresh_request_body_json: JSON.stringify({ grant_type: 'refresh_token', refresh_token: '{{refresh_token}}' })
};
try {
await fetchApiSourceResponse(source);
await fetchApiSourceResponse(source);
assert.equal(calls.filter(call => call.url === 'https://example.com/login').length, 1);
assert.equal(calls.filter(call => call.url === 'https://example.com/report').length, 2);
currentTime += 120000;
await fetchApiSourceResponse(source);
assert.equal(calls.filter(call => call.url === 'https://example.com/refresh').length, 1);
assert.deepEqual(JSON.parse(calls[3].options.body), { grant_type: 'refresh_token', refresh_token: 'initial-refresh' });
assert.equal(calls[4].options.headers.Authorization, 'Bearer refreshed-token');
currentTime += 300000;
await fetchApiSourceResponse(source);
assert.equal(calls.filter(call => call.url === 'https://example.com/refresh').length, 2);
assert.deepEqual(JSON.parse(calls[5].options.body), { grant_type: 'refresh_token', refresh_token: 'rotated-refresh' });
} finally {
Date.now = originalDateNow;
global.fetch = originalFetch;
}
});
+21 -1
View File
@@ -3,7 +3,8 @@ const assert = require('node:assert/strict');
require('../src/common');
const { buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
const { getDefaultAppSettings } = require('../src/data/app-settings');
function createPool(settings) {
const inserts = [];
@@ -14,6 +15,7 @@ function createPool(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) }
]];
}
@@ -32,6 +34,24 @@ const request = {
}
};
test('audit categories include weather data sources', () => {
assert.ok(AUDIT_CATEGORY_KEYS.includes('weather'));
assert.equal(AUDIT_CATEGORY_LABELS.weather, 'Weather');
});
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' });
+124
View File
@@ -157,4 +157,128 @@ test('successful login returns to the requested link instead of the dashboard',
await handlers['POST /login'][0](rememberedRequest, rememberedResponse, () => {});
assert.equal(rememberedResponse.sessionMaxAgeMs, 7 * 24 * 60 * 60 * 1000);
});
test('accept invite page renders a valid invitation with password requirements', async () => {
const handlers = {};
let rendered = null;
const app = {
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
registerAuthRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('a_user_invitations')) return [[{ email: 'person@example.com', name: 'Person' }]];
return [[{ setting_key: 'security.password_min_length', setting_value: '12' }]];
}
},
pages: {
renderAcceptInvitePage(...args) { rendered = args; return 'invite form'; }
},
hashSessionToken: (value) => 'hash:' + value
});
const res = { send(value) { this.body = value; }, status() { return this; } };
await handlers['GET /accept-invite'][0]({ query: { token: 'invite-token' } }, res, (error) => { throw error; });
assert.equal(res.body, 'invite form');
assert.equal(rendered[0], '');
assert.equal(rendered[1], 'invite-token');
assert.equal(rendered[2], 'person@example.com');
assert.equal(rendered[3], 'Person');
assert.equal(rendered[4].minimumLength, 12);
assert.equal(rendered[5], true);
});
test('accept invite creates the account, assigns roles, and consumes the invitation', async () => {
const handlers = {};
const queries = [];
let committed = false;
let syncedRoles = null;
const app = {
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
const connection = {
async query(sql, args) {
queries.push({ sql, args });
if (sql.includes('a_user_invitations')) return [[{ id: 8, email: 'person@example.com', name: 'Invited name', role_ids_json: '[2, 3]' }]];
if (sql.includes('SELECT id FROM a_users')) return [[]];
if (sql.includes('INSERT INTO a_users')) return [{ insertId: 21 }];
return [[]];
},
async beginTransaction() {},
async commit() { committed = true; },
async rollback() {},
release() {}
};
registerAuthRoutes(app, {
pool: {
async query() { return [[]]; },
async getConnection() { return connection; }
},
pages: {
renderAcceptInvitePage() { return ''; }
},
getConnection: async () => connection,
hashSessionToken: (value) => 'hash:' + value,
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1000 }),
validatePasswordStrength: () => '',
rbacData: {
async syncUserRoles(_connection, userId, roleIds) { syncedRoles = { userId, roleIds }; }
}
});
const req = {
body: {
token: 'invite-token',
username: 'person',
name: 'Person',
password: 'ValidPass!1',
confirm_password: 'ValidPass!1'
}
};
const res = {
redirect(url) { this.redirectedTo = url; },
status() { return this; },
send() { return this; }
};
await handlers['POST /accept-invite'][0](req, res, (error) => { throw error; });
const insert = queries.find((query) => query.sql.includes('INSERT INTO a_users'));
const consumed = queries.find((query) => query.sql.includes('UPDATE a_user_invitations'));
assert.deepEqual(insert.args, ['Person', 'person', 'person@example.com', 'hash', 'salt', 1000]);
assert.deepEqual(syncedRoles, { userId: 21, roleIds: [2, 3] });
assert.deepEqual(consumed.args, [8]);
assert.equal(committed, true);
assert.equal(res.redirectedTo, '/login?message=Account%20created.%20You%20can%20now%20sign%20in.');
});
test('accept invite rejects an invalid token without creating an account', async () => {
const handlers = {};
let renderedMessage = '';
const app = {
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
const connection = {
async query(sql) {
if (sql.includes('a_user_invitations')) return [[]];
return [[]];
},
async rollback() {},
release() {}
};
registerAuthRoutes(app, {
pool: { async query() { return [[]]; } },
pages: { renderAcceptInvitePage(message) { renderedMessage = message; return ''; } },
getConnection: async () => connection,
hashSessionToken: (value) => 'hash:' + value
});
const res = { status() { return this; }, send() {} };
await handlers['POST /accept-invite'][0]({ body: { token: 'expired-token' } }, res, (error) => { throw error; });
assert.equal(renderedMessage, 'This invitation is invalid or has expired.');
});
+76
View File
@@ -1,6 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
require('../src/common');
@@ -85,6 +86,81 @@ test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () =
assert.equal(renderEditorJsContent('plain text'), '<p>plain text</p>');
});
test('shared API progress placeholder renders a clamped bar from numeric fields', () => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox);
const placeholderUtils = sandbox.window.placeholderUtils;
const markup = placeholderUtils.renderProgressPlaceholder(
{ fundraising: { current: '$1,250.00', total: '$1,000.00' } },
'progress(fundraising.current,fundraising.total,success,light,striped,animated)'
);
assert.match(markup, /<span class="progress api-progress" style="background-color:#f8f9fa;font-size:inherit;--bs-progress-font-size:inherit;--bs-progress-height:1em;height:1em;border-radius:var\(--bs-border-radius\);"/);
assert.match(markup, /class="progress-bar bg-success progress-bar-striped progress-bar-animated"/);
assert.match(markup, /style="width:100%;color:inherit;background-color:#198754;"/);
assert.match(markup, /aria-valuenow="100"/);
assert.match(markup, /width:100%/);
const objectValueMarkup = placeholderUtils.renderProgressPlaceholder(
{ fundraising: { current: { value: 30, currency: 'USD' }, total: { amount: 100, currency: 'USD' } } },
'progress(fundraising.current,fundraising.total)'
);
assert.match(objectValueMarkup, /aria-valuenow="30"/);
assert.match(objectValueMarkup, /width:30%/);
const announcementColorMarkup = placeholderUtils.renderProgressPlaceholder(
{ current: 25, total: 100 },
'progress(current,total,midnight)'
);
assert.match(announcementColorMarkup, /background-color:#1e1d2d/);
assert.doesNotMatch(announcementColorMarkup, /bg-primary/);
const omittedOptionsMarkup = placeholderUtils.renderProgressPlaceholder(
{ current: 25, total: 100 },
'progress(current,total)'
);
assert.match(omittedOptionsMarkup, /aria-valuenow="25"/);
const literalMarkup = placeholderUtils.renderProgressPlaceholder(
{},
'progress("30","100")'
);
assert.match(literalMarkup, /aria-valuenow="30"/);
});
test('shared placeholder transforms support arithmetic', () => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox);
const placeholderUtils = sandbox.window.placeholderUtils;
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.multiply(10)')), '120');
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.add(3)')), '15');
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.subtract(3)')), '9');
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.divide(3)')), '4');
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.add(3).multiply(10)')), '150');
const textlessMarkup = placeholderUtils.renderProgressPlaceholder(
{ current: 30, total: 100 },
'progress(current,total,textless)'
);
assert.match(textlessMarkup, /aria-label="30%"/);
assert.match(textlessMarkup, /aria-valuenow="30"/);
assert.match(textlessMarkup, /style="width:30%;color:inherit;"><\/span>/);
const radiusMarkup = placeholderUtils.renderProgressPlaceholder(
{ current: 30, total: 100 },
'progress(current,total,radius(12px))'
);
assert.match(radiusMarkup, /border-radius:12px;/);
const now = Date.now();
const timedMarkup = placeholderUtils.renderProgressPlaceholder(
{ start: new Date(now - 30 * 60 * 1000).toISOString(), end: new Date(now + 30 * 60 * 1000).toISOString() },
'progress(start,end)'
);
assert.match(timedMarkup, /aria-valuenow="50"/);
});
test('timetable region registers the timetable type', () => {
assert.ok(timetableRegionSource.includes("registry.register('timetable'"));
assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value"));
+1 -1
View File
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
}
]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.2' });
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.11.1' });
assert.equal(pendingMigrations.length, 0);
});
+54
View File
@@ -118,3 +118,57 @@ test('settings route renders the overview', async () => {
});
assert.equal(Number(videoInsert.params[1]), 1024 * 1024 * 1024);
});
test('email settings can send a test email to the logged-in user', async () => {
const handlers = {};
const sentMessages = [];
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers['POST ' + path] = routeHandlers;
}
};
registerSettingsRoutes(app, {
pages: {},
pool: {
async query(sql) {
if (/SELECT id, setting_key, setting_value FROM o_app_settings/.test(sql)) {
return [[
{ setting_key: 'email.smtp_enabled', setting_value: 'true' },
{ setting_key: 'email.smtp_host', setting_value: 'smtp.example.test' },
{ setting_key: 'email.from_address', setting_value: 'pulse@example.test' }
]];
}
throw new Error('Unexpected settings write during test email action.');
}
},
requirePermission() {
return function (_req, _res, next) {
next();
};
},
sendAccountEmail(settings, message) {
sentMessages.push({ settings, message });
}
});
const response = {
redirect(url) {
this.redirectedTo = url;
}
};
await handlers['POST /settings/system'][1]({
body: { settings_section: 'email', settings_action: 'test_email' },
currentUser: { id: 7, email: 'admin@example.test' }
}, response, function (error) {
throw error;
});
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].message.to, 'admin@example.test');
assert.equal(sentMessages[0].message.subject, 'Pulse Signage SMTP test email');
assert.match(response.redirectedTo, /Test%20email%20sent%20to%20admin%40example\.test/);
});
+2 -2
View File
@@ -224,7 +224,7 @@ test('dashboard move client button opens the move modal for the selected row', (
return '';
},
querySelector(selector) {
if (selector === 'td[data-label="Client"] > div') {
if (selector === 'td[data-label="Client"] > div, [data-mobile-client-name]' || selector === 'td[data-label="Client"] > div') {
return { textContent: 'Lobby Client' };
}
return null;
@@ -235,7 +235,7 @@ test('dashboard move client button opens the move modal for the selected row', (
if (selector === 'button[data-action="move-screen"]') {
return this;
}
if (selector === 'tr[data-client-key]') {
if (selector === 'tr[data-client-key], article[data-mobile-client-key]' || selector === 'tr[data-client-key]') {
return row;
}
return null;