460 lines
15 KiB
JavaScript
460 lines
15 KiB
JavaScript
const test = require('node:test');
|
|
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');
|
|
|
|
test('login page preserves the return target in a hidden field', () => {
|
|
const html = renderLoginPage('', '/slides/6/edit', 'demo');
|
|
|
|
assert.match(html, /name="returnTo"/);
|
|
assert.match(html, /value="\/slides\/6\/edit"/);
|
|
assert.match(html, /name="remember_me"/);
|
|
assert.match(html, /name="username"[^>]*value="demo"/);
|
|
});
|
|
|
|
test('requireAuth preserves the original request path when redirecting to login', () => {
|
|
const sessionService = createSessionService({
|
|
sessionCookieName: 'pulse_session',
|
|
sessionMaxAgeMs: 60 * 60 * 1000,
|
|
hashSessionToken: (value) => value,
|
|
createSessionToken: () => 'token'
|
|
});
|
|
|
|
const req = {
|
|
originalUrl: '/slides/6/edit?tab=content'
|
|
};
|
|
const res = {
|
|
headers: {},
|
|
getHeader(name) {
|
|
return this.headers[name];
|
|
},
|
|
setHeader(name, value) {
|
|
this.headers[name] = value;
|
|
},
|
|
redirect(url) {
|
|
this.redirectedTo = url;
|
|
}
|
|
};
|
|
|
|
sessionService.requireAuth(req, res, () => {});
|
|
|
|
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'),
|
|
'/slides/6/edit?tab=content'
|
|
);
|
|
assert.equal(
|
|
normalizeReturnToPath('https://evil.example.com/slides/6/edit', 'https://signage.example.com'),
|
|
''
|
|
);
|
|
});
|
|
|
|
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 = {
|
|
get(path, ...routeHandlers) {
|
|
handlers['GET ' + path] = routeHandlers;
|
|
},
|
|
post(path, ...routeHandlers) {
|
|
handlers['POST ' + path] = routeHandlers;
|
|
}
|
|
};
|
|
|
|
registerAuthRoutes(app, {
|
|
pool: {
|
|
async query(sql) {
|
|
if (sql.includes('FROM a_users WHERE username = ? LIMIT 1')) {
|
|
return [[{ id: 7, name: 'Demo User', username: 'demo', password_hash: 'hash', password_salt: 'salt', password_iterations: 1 }]];
|
|
}
|
|
return [[]];
|
|
}
|
|
},
|
|
pages: {
|
|
renderLoginPage(message, returnTo) {
|
|
return { message, returnTo };
|
|
}
|
|
},
|
|
createUserSession: async () => 'session-token',
|
|
getSessionMaxAgeMs: async () => 7 * 24 * 60 * 60 * 1000,
|
|
setSessionCookie(res, token, maxAgeMs) {
|
|
res.sessionToken = token;
|
|
res.sessionMaxAgeMs = maxAgeMs;
|
|
},
|
|
clearSessionCookie() {},
|
|
parseCookies() {
|
|
return {};
|
|
},
|
|
consumeAuthMessageCookie() {
|
|
return '';
|
|
},
|
|
setAuthMessageCookie() {},
|
|
hashSessionToken(value) {
|
|
return value;
|
|
},
|
|
verifyPassword() {
|
|
return true;
|
|
},
|
|
sessionCookieName: 'pulse_session'
|
|
});
|
|
|
|
const req = {
|
|
body: {
|
|
username: 'demo',
|
|
password: 'secret',
|
|
returnTo: '/slides/6/edit?tab=content'
|
|
},
|
|
query: {
|
|
returnTo: '/slides/6/edit?tab=content'
|
|
}
|
|
};
|
|
const res = {
|
|
redirect(url) {
|
|
this.redirectedTo = url;
|
|
},
|
|
status() {
|
|
return this;
|
|
},
|
|
send(value) {
|
|
this.sentValue = value;
|
|
return this;
|
|
}
|
|
};
|
|
|
|
await handlers['POST /login'][0](req, res, () => {});
|
|
|
|
assert.equal(res.sessionToken, 'session-token');
|
|
assert.equal(res.sessionMaxAgeMs, null);
|
|
assert.equal(res.redirectedTo, '/slides/6/edit?tab=content');
|
|
|
|
const rememberedRequest = {
|
|
body: {
|
|
username: 'demo',
|
|
password: 'secret',
|
|
remember_me: '1'
|
|
},
|
|
query: {}
|
|
};
|
|
const rememberedResponse = {
|
|
redirect(url) {
|
|
this.redirectedTo = url;
|
|
},
|
|
status() {
|
|
return this;
|
|
},
|
|
send(value) {
|
|
this.sentValue = value;
|
|
return this;
|
|
}
|
|
};
|
|
|
|
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 () => {
|
|
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.');
|
|
}); |