Release v2.17.0: add read-only user previews and access fallbacks
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 32s

This commit is contained in:
lzstealth committed 2026-10-23 01:55:33 +01:00
1 parent 11dd801f6d
commit 9c28eca3ef
52 files changed
+1624 -140

No files matched your search

+50
View File
@@ -7,6 +7,8 @@ const packageJson = require('../package.json');
const pages = require('../src/web/pages');
const registerAboutRoutes = require('../src/web/routes/settings/about/routes');
const renderAboutPage = require('../src/web/routes/settings/about');
const registerChangelogRoutes = require('../src/web/routes/settings/changelog/routes');
const renderChangelogPage = require('../src/web/routes/settings/changelog');
test('about page renders the footer link beside the version and lists bundled libraries', () => {
const html = pages.renderAboutPage({ id: 1 });
@@ -27,6 +29,10 @@ test('about page renders the footer link beside the version and lists bundled li
assert.match(html, new RegExp('v' + packageJson.version.split('.').join('\\.')));
assert.match(html, /About Pulse/);
assert.match(html, /href="\/settings\/about"/);
assert.match(html, /href="\/settings\/changelog"/);
assert.match(html, />Changelog<\/a>/);
assert.match(html, /bi-git me-1/);
assert.doesNotMatch(html, /bi-github/);
libraries.forEach(function (library) {
assert.ok(library.version, `${library.name} version is missing`);
assert.match(html, new RegExp(`>${library.version}<`));
@@ -62,4 +68,48 @@ test('about route sends the about page', async () => {
await handlers['/settings/about'][0]({ currentUser: { id: 7 } }, response, function () {});
assert.equal(response.body, 'about-page');
});
test('changelog page renders release notes from the project changelog', () => {
const html = pages.renderChangelogPage({ id: 1 });
const parsed = renderChangelogPage.parseChangelog('## 1.2.3 - 2026-01-02\n\n### Added\n\n- New feature');
assert.ok(parsed.length > 0);
assert.equal(parsed[0].version, '1.2.3');
assert.equal(parsed[0].sections[0].items[0], 'New feature');
assert.match(html, /Changelog/);
assert.match(html, new RegExp(packageJson.version.replaceAll('.', '\\.')));
assert.match(html, /Updates and fixes across Pulse Signage releases/);
assert.match(html, /<details class="changelog-release"[^>]*open/);
assert.match(html, /Latest release/);
assert.match(html, /API source Enable\/Disable action/);
});
test('changelog route sends the changelog page', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
registerChangelogRoutes(app, {
pages: {
renderChangelogPage(currentUser) {
return currentUser && currentUser.id === 7 ? 'changelog-page' : 'unexpected';
}
}
});
const response = {
body: null,
send(value) {
this.body = value;
return this;
}
};
await handlers['/settings/changelog'][0]({ currentUser: { id: 7 } }, response, function () {});
assert.equal(response.body, 'changelog-page');
});
+61
View File
@@ -4,8 +4,69 @@ const assert = require('node:assert/strict');
require('../src/common');
const registerAccountRoutes = require('../src/web/routes/admin/account');
const pages = require('../src/web/pages');
const { validatePasswordStrength } = require('../src/auth');
test('required-password page shows only password controls and uses an inline warning', () => {
const html = pages.renderRequiredPasswordPage({ id: 5, username: 'demo' }, {
minimumLength: 12,
minimumCategories: 3,
requireLowercase: false,
requireUppercase: false,
requireNumber: false,
requireSymbol: false
}, 'Current password is incorrect.');
assert.match(html, /Password change required/);
assert.match(html, /Please change your password before continuing\./);
assert.match(html, /Current password is incorrect\./);
assert.match(html, /name="current_password"/);
assert.match(html, /name="new_password"/);
assert.match(html, /name="confirm_password"/);
assert.doesNotMatch(html, /account-profile-form|name="email"|Active sessions/);
assert.doesNotMatch(html, /id="app-toast"/);
});
test('required-password route renders the password-only page and redirects after the requirement clears', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
post() {}
};
const deps = {
pool: { async query() { return [[]]; } },
common: {},
pages: {
renderRequiredPasswordPage(user, requirements) {
return user.username + ':' + requirements.minimumLength;
}
},
formatDashboardDate: () => '',
getAuditUserId: () => 5,
verifyPassword: () => true,
validatePasswordStrength: validatePasswordStrength,
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1 }),
createUserSession: async () => 'token',
setSessionCookie: () => {}
};
registerAccountRoutes(app, deps);
const forcedResponse = {
send(value) { this.body = value; },
redirect(url) { this.redirectedTo = url; }
};
await handlers['/account/password'][0]({ currentUser: { id: 5, username: 'demo', mustChangePassword: true } }, forcedResponse, (error) => { throw error; });
const regularResponse = {
send(value) { this.body = value; },
redirect(url) { this.redirectedTo = url; }
};
await handlers['/account/password'][0]({ currentUser: { id: 5, username: 'demo', mustChangePassword: false } }, regularResponse, (error) => { throw error; });
assert.equal(forcedResponse.body, 'demo:10');
assert.equal(regularResponse.redirectedTo, '/account');
});
test('account password update redirects back to the account page', async () => {
const handlers = {};
const app = {
+3 -3
View File
@@ -97,9 +97,9 @@ test('2.16.2 adds an enabled master switch without rewriting individual requests
return [[]];
} };
const pending = await getPendingMigrations(pool, { currentVersion: '2.16.1' });
assert.equal(pending.length, 1);
assert.equal(pending[0].version, '2.16.2');
await pending[0].run(pool);
const migration = pending.find(value => value.version === '2.16.2');
assert.ok(migration);
await migration.run(pool);
assert.ok(calls.some(sql => sql.includes('ALTER TABLE i_api_source_groups ADD COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1')));
assert.ok(!calls.some(sql => sql.includes('UPDATE i_api_sources')));
});
+11
View File
@@ -209,11 +209,16 @@ test('API source add and edit forms render connection diagnostics with the corre
const user = { permissionKeys: ['api-sources.create', 'api-sources.update'] };
const add = renderAdd({}, '', user);
const edit = renderEdit({ id: 42, name: 'Campaign' }, {}, '', user);
const preview = renderEdit({ id: 42, name: 'Campaign' }, {}, '', {
isViewingAs: true,
permissionKeys: ['api-sources.update', 'api-sources.allow', 'api-sources.delete']
});
assert.match(add, /<p>Configure shared authentication and the requests for this API source\.<\/p>/);
assert.match(edit, /<p>Manage shared authentication, request URLs, and latest responses\.<\/p>/);
assert.match(add, /data-api-source-test-url="\/data-sources\/api-sources\/test"/);
assert.match(edit, /data-api-source-test-url="\/data-sources\/api-sources\/42\/test"/);
for (const html of [add, edit]) {
assert.match(html, /<form id="api-source-form" method="post" action="[^"]+" data-async-save/);
assert.match(html, /data-api-source-diagnostics hidden/);
assert.match(html, /data-api-source-copy-report disabled/);
assert.match(html, /data-api-source-test-status role="status"/);
@@ -237,6 +242,12 @@ test('API source add and edit forms render connection diagnostics with the corre
assert.doesNotMatch(html, /data-api-requests-header/);
assert.match(html, /data-api-request-title>Request - /);
}
assert.match(preview, /data-manual-refresh-url="\/data-sources\/api-sources\/42\/requests\/42\/refresh"[^>]*disabled/);
assert.match(preview, /data-api-source-test-url="\/data-sources\/api-sources\/42\/test"[^>]*disabled/);
assert.match(preview, /data-api-auth-test-url="\/data-sources\/api-sources\/42\/auth-test"[^>]*disabled/);
assert.match(preview, /data-async-data-source-toggle[^>]*disabled/);
assert.match(preview, /data-api-request-add disabled/);
assert.match(preview, /data-api-request-remove data-request-protected disabled/);
});
test('saved requests show visible full-width response trees with independent expand controls', () => {
+176
View File
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
require('../src/common');
const registerAuthRoutes = require('../src/web/routes/auth');
const pages = require('../src/web/pages');
const renderLoginPage = require('../src/web/routes/auth/login');
const { createSessionService, normalizeReturnToPath } = require('../src/web/lib/auth/session');
@@ -45,6 +46,149 @@ test('requireAuth preserves the original request path when redirecting to login'
assert.equal(res.redirectedTo, '/login?returnTo=%2Fslides%2F6%2Fedit%3Ftab%3Dcontent');
});
test('session loader keeps the administrator identity while loading preview-user permissions', async () => {
const service = createSessionService({
sessionCookieName: 'pulse_session',
sessionMaxAgeMs: 60 * 60 * 1000,
hashSessionToken: (value) => 'hash:' + value,
createSessionToken: () => 'token'
});
const pool = {
async query(sql, params) {
if (sql.includes('FROM a_sessions s')) {
return [[{
user_id: 7,
id: 7,
name: 'Administrator',
username: 'admin',
view_as_user_id: 19,
view_as_expires_at: new Date(Date.now() + 60 * 1000),
preview_id: 19,
preview_name: 'Screen operator',
preview_username: 'operator',
preview_email: 'operator@example.com',
preview_must_change_password: 0
}]];
}
if (sql.includes('SELECT r.role_key')) {
return [[{ role_key: Number(params[0]) === 7 ? 'super-admin' : 'operator' }]];
}
if (sql.includes('SELECT p.permission_key')) {
return [[
{ permission_key: Number(params[0]) === 7 ? 'impersonate.allow' : 'screens.read' }
]];
}
return [{ affectedRows: 1 }];
}
};
const currentUser = await service.loadCurrentUser(pool, {
headers: { cookie: 'pulse_session=token' }
});
assert.equal(currentUser.id, 19);
assert.equal(currentUser.username, 'operator');
assert.deepEqual(currentUser.permissionKeys, ['screens.read']);
assert.equal(currentUser.isViewingAs, true);
assert.equal(currentUser.authenticatedUser.id, 7);
assert.deepEqual(currentUser.authenticatedUser.permissionKeys, ['impersonate.allow']);
});
test('session loader exits preview when the administrator loses the preview permission', async () => {
const queries = [];
const service = createSessionService({
sessionCookieName: 'pulse_session',
sessionMaxAgeMs: 60 * 60 * 1000,
hashSessionToken: (value) => 'hash:' + value,
createSessionToken: () => 'token'
});
const pool = {
async query(sql, params) {
queries.push([sql, params]);
if (sql.includes('FROM a_sessions s')) {
return [[{
user_id: 7,
id: 7,
name: 'Administrator',
username: 'admin',
view_as_user_id: 19,
view_as_expires_at: new Date(Date.now() + 60 * 1000),
preview_id: 19,
preview_name: 'Screen operator',
preview_username: 'operator',
preview_must_change_password: 0
}]];
}
if (sql.includes('SELECT r.role_key')) return [[{ role_key: 'users-viewer' }]];
if (sql.includes('SELECT p.permission_key')) return [[{ permission_key: 'users.read' }]];
return [{ affectedRows: 1 }];
}
};
const currentUser = await service.loadCurrentUser(pool, {
headers: { cookie: 'pulse_session=token' }
});
assert.equal(currentUser.id, 7);
assert.equal(currentUser.isViewingAs, undefined);
assert.ok(queries.some(function ([sql]) {
return sql.includes('SET view_as_user_id = NULL, view_as_expires_at = NULL');
}));
});
test('view-as session helpers bind changes to the authenticated session', async () => {
const queries = [];
const service = createSessionService({
sessionCookieName: 'pulse_session',
sessionMaxAgeMs: 60 * 60 * 1000,
hashSessionToken: (value) => 'hash:' + value,
createSessionToken: () => 'token'
});
const pool = {
async query(sql, params) {
queries.push([sql, params]);
return [{ affectedRows: 1 }];
}
};
const req = { headers: { cookie: 'pulse_session=token' } };
assert.equal(await service.startViewAsUser(pool, req, 7, 19), true);
assert.equal(await service.stopViewAsUser(pool, req, 7), true);
assert.deepEqual(queries[0][1], [19, 7, 'hash:token', 7]);
assert.deepEqual(queries[1][1], [7, 'hash:token', 7]);
});
test('logout audit records the authenticated administrator during a preview', async () => {
const handlers = {};
let auditEvent = null;
const app = {
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
registerAuthRoutes(app, {
pool: { async query() { return [[]]; } },
parseCookies() { return { pulse_session: 'session-token' }; },
sessionCookieName: 'pulse_session',
hashSessionToken(value) { return 'hash:' + value; },
clearSessionCookie() {},
setAuthMessageCookie() {},
recordRequestAuditEvent: async (_pool, _req, event) => { auditEvent = event; }
});
const res = {
redirect(path) { this.redirectedTo = path; }
};
await handlers['GET /logout'][0]({
headers: { cookie: 'pulse_session=session-token' },
currentUser: { id: 19, username: 'operator', isViewingAs: true },
authenticatedUser: { id: 7, username: 'admin' }
}, res, (error) => { throw error; });
assert.equal(auditEvent.actorUserId, 7);
assert.equal(auditEvent.targetId, 7);
assert.equal(res.redirectedTo, '/login');
});
test('normalizeReturnToPath keeps same-origin absolute urls and strips the origin', () => {
assert.equal(
normalizeReturnToPath('https://signage.example.com/slides/6/edit?tab=content', 'https://signage.example.com'),
@@ -56,6 +200,37 @@ test('normalizeReturnToPath keeps same-origin absolute urls and strips the origi
);
});
test('root route sends users to their first permitted page or the no-access fallback', () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
registerAuthRoutes(app, {});
[
[{ permissionKeys: ['dashboard.read', 'clients.read'] }, '/dashboard'],
[{ permissionKeys: ['clients.read'] }, '/clients'],
[{ permissionKeys: [] }, '/no-access']
].forEach(function ([currentUser, expectedPath]) {
const response = { redirect(path) { this.redirectedTo = path; } };
handlers['GET /'][0]({ currentUser }, response);
assert.equal(response.redirectedTo, expectedPath);
});
});
test('no-access page explains the missing permissions and offers account and sign-out links', () => {
const html = pages.renderNoAccessPage({ id: 7, username: 'operator' });
assert.match(html, /No application access/);
assert.match(html, /no application sections have been assigned/);
assert.match(html, /href="\/account"/);
assert.match(html, /href="\/logout"/);
assert.match(html, /class="no-access-page"/);
assert.match(html, /class="no-access-panel"/);
assert.match(html, /class="btn btn-primary" href="\/logout"/);
});
test('successful login returns to the requested link instead of the dashboard', async () => {
const handlers = {};
const app = {
@@ -157,6 +332,7 @@ 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);
assert.equal(rememberedResponse.redirectedTo, '/');
});
test('accept invite page renders a valid invitation with password requirements', async () => {
+23
View File
@@ -0,0 +1,23 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
require('../src/common');
const formTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8');
const { buildPermissionSections } = require('../src/web/routes/settings/rbac/form-view-model');
test('permission checkboxes expose their descriptions as hover titles', () => {
const sections = buildPermissionSections([{
categoryName: 'Settings',
title: 'Impersonate',
permissions: [{
permission_key: 'impersonate.allow',
actionKey: 'allow',
description: 'Preview a user account in read-only mode.'
}]
}]);
assert.match(formTemplate, /name="permission_keys\[\]"[^>]*title="\{\{description\}\}"/);
assert.equal(sections[0].groups[0].cells[4].description, 'Preview a user account in read-only mode.');
});
+29 -1
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
require('../src/common');
const { runMigrations } = require('../src/db/migrations');
const { getPendingMigrations, runMigrations } = require('../src/db/migrations');
function createPool(responses) {
const queries = [];
@@ -142,4 +142,32 @@ test('timetable timezone migrations use timetable tables when legacy tables are
assert.ok(!pool.queries.some(function ([sql]) {
return String(sql).includes('i_schedule_groups') || String(sql).includes('i_schedule_entries');
}));
});
test('2.17.0 migration adds the expiring view-as session columns', async () => {
const pool = createPool([
{
match(sql) {
return sql.includes('FROM information_schema.COLUMNS');
},
result: [[{ column_count: 0 }]]
},
{
match(sql) {
return sql.includes('FROM information_schema.TABLES');
},
result: [[{ table_count: 0 }]]
}
]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.16.2' });
assert.deepEqual(pending.map(function (migration) { return migration.version; }), ['2.17.0']);
await pending[0].run(pool);
assert.ok(pool.queries.some(function ([sql]) {
return String(sql).includes('ALTER TABLE a_sessions ADD COLUMN view_as_user_id INT NULL AFTER user_id');
}));
assert.ok(pool.queries.some(function ([sql]) {
return String(sql).includes('ALTER TABLE a_sessions ADD COLUMN view_as_expires_at DATETIME NULL AFTER view_as_user_id');
}));
});
+1
View File
@@ -34,4 +34,5 @@ test('user form keeps username editable in the admin section', () => {
assert.ok(usersFormTemplate.includes('name="username"'));
assert.ok(!usersFormTemplate.includes('{{#if isEdit}}disabled{{else}}required{{/if}}'));
assert.ok(usersFormTemplate.includes('required />'));
assert.match(usersFormTemplate, /class="btn-group ms-auto" role="group" aria-label=/);
});
+153
View File
@@ -0,0 +1,153 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const pages = require('../src/web/pages');
const registerViewAsRoutes = require('../src/web/routes/settings/view-as');
const renderUsersPage = require('../src/web/routes/settings/users/list');
const renderUsersEditPage = require('../src/web/routes/settings/users/edit');
const renderRssFeedEditPage = require('../src/web/routes/data-sources/rss-feeds/edit');
const renderWeatherLocationEditPage = require('../src/web/routes/data-sources/weather/edit');
const { hasAssignedPermission, hasPermission, PERMISSIONS } = require('../src/rbac');
test('preview permission is Impersonate > Allow and read-only execution stays denied', () => {
const permission = PERMISSIONS.find(function (item) { return item.key === 'impersonate.allow'; });
assert.ok(permission);
assert.equal(permission.resourceName, 'Impersonate');
assert.equal(permission.actionName, 'Allow');
assert.ok(!PERMISSIONS.some(function (item) {
return item.key === 'users.allow' || item.key === 'users.impersonate' || item.key === 'dashboard.allow';
}));
const previewUser = { isViewingAs: true, permissionKeys: ['clients.allow'] };
assert.equal(hasAssignedPermission(previewUser, 'clients.allow'), true);
assert.equal(hasPermission(previewUser, 'clients.allow'), false);
assert.equal(hasPermission({ permissionKeys: ['clients.allow'] }, 'clients.allow'), true);
});
test('View as is on the user edit footer, not the Users table', () => {
const user = { id: 19, name: 'Screen operator', username: 'operator', roleNames: 'Operators' };
const administrator = { id: 7, permissionKeys: ['users.read', 'impersonate.allow'] };
const adminPage = renderUsersPage({ users: [user] }, '', administrator);
const editPage = renderUsersEditPage(user, '', administrator, [], []);
const previewPage = renderUsersPage({ users: [user] }, '', Object.assign({}, administrator, { isViewingAs: true }));
const previewEditPage = renderUsersEditPage(user, '', Object.assign({}, administrator, { isViewingAs: true }), [], []);
assert.doesNotMatch(adminPage, /view-as/);
assert.match(editPage, /form="view-as-user-form"/);
assert.match(editPage, /<form id="view-as-user-form" method="post" action="\/settings\/users\/19\/view-as"/);
assert.ok(editPage.indexOf('View as') < editPage.indexOf('Profile actions'));
assert.doesNotMatch(previewPage, /action="\/settings\/users\/19\/view-as"/);
assert.doesNotMatch(previewEditPage, /view-as-user-form/);
});
test('view-as banner identifies both users and keeps an exit control on regular and error pages', () => {
const previewUser = {
id: 19,
name: 'Screen operator',
username: 'operator',
isViewingAs: true,
authenticatedUser: { id: 7, name: 'Administrator', username: 'admin' }
};
const regularPage = pages.renderAboutPage(previewUser);
const errorPage = pages.renderErrorPage({ statusCode: 403 }, previewUser);
[regularPage, errorPage].forEach(function (html) {
assert.match(html, /Read-only preview/);
assert.match(html, /Screen operator/);
assert.match(html, /signed in as Administrator/);
assert.match(html, /action="\/settings\/view-as\/stop"/);
assert.match(html, /class="btn btn-sm btn-dark"/);
assert.doesNotMatch(html, /btn-outline-dark/);
});
});
test('view-as start and stop routes audit the authenticated administrator', async () => {
const handlers = {};
const permissionKeys = [];
const auditEvents = [];
const helperCalls = [];
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
const administrator = {
id: 7,
name: 'Administrator',
username: 'admin',
permissionKeys: ['users.read', 'impersonate.allow']
};
registerViewAsRoutes(app, {
pool: {
async query(sql, params) {
assert.match(sql, /FROM a_users/);
assert.deepEqual(params, [19]);
return [[{ id: 19, name: 'Screen operator', username: 'operator' }]];
}
},
requirePermission(permissionKey) {
permissionKeys.push(permissionKey);
return function (_req, _res, next) { next(); };
},
async startViewAsUser(_pool, _req, actorId, targetId) {
helperCalls.push(['start', actorId, targetId]);
return true;
},
async stopViewAsUser(_pool, _req, actorId) {
helperCalls.push(['stop', actorId]);
return true;
},
async recordRequestAuditEvent(_pool, _req, event) {
auditEvents.push(event);
}
});
const startResponse = {
redirect(path) { this.redirectedTo = path; },
status(statusCode) { this.statusCode = statusCode; return this; },
send(body) { this.body = body; return this; },
sendStatus(statusCode) { this.statusCode = statusCode; return this; }
};
await handlers['/settings/users/:id/view-as'][2]({
params: { id: '19' },
authenticatedUser: administrator,
currentUser: administrator
}, startResponse, (error) => { throw error; });
const stopResponse = {
redirect(path) { this.redirectedTo = path; },
status(statusCode) { this.statusCode = statusCode; return this; },
send(body) { this.body = body; return this; }
};
await handlers['/settings/view-as/stop'][0]({
authenticatedUser: administrator,
currentUser: { id: 19, username: 'operator', isViewingAs: true }
}, stopResponse, (error) => { throw error; });
assert.deepEqual(permissionKeys, ['users.read', 'impersonate.allow']);
assert.deepEqual(helperCalls, [['start', 7, 19], ['stop', 7]]);
assert.deepEqual(auditEvents.map(function (event) { return event.actorUserId; }), [7, 7]);
assert.deepEqual(auditEvents.map(function (event) { return event.eventType; }), ['user.view_as_started', 'user.view_as_stopped']);
assert.equal(startResponse.redirectedTo, '/dashboard');
assert.equal(stopResponse.redirectedTo, '/settings/users');
});
test('RSS and weather refresh actions stay visible but disabled when authorized previewing', () => {
const rssPage = renderRssFeedEditPage({ id: 31, name: 'Feed', enabled: 1 }, {}, '', {
isViewingAs: true,
permissionKeys: ['rss-feeds.update', 'rss-feeds.allow']
});
const weatherPage = renderWeatherLocationEditPage({ id: 12, name: 'Forecast', enabled: 1 }, '', {
isViewingAs: true,
permissionKeys: ['weather.update', 'weather.allow']
});
assert.match(rssPage, /data-manual-refresh-url="\/data-sources\/rss-feeds\/31\/refresh"[^>]*disabled/);
assert.match(rssPage, /data-async-data-source-toggle[^>]*disabled/);
assert.match(weatherPage, /data-weather-refresh-url="\/data-sources\/weather\/12\/refresh"[^>]*disabled/);
assert.match(weatherPage, /data-async-data-source-toggle[^>]*disabled/);
});
+1 -1
View File
@@ -80,7 +80,7 @@ test('dashboard kiosk launcher includes connected player choices', () => {
connectedPlayersCount: 1
},
'',
{ id: 1, permissions: ['screens.read', 'clients.read', 'dashboard.allow'] }
{ id: 1, permissions: ['screens.read', 'clients.read', 'clients.allow'] }
);
assert.match(html, /dashboard-kiosk-launcher-player-select/);
+8 -2
View File
@@ -76,6 +76,7 @@ test('screen update keeps the existing slug on edit', async () => {
};
const calls = [];
const permissionKeys = [];
const pages = { renderScreenFormPage() {}, renderScreenEditPage() {} };
const deps = {
pool,
@@ -132,7 +133,8 @@ test('screen update keeps the existing slug on edit', async () => {
return { ok: true };
},
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
requirePermission(permissionKey) {
permissionKeys.push(permissionKey);
return function (_req, _res, next) {
next();
};
@@ -178,6 +180,7 @@ test('dashboard commands fan out to each connected bridge player', async () => {
};
const calls = [];
const permissionKeys = [];
registerManageRoutes(app, {
pool: {
async query(sql) {
@@ -251,7 +254,8 @@ test('dashboard commands fan out to each connected bridge player', async () => {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
return { ok: true };
},
requirePermission() {
requirePermission(permissionKey) {
permissionKeys.push(permissionKey);
return function (_req, _res, next) {
next();
};
@@ -260,6 +264,8 @@ test('dashboard commands fan out to each connected bridge player', async () => {
const routeHandlers = handlers['/commands'];
assert.equal(Array.isArray(routeHandlers), true);
assert.ok(permissionKeys.includes('clients.allow'));
assert.ok(!permissionKeys.includes('dashboard.allow'));
const req = {
body: { command: 'pause' },
+133
View File
@@ -41,4 +41,137 @@ test('web middleware bypasses auth for internal player sync routes', () => {
assert.deepEqual(nextCalls, ['media', 'font', 'dashboard']);
assert.deepEqual(calls, ['/dashboard']);
});
test('view-as blocks writes but allows the stop-preview request', () => {
const app = {
middlewares: [],
use(...args) {
this.middlewares.push(args);
}
};
registerMiddleware(app, {
pool: {},
loadCurrentUser: async () => null,
requireAuth(req, _res, next) { next(); },
MEDIA_DIR: path.join(process.cwd(), 'media'),
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
});
const authGate = app.middlewares[app.middlewares.length - 1][0];
const errors = [];
let stopAllowed = false;
['/slides/12', '/api/internal/sync/player-control'].forEach(function (requestPath) {
authGate({
method: 'POST',
path: requestPath,
currentUser: { id: 19, isViewingAs: true },
authenticatedUser: { id: 7 }
}, {}, function (error) {
errors.push(error);
});
});
authGate({
method: 'POST',
path: '/settings/view-as/stop',
currentUser: { id: 19, isViewingAs: true },
authenticatedUser: { id: 7 }
}, {}, function (error) {
stopAllowed = !error;
});
assert.equal(errors.length, 2);
assert.equal(errors[0].statusCode, 403);
assert.equal(stopAllowed, true);
});
test('view-as ignores the preview user password-change requirement and permits exit', () => {
const app = {
middlewares: [],
use(...args) { this.middlewares.push(args); }
};
registerMiddleware(app, {
pool: {},
loadCurrentUser: async () => null,
requireAuth(_req, _res, next) { next(); },
MEDIA_DIR: path.join(process.cwd(), 'media'),
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
});
const authGate = app.middlewares[app.middlewares.length - 1][0];
let pageAllowed = false;
const response = { redirect(path) { this.redirectedTo = path; } };
authGate({
method: 'GET',
path: '/screens',
currentUser: { id: 19, isViewingAs: true, mustChangePassword: true },
authenticatedUser: { id: 7 }
}, response, function (error) { assert.ifError(error); pageAllowed = true; });
assert.equal(pageAllowed, true);
assert.equal(response.redirectedTo, undefined);
const adminMustChangeResponse = { redirect(path) { this.redirectedTo = path; } };
authGate({
method: 'GET',
path: '/screens',
currentUser: { id: 19, isViewingAs: true, mustChangePassword: false },
authenticatedUser: { id: 7, mustChangePassword: true }
}, adminMustChangeResponse, function (error) { assert.ifError(error); });
assert.equal(adminMustChangeResponse.redirectedTo, '/account/password');
let stopAllowed = false;
authGate({
method: 'POST',
path: '/settings/view-as/stop',
currentUser: { id: 19, isViewingAs: true, mustChangePassword: true },
authenticatedUser: { id: 7, mustChangePassword: true }
}, {}, function (error) { assert.ifError(error); stopAllowed = true; });
assert.equal(stopAllowed, true);
});
test('required-password gate blocks account details and allows only password page and logout', () => {
const app = {
middlewares: [],
use(...args) { this.middlewares.push(args); }
};
registerMiddleware(app, {
pool: {},
loadCurrentUser: async () => null,
requireAuth(_req, _res, next) { next(); },
MEDIA_DIR: path.join(process.cwd(), 'media'),
UPLOADS_DIR: path.join(process.cwd(), 'media', 'uploads-test'),
THUMBNAILS_DIR: path.join(process.cwd(), 'media', 'thumbnails-test'),
ASSET_DIR: path.join(process.cwd(), 'src', 'web', 'public')
});
const authGate = app.middlewares[app.middlewares.length - 1][0];
const requests = [
{ method: 'GET', path: '/account' },
{ method: 'POST', path: '/account/profile' },
{ method: 'GET', path: '/screens' }
];
requests.forEach(function (req) {
const response = { redirect(path) { this.redirectedTo = path; } };
Object.assign(req, { currentUser: { id: 5, mustChangePassword: true }, authenticatedUser: { id: 5, mustChangePassword: true } });
authGate(req, response, function (error) { assert.ifError(error); });
assert.equal(response.redirectedTo, '/account/password');
});
const passwordPageResponse = {};
authGate({
method: 'GET',
path: '/account/password',
currentUser: { id: 5, mustChangePassword: true },
authenticatedUser: { id: 5, mustChangePassword: true }
}, passwordPageResponse, function (error) { assert.ifError(error); });
const logoutResponse = {};
authGate({
method: 'GET',
path: '/logout',
currentUser: { id: 5, mustChangePassword: true },
authenticatedUser: { id: 5, mustChangePassword: true }
}, logoutResponse, function (error) { assert.ifError(error); });
});