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.'); });