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
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user