Files
pulse-signage/test/auth-routes.test.js

351 lines
13 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('two-factor login enables device trust, submits it, and resets it on back', async () => {
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const nodes = {};
const requests = [];
let initialMessageRemoved = false;
let redirectedTo;
let submit;
const form = {
action: '/api/auth/sign-in/username',
elements: {
username: { value: 'demo' },
password: { value: 'password' },
remember_me: { checked: false },
trust_device: { disabled: true, checked: false },
two_factor_code: { disabled: true, value: '', focus() {} }
},
querySelector(selector) {
return nodes[selector] ||= { addEventListener(event, handler) { this[event] = handler; } };
},
addEventListener(event, handler) { submit = handler; }
};
const scriptPath = path.join(__dirname, '../src/web/public/js/auth/login.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), {
document: {
querySelector(selector) {
if (selector === '[data-better-auth-login]') return form;
if (selector === '[data-login-initial-message]' && !initialMessageRemoved) {
return { remove() { initialMessageRemoved = true; } };
}
return null;
}
},
window: { location: { search: '', assign(value) { redirectedTo = value; } } },
URLSearchParams,
async fetch(endpoint, options) {
assert.equal(initialMessageRemoved, true);
requests.push({ endpoint, payload: JSON.parse(options.body) });
return { ok: true, async json() { return requests.length === 1 ? { twoFactorRedirect: true, twoFactorMethods: ['totp'] } : {}; } };
}
});
await submit({ preventDefault() {} });
assert.equal(initialMessageRemoved, true);
assert.equal(form.elements.trust_device.disabled, false);
assert.equal(nodes['[data-login-feedback]'].hidden, true);
assert.equal(redirectedTo, undefined);
form.elements.trust_device.checked = true;
form.elements.two_factor_code.value = '123456';
await submit({ preventDefault() {} });
assert.equal(requests[1].endpoint, '/api/auth/two-factor/verify-totp');
assert.equal(requests[1].payload.trustDevice, true);
assert.equal(redirectedTo, '/');
nodes['[data-two-factor-back]'].click();
assert.equal(form.elements.trust_device.disabled, true);
assert.equal(form.elements.trust_device.checked, false);
});
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, /action="\/api\/auth\/sign-in\/username"/);
assert.match(html, /js\/auth\/login\.js/);
assert.match(html, /name="username"[^>]*value="demo"/);
assert.match(html, /data-login-two-factor/);
assert.match(html, /name="two_factor_code"/);
assert.match(html, /data-two-factor-toggle-method/);
assert.doesNotMatch(html, /Sign in to start your session/);
assert.match(html, /<label for="username" class="visually-hidden">Username or email<\/label>/);
assert.match(html, /<label for="password" class="visually-hidden">Password<\/label>/);
assert.match(html, /class="btn btn-secondary w-100" data-passkey-login/);
assert.doesNotMatch(html, /class="btn btn-outline-secondary w-100" data-passkey-login/);
assert.match(html, /data-passkey-login/);
assert.match(html, /js\/auth\/passkey-client\.js/);
});
test('login messages render inline without toast markup or scripts', () => {
const html = renderLoginPage('You have been signed out.');
assert.match(html, /<div class="alert alert-warning py-2" data-login-initial-message>You have been signed out\.<\/div>/);
assert.doesNotMatch(html, /app-toast|js\/shared\/ui\/toast\.js/);
});
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 and aggregates role two-factor requirements', 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', require_two_factor: 0 }, { role_key: 'secure', require_two_factor: Number(params[0]) === 7 ? 1 : 0 }]];
}
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.equal(currentUser.requiresTwoFactor, false);
assert.equal(currentUser.authenticatedUser.requiresTwoFactor, true);
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' } };
req.betterAuthSession = { session: { token: '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, 'token', 7]);
assert.deepEqual(queries[1][1], [7, '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('custom login POST is removed in favor of Better Auth', () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers['GET ' + path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers['POST ' + path] = routeHandlers;
}
};
registerAuthRoutes(app, {
pool: { async query() { return [[]]; } },
pages: {},
clearSessionCookie() {},
parseCookies() { return {}; },
consumeAuthMessageCookie() { return ''; },
setAuthMessageCookie() {},
hashSessionToken(value) { return value; },
sessionCookieName: 'pulse_session'
});
assert.equal(handlers['POST /login'], undefined);
});
test('invitation acceptance endpoints are not registered', () => {
const routes = [];
const app = {
get(path) { routes.push('GET ' + path); },
post(path) { routes.push('POST ' + path); }
};
registerAuthRoutes(app, {});
assert.equal(routes.includes('GET /accept-invite'), false);
assert.equal(routes.includes('POST /accept-invite'), false);
});