Release v2.17.0: add read-only user previews and access fallbacks
This commit is contained in:
1 parent
11dd801f6d
commit
9c28eca3ef
52 files changed
+1624
-140
No files matched your search
@@ -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');
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
@@ -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=/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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); });
|
||||
});
|
||||
Reference in new issue
Block a user