Release v2.20.1: migrate authentication to Better Auth

This commit is contained in:
lzstealth committed 2026-10-25 18:19:55 +00:00
1 parent e9a53f577c
commit fba4f99ff7
92 files changed
+5085 -1943

No files matched your search

+15 -7
View File
@@ -1,7 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, renderAccountEmailTemplate } = require('../src/data/account-email-templates');
const { DEFAULT_ACCOUNT_EMAIL_TEMPLATES, formatAccountEmailExpiry, normalizeOtpEmailBody, renderAccountEmailTemplate } = require('../src/data/account-email-templates');
test('account email expiry values render with singular and plural units', () => {
assert.equal(formatAccountEmailExpiry(1, 'minutes'), '1 minute');
@@ -10,16 +10,24 @@ test('account email expiry values render with singular and plural units', () =>
assert.equal(renderAccountEmailTemplate('Subject [[expiry_time]]', 'Expires in [[expiry_time]].', { expiry_time: '45 minutes' }).text, 'Expires in 45 minutes.');
});
test('default account email templates render both the action button and inline link', () => {
test('default account email templates render one-time verification codes', () => {
const rendered = renderAccountEmailTemplate('Subject', DEFAULT_ACCOUNT_EMAIL_TEMPLATES.verificationBody, {
url: 'https://example.test/verify',
display_name: 'Alex',
otp: '123456',
expiry_time: '30 minutes',
action_label: 'Verify email address'
});
assert.match(rendered.html, /<strong>Alex<\/strong>/);
assert.match(rendered.html, /This <u>link<\/u> expires in <em>30 minutes<\/em>\./);
assert.match(rendered.html, /Confirm this email address: <a href="https:\/\/example\.test\/verify">https:\/\/example\.test\/verify<\/a>/);
assert.doesNotMatch(rendered.html, /\[\[(?:action_button|url)\]\]/);
assert.match(rendered.html, /verification code[\s\S]*<strong>123456<\/strong>/);
assert.match(rendered.html, /This code expires in <em>30 minutes<\/em>\./);
assert.doesNotMatch(rendered.html, /\[\[(?:action_button|url|otp)\]\]/);
});
test('legacy link email templates normalize to OTP wording and placeholders', () => {
const normalized = normalizeOtpEmailBody('Click [[action_button]]. This link expires in [[expiry_time]]. Verify: [[url]]');
const rendered = renderAccountEmailTemplate('Subject', normalized, { otp: '321654', expiry_time: '5 minutes' });
assert.match(rendered.text, /verification code/);
assert.match(rendered.text, /321654/);
assert.doesNotMatch(rendered.text, /\[\[(?:action_button|url|otp)\]\]/);
});
+258 -10
View File
@@ -27,6 +27,197 @@ test('required-password page shows only password controls and uses an inline war
assert.doesNotMatch(html, /id="app-toast"/);
});
test('account page presents Better Auth identity, credential, and session controls', () => {
const html = pages.renderAccountPage({
id: 5,
username: 'demo',
name: 'Demo',
email: 'demo@example.test',
email_verified: true
}, '', '/dashboard', true, [], {
minimumLength: 10,
minimumCategories: 3,
requireLowercase: false,
requireUppercase: false,
requireNumber: false,
requireSymbol: false
});
assert.match(html, /Profile and email/);
assert.match(html, /Verified/);
assert.match(html, /Active sessions/);
assert.match(html, /Change password/);
assert.equal((html.match(/name="current_password"/g) || []).length, 2);
assert.doesNotMatch(html, /Confirmation|data-account-current-password|type="hidden" name="current_password"/);
});
test('two-factor account controls show setup or management only for the authenticated user', () => {
const requirements = { minimumLength: 10, minimumCategories: 3 };
const render = user => pages.renderAccountPage(user, '', '/dashboard', false, [], requirements);
const setupHtml = render({ id: 5, username: 'demo', twoFactorEnabled: false });
const enabledHtml = render({ id: 5, username: 'demo', twoFactorEnabled: true });
const viewAsHtml = render({ id: 8, username: 'preview', twoFactorEnabled: false, isViewingAs: true });
assert.match(setupHtml, /data-two-factor-enable/);
assert.match(setupHtml, /data-two-factor-setup hidden/);
assert.doesNotMatch(setupHtml, /optional/i);
assert.match(setupHtml, /id="two-factor-setup-modal"/);
assert.match(setupHtml, /class="card-body py-0"[\s\S]*class="list-group list-group-flush"/);
assert.match(setupHtml, /list-group-item d-flex align-items-center justify-content-between gap-3 px-0/);
assert.match(setupHtml, /data-two-factor-download/);
assert.match(setupHtml, /aria-label="Download recovery codes"/);
assert.doesNotMatch(setupHtml, /modal-body[^]*?<hr>/);
assert.doesNotMatch(setupHtml, /data-two-factor-disable/);
assert.match(enabledHtml, /data-two-factor-disable/);
assert.match(enabledHtml, /data-two-factor-regenerate-codes/);
assert.match(enabledHtml, /id="two-factor-manage-modal"/);
assert.match(enabledHtml, /aria-label="Download recovery codes"/);
assert.doesNotMatch(enabledHtml, /Once enabled, two-step verification is required at password sign-in\./);
assert.match(enabledHtml, /data-bs-target="#two-factor-manage-modal"/);
assert.match(enabledHtml, /id="two-factor-manage-modal"/);
assert.doesNotMatch(viewAsHtml, /Two-step verification|data-two-factor-enable/);
});
test('passkey management is account-scoped and hidden while TOTP policy requires password sign-in', () => {
const requirements = { minimumLength: 10, minimumCategories: 3 };
const render = (user, passkeys) => pages.renderAccountPage(user, '', '/dashboard', false, [], requirements, [], passkeys);
const available = render({ id: 5, username: 'demo', twoFactorEnabled: false }, [{ id: 'pk-1', name: 'Laptop' }]);
const protectedAccount = render({ id: 5, username: 'demo', twoFactorEnabled: true }, [{ id: 'pk-1', name: 'Laptop' }]);
const viewAs = render({ id: 8, username: 'preview', isViewingAs: true }, []);
assert.match(available, /data-passkey-add/);
assert.match(available, /id="passkey-setup-modal"/);
assert.match(available, /data-passkey-add-trigger[^>]*data-bs-toggle="modal"[^>]*data-bs-target="#passkey-setup-modal"/);
assert.doesNotMatch(available, /optional/i);
assert.match(available, /data-passkey-group[\s\S]*?data-passkey-list/);
assert.match(available, /data-passkey-list[\s\S]*?data-passkey-delete="pk-1"/);
assert.match(available, /<li class="list-group-item d-flex align-items-center justify-content-between gap-3 border-0 px-0 py-1 lh-sm">[\s\S]*Laptop/);
assert.match(available, /bi-arrow-return-right[^>]*aria-hidden="true"/);
assert.match(available, /Laptop/);
assert.match(available, /data-passkey-delete="pk-1"/);
assert.match(available, /js\/account\/passkeys\.js/);
assert.doesNotMatch(protectedAccount, /data-passkey-add/);
assert.match(protectedAccount, /Unavailable while authenticator verification is enabled/);
assert.doesNotMatch(viewAs, /data-passkey-add|data-passkey-delete/);
});
test('role-required two-factor enrollment is visible and cannot be unlinked from My account', () => {
const render = twoFactorEnabled => pages.renderAccountPage({ id: 5, username: 'demo', requiresTwoFactor: true, twoFactorEnabled }, '', '/dashboard', false, []);
const pending = render(false);
assert.match(pending, /Required by role/);
assert.match(pending, /Set up an authenticator to continue/);
assert.match(pending, /data-two-factor-enable/);
const enabled = render(true);
assert.doesNotMatch(enabled, /data-two-factor-disable/);
assert.match(enabled, /data-two-factor-regenerate-codes/);
assert.match(enabled, />Manage<\/button>/);
});
test('account email status and verification action stay inline with the address field', () => {
const requirements = {
minimumLength: 10,
minimumCategories: 3,
requireLowercase: false,
requireUppercase: false,
requireNumber: false,
requireSymbol: false
};
const render = user => pages.renderAccountPage(user, '', '/dashboard', false, [], requirements);
const verifiedHtml = render({ username: 'demo', name: 'Demo', email: 'demo@example.test', email_verified: true });
assert.match(verifiedHtml, /<label for="account-email"[^>]*>[\s\S]*<span class="small text-success">Verified<\/span>/);
assert.doesNotMatch(verifiedHtml, /badge|Send code|Resend code/);
const unverifiedHtml = render({ username: 'demo', name: 'Demo', email: 'demo@example.test', email_verified: false });
assert.match(unverifiedHtml, /<div class="input-group">[\s\S]*name="email"[\s\S]*form="account-email-verification-form">Send code/);
assert.match(unverifiedHtml, /action="\/account\/email\/send-verification"/);
const pendingHtml = render({ username: 'demo', name: 'Demo', email: 'demo@example.test', pending_email: 'new@example.test' });
assert.match(pendingHtml, /Change pending/);
assert.match(pendingHtml, /form="account-email-verification-form">Resend code/);
assert.match(pendingHtml, /action="\/account\/email"/);
assert.match(pendingHtml, /name="otp"/);
assert.doesNotMatch(pendingHtml, /Cancel change|Cancel verification|\/account\/email\/cancel/);
});
test('account page clears pending_email when its Better Auth OTP is missing or expired', async () => {
const handlers = {};
const queries = [];
let renderedUser = null;
const app = {
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
post() {}
};
const pool = {
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('SELECT id, setting_key, setting_value FROM o_app_settings')) {
return [[{ id: 1, setting_key: 'security.allow_user_session_revocation', setting_value: 'false' }]];
}
if (text.includes('UPDATE a_users') && text.includes('NOT EXISTS')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
};
registerAccountRoutes(app, {
pool: pool,
common: {},
pages: {
renderAccountPage(user) { renderedUser = Object.assign({}, user); return 'account'; }
}
});
const req = {
currentUser: { id: 7, username: 'demo', email: 'old@example.test', pending_email: 'new@example.test' },
query: {}
};
const res = { send(value) { this.body = value; }, status() { return this; } };
await handlers['/account'][0](req, res);
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_verifications WHERE identifier = ? AND expires_at <= NOW()') && params[0] === 'change-email-otp-old@example.test-new@example.test'));
assert.ok(queries.some(([sql, params]) => sql.includes('UPDATE a_users') && sql.includes('NOT EXISTS') && params[1] === 'new@example.test'));
assert.equal(renderedUser.pending_email, null);
assert.equal(res.body, 'account');
});
test('account page preserves pending_email while a Better Auth OTP is still active', async () => {
const handlers = {};
let renderedUser = null;
const app = {
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
post() {}
};
const pool = {
async query(sql) {
if (String(sql).includes('SELECT id, setting_key, setting_value FROM o_app_settings')) {
return [[{ id: 1, setting_key: 'security.allow_user_session_revocation', setting_value: 'false' }]];
}
if (String(sql).includes('UPDATE a_users') && String(sql).includes('NOT EXISTS')) {
return [{ affectedRows: 0 }];
}
return [[]];
}
};
registerAccountRoutes(app, {
pool: pool,
common: {},
pages: {
renderAccountPage(user) { renderedUser = Object.assign({}, user); return 'account'; }
}
});
const pendingEmail = 'new@example.test';
const req = {
currentUser: { id: 7, username: 'demo', email: 'old@example.test', pending_email: pendingEmail },
query: {}
};
const res = { send(value) { this.body = value; }, status() { return this; } };
await handlers['/account'][0](req, res);
assert.equal(renderedUser.pending_email, pendingEmail);
});
test('required-password route renders the password-only page and redirects after the requirement clears', async () => {
const handlers = {};
const app = {
@@ -67,8 +258,9 @@ test('required-password route renders the password-only page and redirects after
assert.equal(regularResponse.redirectedTo, '/account');
});
test('account password update redirects back to the account page', async () => {
test('account password update delegates to Better Auth and signs out', async () => {
const handlers = {};
let changePasswordRequest = null;
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
@@ -79,10 +271,7 @@ test('account password update redirects back to the account page', async () => {
const deps = {
pool: {
async query(sql) {
if (sql.includes('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1')) {
return [[{ id: 5, password_hash: 'hash', password_salt: 'salt', password_iterations: 1 }]];
}
if (sql.includes('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?')) {
if (sql.includes('UPDATE a_users SET must_change_password = 0')) {
return [{ affectedRows: 1 }];
}
if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) {
@@ -93,13 +282,16 @@ test('account password update redirects back to the account page', async () => {
},
common: {},
pages: {},
betterAuth: {
api: {
async changePassword(request) { changePasswordRequest = request; }
}
},
fromNodeHeaders(headers) { return headers; },
formatDashboardDate: () => '',
getAuditUserId: () => 9,
verifyPassword: () => true,
validatePasswordStrength: validatePasswordStrength,
hashPassword: () => ({ hash: 'new-hash', salt: 'new-salt', iterations: 2 }),
createUserSession: async () => 'session-token',
setSessionCookie: () => {}
clearSessionCookie() {}
};
registerAccountRoutes(app, deps);
@@ -113,6 +305,7 @@ test('account password update redirects back to the account page', async () => {
new_password: 'NewPassword!1',
confirm_password: 'NewPassword!1'
},
headers: { cookie: 'digital_signage_session=signed-token' },
currentUser: { id: 5 }
};
const res = {
@@ -129,5 +322,60 @@ test('account password update redirects back to the account page', async () => {
await routeHandlers[0](req, res, () => {});
assert.equal(res.redirectedTo, '/account?message=Password%20updated.');
assert.deepEqual(changePasswordRequest.body, {
currentPassword: 'current-password',
newPassword: 'NewPassword!1',
revokeOtherSessions: true
});
assert.deepEqual(changePasswordRequest.headers, req.headers);
assert.equal(res.redirectedTo, '/login?message=Password%20updated.%20Sign%20in%20again.');
});
test('account profile verifies the current password through Better Auth', async () => {
const handlers = {};
let verificationRequest = null;
const app = {
post(path, ...routeHandlers) { handlers[path] = routeHandlers; },
get() {}
};
registerAccountRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('SELECT id, username, name, email, pending_email FROM a_users')) {
return [[{ id: 5, username: 'demo', name: 'Demo', email: 'demo@example.test', pending_email: null }]];
}
return [{ affectedRows: 1 }];
}
},
common: {
fetchDuplicateName: async () => null,
validateMaxLength(value) { return String(value || '').trim(); }
},
pages: {},
betterAuth: {
api: {
async verifyPassword(request) {
verificationRequest = request;
return { status: true };
}
}
},
fromNodeHeaders(headers) { return { cookie: headers.cookie }; },
formatDashboardDate: () => '',
getAuditUserId: () => 5
});
const req = {
body: { name: 'Demo', username: 'demo', email: 'demo@example.test', current_password: 'current-password' },
headers: { cookie: 'auth-session=session-token' },
currentUser: { id: 5 }
};
const res = { redirect(url) { this.redirectedTo = url; } };
await handlers['/account/profile'][0](req, res, error => { throw error; });
assert.deepEqual(verificationRequest, {
body: { password: 'current-password' },
headers: { cookie: 'auth-session=session-token' }
});
assert.equal(res.redirectedTo, '/account?message=Account%20details%20updated.');
});
+12 -1
View File
@@ -15,6 +15,7 @@ test('user create route allows users without roles', async () => {
};
let insertedArgs = null;
let credentialPassword = null;
let syncedRoles = null;
const deps = {
@@ -33,6 +34,13 @@ test('user create route allows users without roles', async () => {
insertedArgs = args;
return [{ insertId: 11 }];
}
if (sql.includes('SELECT id, created_at, modified_at FROM a_users')) {
return [[{ id: 11, created_at: new Date(), modified_at: new Date() }]];
}
if (sql.includes('INSERT INTO a_accounts')) {
credentialPassword = args[3];
return [{ affectedRows: 1 }];
}
return [[]];
},
async commit() {},
@@ -81,7 +89,8 @@ test('user create route allows users without roles', async () => {
name: 'No Roles User',
username: 'noroles',
password: 'ValidPass!1',
confirm_password: 'ValidPass!1'
confirm_password: 'ValidPass!1',
must_change_password: '1'
},
currentUser: { id: 1 }
};
@@ -100,6 +109,8 @@ test('user create route allows users without roles', async () => {
await routeHandlers[1](req, res, () => {});
assert.ok(insertedArgs);
assert.equal(insertedArgs[3], 1);
assert.match(credentialPassword, /^[a-f0-9]{32}:[a-f0-9]{128}$/);
assert.deepEqual(syncedRoles, { userId: 11, roleIds: [] });
assert.equal(res.redirectedTo, '/settings/users/11/edit?message=User%20created.');
});
+6 -2
View File
@@ -7,6 +7,7 @@ const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user edit save and new goes to the blank create page', async () => {
const handlers = {};
let mustChangePasswordValue = null;
const app = {
get() {},
post(path, ...routeHandlers) {
@@ -28,8 +29,9 @@ test('user edit save and new goes to the blank create page', async () => {
async getConnection() {
return {
async beginTransaction() {},
async query(sql) {
if (sql.includes('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?')) {
async query(sql, params) {
if (sql.includes('UPDATE a_users SET name = ?, username = ?, account_locked = ?, must_change_password = ?, modified_by = ? WHERE id = ?')) {
mustChangePasswordValue = params[3];
return [{ affectedRows: 1 }];
}
return [[]];
@@ -75,6 +77,7 @@ test('user edit save and new goes to the blank create page', async () => {
body: {
name: 'User',
username: 'user',
must_change_password: '1',
save_action: 'new'
},
currentUser: { id: 1 }
@@ -94,4 +97,5 @@ test('user edit save and new goes to the blank create page', async () => {
await routeHandlers[1](req, res, () => {});
assert.equal(res.redirectedTo, '/settings/users/new?message=User%20updated.');
assert.equal(mustChangePasswordValue, 1);
});
-192
View File
@@ -1,192 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerUsersRoutes = require('../src/web/routes/admin/users');
test('pending invitations route loads active invitations with display fields', async () => {
const handlers = {};
let invitationQuery = '';
const rendered = {};
const createdAt = new Date('2026-09-03T10:00:00Z');
const expiresAt = new Date('2026-09-04T10:00:00Z');
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post() {}
};
const requiredPermissions = [];
const deps = {
pool: { async query() { return [[]]; } },
common: {
getSearchQuery: () => '',
getSortQuery: () => '',
getSortDirectionQuery: () => 'asc'
},
pages: {
renderInvitationsPage(data, currentUser) {
rendered.invitations = data.invitations;
rendered.pagination = data.pagination;
rendered.currentUser = currentUser;
return '';
}
},
formatDashboardDate(value) {
return value === createdAt ? 'sent label' : 'expiry label';
},
rbacData: {
fetchRoles: async () => [{ id: 2, name: 'Viewer' }],
fetchInvitationsPage: async (_pool, page, pageSize, search, sort, direction) => {
invitationQuery = { page, pageSize, search, sort, direction };
return {
invitations: [{
id: 7,
email: 'person@example.com',
name: 'Person',
role_ids_json: '[2]',
created_at: createdAt,
expires_at: expiresAt,
created_by_username: 'admin'
}],
totalItems: 26,
currentPage: 2
};
}
},
requirePermission(permission) {
requiredPermissions.push(permission);
return function (_req, _res, next) {
next();
};
}
};
registerUsersRoutes(app, deps);
const routeHandlers = handlers['/settings/invitations'];
assert.ok(requiredPermissions.includes('invitations.read'));
const currentUser = { id: 1 };
const res = {
send(value) {
this.body = value;
}
};
await routeHandlers[1]({ currentUser, query: {} }, res, function (error) {
throw error;
});
assert.deepEqual(invitationQuery, { page: 1, pageSize: 25, search: '', sort: '', direction: 'asc' });
assert.equal(rendered.currentUser, currentUser);
assert.equal(rendered.pagination.totalItems, 26);
assert.deepEqual(rendered.invitations, [{
id: 7,
email: 'person@example.com',
name: 'Person',
role_ids_json: '[2]',
created_at: createdAt,
expires_at: expiresAt,
created_by_username: 'admin',
roleNames: 'Viewer',
createdAtLabel: 'sent label',
expiresAtLabel: 'expiry label',
createdByLabel: 'admin'
}]);
});
test('user invite route stores the invitation and sends an acceptance link', async () => {
const handlers = {};
const queries = [];
let sentEmail = null;
const app = {
get() {},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
const deps = {
pool: {
async query(sql, args) {
queries.push({ sql, args });
if (sql.includes('o_app_settings')) return [[{ setting_key: 'email.smtp_enabled', setting_value: 'true' }]];
if (sql.includes('SELECT id FROM a_users WHERE email')) return [[]];
if (sql.includes('INSERT INTO a_user_invitations')) return [{ insertId: 4 }];
if (sql.includes('COUNT(*) AS invite_count')) return [[{ invite_count: 0 }]];
return [[]];
}
},
common: {
validateMaxLength(value) { return String(value || '').trim(); }
},
pages: {
renderUsersInvitePage() { return ''; }
},
formatDashboardDate: () => '',
getAuditUserId: () => 9,
sendAccountEmail: async (_settings, message) => { sentEmail = message; },
createOneTimeToken: () => 'invite-token',
hashSessionToken: (value) => 'hash:' + value,
getRequestOrigin: () => 'https://signage.example.com',
readArrayField: () => ['2'],
rbacData: {
fetchRoles: async () => [{ id: 2, name: 'Viewer' }]
},
requirePermission() {
return function (_req, _res, next) { next(); };
}
};
registerUsersRoutes(app, deps);
const res = {
redirect(url) { this.redirectedTo = url; },
status() { return this; },
send() { return this; }
};
let routeError = null;
await handlers['/settings/users/invite'][1]({
body: { email: ' Person@Example.COM ', name: 'Person' },
currentUser: { id: 9 }
}, res, (error) => { routeError = error; });
assert.equal(routeError, null);
const insert = queries.find((query) => query.sql.includes('INSERT INTO a_user_invitations'));
assert.deepEqual(insert.args, ['person@example.com', 'Person', '[2]', 'hash:invite-token', 9]);
assert.equal(sentEmail.to, 'person@example.com');
assert.match(sentEmail.html, /accept-invite\?token=invite-token/);
assert.equal(res.redirectedTo, '/settings/users?message=Invitation%20sent.');
});
test('user invite route rejects invalid email before sending', async () => {
const handlers = {};
const app = {
get() {},
post(path, ...routeHandlers) { handlers[path] = routeHandlers; }
};
let renderedMessage = '';
const deps = {
pool: { async query() { return [[]]; } },
common: {
validateMaxLength(value) { return String(value || '').trim(); }
},
pages: {
renderUsersInvitePage(message) { renderedMessage = message; return ''; }
},
formatDashboardDate: () => '',
getAuditUserId: () => 1,
readArrayField: () => [],
rbacData: { fetchRoles: async () => [] },
requirePermission() {
return function (_req, _res, next) { next(); };
}
};
registerUsersRoutes(app, deps);
const res = { status() { return this; }, send() {} };
await handlers['/settings/users/invite'][1]({ body: { email: 'invalid', name: 'Person' }, currentUser: { id: 1 } }, res, () => {});
assert.equal(renderedMessage, 'Email address is invalid.');
});
+128 -5
View File
@@ -8,6 +8,8 @@ const { validatePasswordStrength } = require('../src/auth');
test('user password reset redirects back to the selected user edit page', async () => {
const handlers = {};
let savedMustChangePassword = null;
let credentialPassword = null;
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
@@ -17,11 +19,19 @@ test('user password reset redirects back to the selected user edit page', async
const deps = {
pool: {
async query(sql) {
if (sql.includes('SELECT id FROM a_users WHERE id = ? LIMIT 1')) {
return [[{ id: 7 }]];
async query(sql, params) {
if (sql.includes('SELECT id, username, must_change_password FROM a_users WHERE id = ? LIMIT 1')) {
return [[{ id: 7, username: 'user', must_change_password: 0 }]];
}
if (sql.includes('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?')) {
if (sql.includes('SELECT id, created_at, modified_at FROM a_users')) {
return [[{ id: 7, created_at: new Date(), modified_at: new Date() }]];
}
if (sql.includes('INSERT INTO a_accounts')) {
credentialPassword = params[3];
return [{ affectedRows: 1 }];
}
if (sql.includes('UPDATE a_users SET must_change_password = ?, modified_by = ? WHERE id = ?')) {
savedMustChangePassword = params[0];
return [{ affectedRows: 1 }];
}
if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) {
@@ -59,7 +69,7 @@ test('user password reset redirects back to the selected user edit page', async
const req = {
params: { id: '7' },
body: { password: 'NewPassword!1', confirm_password: 'NewPassword!1' },
body: { password: 'NewPassword!1', confirm_password: 'NewPassword!1', must_change_password: '1' },
currentUser: { id: 1 }
};
const res = {
@@ -77,4 +87,117 @@ test('user password reset redirects back to the selected user edit page', async
await routeHandlers[1](req, res, () => {});
assert.equal(res.redirectedTo, '/settings/users/7/edit?message=Password%20updated.');
assert.equal(savedMustChangePassword, 1);
assert.match(credentialPassword, /^[a-f0-9]{32}:[a-f0-9]{128}$/);
});
test('admin unlinks another user two-factor data and revokes their sessions', async () => {
const handlers = {};
const queries = [];
let committed = false;
let auditedEvent = null;
const app = {
post(path, ...routeHandlers) { handlers[path] = routeHandlers; },
get() {}
};
const connection = {
async beginTransaction() {},
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('SELECT id FROM a_users')) return [[{ id: 7 }]];
if (text.includes('SELECT id FROM a_user_two_factors')) return [[{ id: 'factor-7' }]];
return [{ affectedRows: 1 }];
},
async commit() { committed = true; },
async rollback() {},
release() {}
};
const deps = {
pool: {
async query() { return [[]]; },
async getConnection() { return connection; }
},
common: {},
pages: {},
formatDashboardDate: () => '',
getAuditUserId: () => 3,
recordRequestAuditEvent: async (_pool, _req, event) => { auditedEvent = event; },
validatePasswordStrength: () => '',
readArrayField: () => [],
rbacData: {
fetchRoles: async () => [],
fetchUsersWithRolesPage: async () => ({ users: [], totalItems: 0, currentPage: 1 }),
fetchUserWithRoles: async () => null,
fetchActiveUserSessions: async () => [],
syncUserRoles: async () => {}
},
requirePermission() { return (_req, _res, next) => next(); }
};
registerUsersRoutes(app, deps);
const response = { redirect(url) { this.redirectedTo = url; }, status() { return this; }, send(value) { this.body = value; } };
await handlers['/settings/users/:id/two-factor/unlink'][1]({ params: { id: '7' }, currentUser: { id: 3 } }, response, error => { throw error; });
assert.equal(committed, true);
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_user_two_factors WHERE user_id = ?') && params[0] === 7));
assert.ok(queries.some(([sql, params]) => sql.includes('UPDATE a_users SET two_factor_enabled = 0') && params[1] === 7));
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_sessions WHERE user_id = ?') && params[0] === 7));
assert.equal(auditedEvent.eventType, 'user.two_factor_unlinked');
assert.equal(response.redirectedTo, '/settings/users/7/edit?message=Two-step%20verification%20removed.%20The%20user%20must%20sign%20in%20again.');
});
test('admin unlinks a passkey only from the selected user and revokes their sessions', async () => {
const handlers = {};
const queries = [];
let committed = false;
let auditedEvent = null;
const app = {
post(path, ...routeHandlers) { handlers[path] = routeHandlers; },
get() {}
};
const connection = {
async beginTransaction() {},
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('SELECT id FROM a_users')) return [[{ id: 7 }]];
if (text.includes('FROM a_user_passkeys')) return [[{ id: 'passkey-7' }]];
return [{ affectedRows: 1 }];
},
async commit() { committed = true; },
async rollback() {},
release() {}
};
const deps = {
pool: {
async query() { return [[]]; },
async getConnection() { return connection; }
},
common: {},
pages: {},
formatDashboardDate: () => '',
getAuditUserId: () => 3,
recordRequestAuditEvent: async (_pool, _req, event) => { auditedEvent = event; },
validatePasswordStrength: () => '',
readArrayField: () => [],
rbacData: {
fetchRoles: async () => [],
fetchUsersWithRolesPage: async () => ({ users: [], totalItems: 0, currentPage: 1 }),
fetchUserWithRoles: async () => null,
fetchActiveUserSessions: async () => [],
syncUserRoles: async () => {}
},
requirePermission() { return (_req, _res, next) => next(); }
};
registerUsersRoutes(app, deps);
const response = { redirect(url) { this.redirectedTo = url; }, status() { return this; }, send(value) { this.body = value; } };
await handlers['/settings/users/:id/passkeys/:passkeyId/unlink'][1]({ params: { id: '7', passkeyId: 'passkey-7' }, currentUser: { id: 3 } }, response, error => { throw error; });
assert.equal(committed, true);
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_user_passkeys WHERE id = ? AND user_id = ?') && params[0] === 'passkey-7' && params[1] === 7));
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_sessions WHERE user_id = ?') && params[0] === 7));
assert.equal(auditedEvent.eventType, 'user.passkey_unlinked');
assert.equal(response.redirectedTo, '/settings/users/7/edit?message=Passkey%20removed.%20The%20user%20must%20sign%20in%20again.');
});
+3 -7
View File
@@ -16,16 +16,12 @@ test('application settings expose typed defaults', () => {
assert.equal(settings['uploads.image_max_bytes'], 100 * 1024 * 1024);
assert.deepEqual(settings['uploads.allowed_mime_types'], ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'video/mp4', 'video/webm', 'video/ogg']);
assert.equal(settings['uploads.optimize_images'], true);
assert.equal(settings['security.session_lifetime_days'], 14);
assert.equal(settings['security.allow_user_session_revocation'], true);
assert.equal(settings['security.max_active_sessions'], 0);
assert.equal(settings['security.password_min_length'], 10);
assert.equal(settings['security.password_min_categories'], 3);
assert.equal(settings['security.require_password_change_for_new_users'], true);
assert.equal(settings['security.require_password_change_after_admin_reset'], true);
assert.equal(settings['security.login_max_attempts'], 5);
assert.equal(settings['security.login_lockout_minutes'], 15);
assert.equal(settings['security.login_rate_limit_scope'], 'both');
assert.equal(settings['security.require_password_change_for_new_users'], undefined);
assert.equal(settings['security.require_password_change_after_admin_reset'], undefined);
assert.equal(settings['security.allow_admin_email_verification_bypass'], true);
assert.equal(settings['data-sources.rss_default_interval_value'], 60);
assert.equal(settings['data-sources.rss_default_interval_unit'], 'minutes');
assert.equal(settings['data-sources.api_default_interval_value'], 60);
+21
View File
@@ -51,6 +51,22 @@ test('audit log uses its own read permission and shared pagination partial', asy
logoPath: {
from: '',
to: '/media/logo.png'
},
'content.heading.value': {
from: 'Old heading',
to: 'New heading'
},
'content.added.value': {
from: null,
to: 'Added text'
},
'content.removed.value': {
from: 'Removed text',
to: null
},
'content.qr.qr_svg': {
from: null,
to: '[Generated asset: 13 bytes; SHA-256 fingerprint]'
}
}
})
@@ -108,6 +124,11 @@ test('audit log uses its own read permission and shared pagination partial', asy
assert.match(response.body, /playlists\.read/);
assert.match(response.body, /backgroundColor added/);
assert.match(response.body, /logoPath added/);
assert.match(response.body, /content\.heading\.value/);
assert.match(response.body, />New heading</);
assert.match(response.body, /content\.added\.value added/);
assert.match(response.body, /content\.removed\.value removed/);
assert.match(response.body, /Generated asset: 13 bytes; SHA-256 fingerprint/);
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
assert.match(response.body, />Export</);
assert.ok(handlers['/settings/audit-log/export']);
+137 -1
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
require('../src/common');
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, buildAuditChanges, buildSlideAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
const { getDefaultAppSettings } = require('../src/data/app-settings');
function createPool(settings) {
@@ -68,6 +68,20 @@ test('audit writer omits request metadata when disabled', async () => {
assert.equal(pool.inserts[0][7], null);
});
test('audit writer skips empty update diffs for content but preserves action events and other details', async () => {
const categories = ['slides', 'templates', 'playlists', 'screens', 'canvas-sizes', 'weather'];
const pool = createPool({ enabled: true, categories, includeMetadata: false });
for (const category of categories) {
await recordRequestAuditEvent(pool, request, { category, eventType: 'content.updated', details: { changes: {} } });
}
assert.equal(pool.inserts.length, 0);
await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.created' });
await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.deleted' });
await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.updated', details: { changes: {}, promotedSlideId: 7 } });
await recordRequestAuditEvent(pool, request, { category: 'templates', eventType: 'template.updated', details: { changes: { 'regions.heading.x': { from: 1, to: 2 } } } });
assert.equal(pool.inserts.length, 4);
});
test('audit changes include only fields with different from and to values', () => {
assert.deepEqual(buildAuditChanges({
name: 'Old name',
@@ -81,4 +95,126 @@ test('audit changes include only fields with different from and to values', () =
name: { from: 'Old name', to: 'New name' },
roleIds: { from: [1, 2], to: [2, 3] }
});
});
test('slide audit stores only changed leaf fields without unchanged content', () => {
const previous = { title: 'Slide', content: { heading: { value: 'Old', font_size: 32 }, qr: { qr_svg: '<svg>' + 'x'.repeat(100000) + '</svg>' } } };
const next = structuredClone(previous);
next.content.heading.value = 'New';
const changes = buildSlideAuditChanges(previous, next);
assert.deepEqual(changes, { 'content.heading.value': { from: 'Old', to: 'New' } });
assert.ok(JSON.stringify(changes).length < 100);
});
test('slide audit ignores unchanged saves and object key order', () => {
assert.deepEqual(buildSlideAuditChanges({ content: { body: { value: 'Same', size: 32 } } }, { content: { body: { size: 32, value: 'Same' } } }), {});
});
test('slide audit preserves added and removed fields across JSON serialization', () => {
const changes = buildSlideAuditChanges({ content: { removed: { value: 'Old' }, body: { muted: false } } }, { content: { added: { value: 'New' }, body: { muted: true } } });
assert.deepEqual(JSON.parse(JSON.stringify(changes)), {
'content.removed.value': { from: 'Old', to: null },
'content.body.muted': { from: false, to: true },
'content.added.value': { from: null, to: 'New' }
});
});
test('slide audit fingerprints generated QR payloads including added and removed assets', () => {
const previous = { content: { qr: { qr_svg: '<svg>' + 'x'.repeat(100000) + '</svg>', qr_preview: 'data:image/png;base64,' + 'y'.repeat(100000), value: 'https://example.com/old' } } };
const next = { content: { qr: { qr_svg: '<svg>new</svg>', value: 'https://example.com/new' } } };
const changes = buildSlideAuditChanges(previous, next);
assert.match(changes['content.qr.qr_svg'].from, /^\[Generated asset: 100011 bytes; SHA-256 [a-f0-9]{64}\]$/);
assert.notEqual(changes['content.qr.qr_svg'].from, changes['content.qr.qr_svg'].to);
assert.equal(changes['content.qr.qr_preview'].to, null);
assert.deepEqual(changes['content.qr.value'], { from: 'https://example.com/old', to: 'https://example.com/new' });
assert.ok(JSON.stringify(changes).length < 1000);
const added = buildSlideAuditChanges({}, next);
assert.equal(added['content.qr.qr_svg'].from, null);
assert.match(added['content.qr.qr_svg'].to, /SHA-256/);
assert.equal(previous.content.qr.qr_svg.length, 100011);
});
test('slide save skips no-op audit events while retaining save side effects', async () => {
const registerContentRoutes = require('../src/web/routes/admin/content');
for (const changed of [false, true]) {
const handlers = {};
const calls = [];
const events = [];
const content = { heading: { type: 'text', value: 'Old' } };
const nextContent = { heading: { type: 'text', value: changed ? 'New' : 'Old' } };
const noop = async () => {};
const connection = {
beginTransaction: noop,
async commit() { calls.push('commit'); },
rollback: noop,
release() {},
async query(sql) {
if (sql.startsWith('SELECT id FROM c_slides')) return [[{ id: 7 }]];
if (sql.startsWith('UPDATE c_slides')) calls.push('update');
return [[]];
}
};
registerContentRoutes({
get() {},
post(route, ...actions) { handlers[route] = actions; }
}, {
pool: { async query() { return [[]]; }, async getConnection() { return connection; } },
common: {
fetchSlideById: async () => ({ id: 7, title: 'Slide', template_id: 2, content }),
buildSlidePayload: async () => ({ title: 'Slide', templateId: 2, contentJson: JSON.stringify(nextContent) }),
fetchDuplicateName: async () => null,
parseJsonSafe: JSON.parse
},
pages: {}, upload: { any: () => noop, single: () => noop },
fetchScreensBySlideId: async () => [], fetchScreensByTemplateId: async () => [],
collectUploadReferencesFromSlide: () => [], collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [], removeUnusedUploadFiles: noop,
syncPlaylistUploadsOnChange: async () => { calls.push('sync'); },
getAuditUserId: () => 9,
recordRequestAuditEvent: async (_pool, _req, event) => { events.push(event); },
redirectAfterSave: () => { calls.push('redirect'); },
notifyPlayerScreens: noop,
broadcastDashboardState: async () => { calls.push('broadcast'); },
backgroundTaskQueue: { enqueueTask: async () => { calls.push('thumbnail'); } },
getSlideDeleteBlockMessage: noop, getTemplateDeleteBlockMessage: noop, getCanvasSizeDeleteBlockMessage: noop,
hasAnyPermission: () => false, requirePermission: () => noop
});
await handlers['/slides/:id'].at(-1)({ params: { id: '7' }, files: [] }, {}, error => { throw error; });
assert.deepEqual(calls, ['update', 'commit', 'sync', 'broadcast', 'thumbnail', 'redirect']);
assert.equal(events.length, changed ? 1 : 0);
if (changed) {
assert.equal(events[0].eventType, 'slide.updated');
assert.deepEqual(events[0].details.changes, { 'content.heading.value': { from: 'Old', to: 'New' } });
}
}
});
test('shared audit changes compact nested content across categories', () => {
assert.deepEqual(buildAuditChanges({ settings: { text: 'Old', keep: 'Same' } }, { settings: { text: 'New', keep: 'Same' } }), {
'settings.text': { from: 'Old', to: 'New' }
});
});
test('template auditing matches regions by stable key and summarizes default QR assets', () => {
const heading = { region_key: 'heading', x: 10, default_content: { value: 'Same' } };
const qr = { region_key: 'qr', default_content: { qr_svg: '<svg>old</svg>' } };
assert.deepEqual(buildAuditChanges({ regions: [heading, qr] }, { regions: [qr, heading] }), {});
const changes = buildAuditChanges({ regions: [heading, qr] }, { regions: [qr, { ...heading, x: 20 }] });
assert.deepEqual(changes, { 'regions.heading.x': { from: 10, to: 20 } });
const assets = buildAuditChanges({ regions: [qr] }, { regions: [{ ...qr, default_content: { qr_svg: '<svg>new</svg>' } }] });
assert.match(assets['regions.qr.default_content.qr_svg'].from, /SHA-256/);
assert.match(assets['regions.qr.default_content.qr_svg'].to, /SHA-256/);
});
test('ordered content collections retain reordering and added or removed item differences', () => {
const first = { slideId: 1, duration: 10 };
const second = { slideId: 2, duration: 10 };
assert.deepEqual(buildAuditChanges({ items: [first, second] }, { items: [second, first] }), {
'items[0].slideId': { from: 1, to: 2 },
'items[1].slideId': { from: 2, to: 1 }
});
assert.deepEqual(buildAuditChanges({ items: [first, second] }, { items: [first] }), {
'items[1].slideId': { from: 2, to: null },
'items[1].duration': { from: 10, to: null }
});
});
+27
View File
@@ -0,0 +1,27 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createSyntheticAuthEmail, isSyntheticAuthEmail, toVisibleEmail, setCredentialPassword } = require('../src/data/auth-accounts');
test('credential password is stored directly on the Better Auth account', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push([String(sql), params]);
if (String(sql).includes('SELECT id, created_at, modified_at FROM a_users')) return [[{ id: 12, created_at: new Date(), modified_at: new Date() }]];
return [{ affectedRows: 1 }];
}
};
assert.equal(await setCredentialPassword(pool, 12, 'pbkdf2-sha256$310000$salt$hash'), true);
assert.ok(queries.some(([sql, params]) => sql.includes('INSERT INTO a_accounts') && params[3] === 'pbkdf2-sha256$310000$salt$hash'));
assert.equal(queries.some(([sql]) => sql.includes('auth_email') || sql.includes('email_verified_at')), false);
});
test('synthetic Better Auth emails stay hidden from app-facing views', () => {
const syntheticEmail = createSyntheticAuthEmail(13);
assert.match(syntheticEmail, /@identity\.invalid$/);
assert.equal(isSyntheticAuthEmail(syntheticEmail), true);
assert.equal(toVisibleEmail(syntheticEmail), '');
assert.equal(isSyntheticAuthEmail('person@example.com'), false);
assert.equal(toVisibleEmail('person@example.com'), 'person@example.com');
});
+99 -209
View File
@@ -8,13 +8,91 @@ 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', () => {
@@ -46,7 +124,7 @@ 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 () => {
test('session loader keeps the administrator identity and aggregates role two-factor requirements', async () => {
const service = createSessionService({
sessionCookieName: 'pulse_session',
sessionMaxAgeMs: 60 * 60 * 1000,
@@ -71,7 +149,7 @@ test('session loader keeps the administrator identity while loading preview-user
}]];
}
if (sql.includes('SELECT r.role_key')) {
return [[{ role_key: Number(params[0]) === 7 ? 'super-admin' : 'operator' }]];
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 [[
@@ -91,6 +169,8 @@ test('session loader keeps the administrator identity while loading preview-user
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']);
});
@@ -151,11 +231,12 @@ test('view-as session helpers bind changes to the authenticated session', async
}
};
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, 'hash:token', 7]);
assert.deepEqual(queries[1][1], [7, 'hash:token', 7]);
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 () => {
@@ -231,7 +312,7 @@ test('no-access page explains the missing permissions and offers account and sig
assert.match(html, /class="btn btn-primary" href="\/logout"/);
});
test('successful login returns to the requested link instead of the dashboard', async () => {
test('custom login POST is removed in favor of Better Auth', () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
@@ -243,218 +324,27 @@ test('successful login returns to the requested link instead of the dashboard',
};
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;
},
pool: { async query() { return [[]]; } },
pages: {},
clearSessionCookie() {},
parseCookies() {
return {};
},
consumeAuthMessageCookie() {
return '';
},
parseCookies() { return {}; },
consumeAuthMessageCookie() { return ''; },
setAuthMessageCookie() {},
hashSessionToken(value) {
return value;
},
verifyPassword() {
return true;
},
hashSessionToken(value) { return value; },
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, '/');
assert.equal(handlers['POST /login'], undefined);
});
test('accept invite page renders a valid invitation with password requirements', async () => {
const handlers = {};
let rendered = null;
test('invitation acceptance endpoints are not registered', () => {
const routes = [];
const app = {
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
get(path) { routes.push('GET ' + path); },
post(path) { routes.push('POST ' + path); }
};
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
});
registerAuthRoutes(app, {});
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);
assert.equal(routes.includes('GET /accept-invite'), false);
assert.equal(routes.includes('POST /accept-invite'), false);
});
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.');
});
+142
View File
@@ -0,0 +1,142 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { hashPassword, encodePasswordRecord, verifyEncodedPassword, hashScryptPassword, verifyCredentialPassword } = require('../src/auth');
const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError } = require('../src/web/lib/auth/better-auth');
test('passkey RP ID uses localhost for local HTTP-IP configuration and the canonical hostname otherwise', () => {
assert.equal(resolvePasskeyRpID('http://192.168.0.80:8080'), 'localhost');
assert.equal(resolvePasskeyRpID('http://localhost:8080'), 'localhost');
assert.equal(resolvePasskeyRpID('https://signage.example.com'), 'signage.example.com');
});
test('passkey authentication is blocked for accounts with individual or role-required two-factor authentication', async () => {
const queries = [];
let account = { two_factor_enabled: 1, role_requires_two_factor: 0 };
class TestAPIError extends Error {
constructor(code, details) { super(details.message); this.code = code; }
}
const guard = createPasskeyAuthenticationGuard({
async query(sql, params) {
queries.push([String(sql), params]);
return [[account]];
}
}, TestAPIError);
await assert.rejects(guard({ clientData: { id: 'credential-id' } }), error => error.code === 'FORBIDDEN');
account = { two_factor_enabled: 0, role_requires_two_factor: 1 };
await assert.rejects(guard({ clientData: { id: 'credential-id' } }), error => error.code === 'FORBIDDEN');
account = { two_factor_enabled: 0, role_requires_two_factor: 0 };
await guard({ clientData: { id: 'credential-id' } });
assert.deepEqual(queries[0][1], ['credential-id']);
assert.match(queries[0][0], /r\.require_two_factor = 1/);
assert.equal(queries.length, 3);
});
test('required two-factor policy blocks disabling and restricts pending enrollment auth endpoints', () => {
const actor = { requiresTwoFactor: true, twoFactorEnabled: false };
assert.equal(getRoleTwoFactorAuthError(actor, '/api/auth/two-factor/disable').code, 'TWO_FACTOR_REQUIRED_BY_ROLE');
assert.equal(getRoleTwoFactorAuthError({ ...actor, twoFactorEnabled: true }, '/api/auth/two-factor/disable/').code, 'TWO_FACTOR_REQUIRED_BY_ROLE');
assert.equal(getRoleTwoFactorAuthError(actor, '/api/auth/change-email').code, 'TWO_FACTOR_ENROLLMENT_REQUIRED');
for (const endpoint of ['two-factor/enable', 'two-factor/get-totp-uri', 'two-factor/verify-totp', 'two-factor/generate-backup-codes', 'get-session', 'sign-out', 'verify-password']) {
assert.equal(getRoleTwoFactorAuthError(actor, '/api/auth/' + endpoint), null);
}
assert.equal(getRoleTwoFactorAuthError(null, '/api/auth/sign-in/username'), null);
assert.equal(getRoleTwoFactorAuthError({ requiresTwoFactor: false }, '/api/auth/two-factor/disable'), null);
assert.equal(getRoleTwoFactorAuthError({ ...actor, twoFactorEnabled: true }, '/api/auth/change-email'), null);
assert.equal(getRoleTwoFactorAuthError({ requiresTwoFactor: false, authenticatedUser: actor }, '/api/auth/two-factor/disable').code, 'TWO_FACTOR_REQUIRED_BY_ROLE');
});
test('successful sign-in clears only the client login and two-factor rate limits', async () => {
const queries = [];
const hook = createLoginRateLimitResetHook({ async query(sql, params) { queries.push({ sql, params }); } });
const paths = ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code'];
for (const path of paths) {
await hook({ path, context: { newSession: { session: { ipAddress: '192.0.2.1' } } } });
}
assert.equal(queries.length, paths.length);
for (const query of queries) {
assert.equal(query.sql, 'DELETE FROM a_login_attempts WHERE rate_key IN (?, ?, ?, ?)');
assert.deepEqual(query.params, paths.map(path => '192.0.2.1|' + path));
}
});
test('failed login, pending two-factor, and unrelated endpoints do not clear rate limits', async () => {
const hook = createLoginRateLimitResetHook({ async query() { assert.fail('Rate limits must remain intact'); } });
await hook({ path: '/sign-in/username', context: { newSession: null } });
await hook({ path: '/two-factor/verify-totp', context: { newSession: null } });
await hook({ path: '/email-otp/reset-password', context: { newSession: { session: { ipAddress: '192.0.2.1' } } } });
});
test('Better Auth password adapter verifies existing PBKDF2 records', () => {
const password = 'Strong-password-123!';
const encoded = encodePasswordRecord(hashPassword(password));
assert.equal(verifyEncodedPassword(password, encoded), true);
assert.equal(verifyEncodedPassword('incorrect-password', encoded), false);
});
test('Better Auth password adapter rejects unknown stored hash formats', () => {
assert.equal(verifyEncodedPassword('password', 'unknown$format'), false);
assert.equal(verifyEncodedPassword('password', 'app-user:7'), false);
});
test('Better Auth upgrades valid PBKDF2 passwords to its native scrypt hash on login', async () => {
const password = 'Strong-password-123!';
const legacyHash = encodePasswordRecord(hashPassword(password));
const updates = [];
const crypto = await import('better-auth/crypto');
const callbacks = createPasswordCallbacks({
async query(sql, params) {
updates.push([String(sql), params]);
return [{ affectedRows: 1 }];
}
}, crypto);
assert.equal(await callbacks.verify({ password: password, hash: legacyHash }), true);
assert.equal(updates.length, 1);
assert.match(updates[0][1][0], /^[a-f0-9]{32}:[a-f0-9]{128}$/);
assert.deepEqual(updates[0][1].slice(1), ['credential', legacyHash]);
assert.equal(await crypto.verifyPassword({ hash: updates[0][1][0], password: password }), true);
});
test('Better Auth does not upgrade an incorrect legacy password', async () => {
let updateCount = 0;
const crypto = await import('better-auth/crypto');
const callbacks = createPasswordCallbacks({
async query() { updateCount += 1; return [{ affectedRows: 1 }]; }
}, crypto);
const legacyHash = encodePasswordRecord(hashPassword('Correct-password-123!'));
assert.equal(await callbacks.verify({ password: 'incorrect-password', hash: legacyHash }), false);
assert.equal(updateCount, 0);
});
test('shared auth utility verifies Better Auth native scrypt hashes', async () => {
const password = 'Strong-password-123!';
const hash = await hashScryptPassword(password);
const crypto = await import('better-auth/crypto');
assert.equal(await crypto.verifyPassword({ hash: hash, password: password }), true);
assert.equal(await verifyCredentialPassword(password, hash), true);
assert.equal(await verifyCredentialPassword('incorrect-password', hash), false);
});
test('Better Auth session hook blocks locked users for every sign-in method', async () => {
let accountLocked = true;
const pool = {
async query(_sql, params) {
assert.deepEqual(params, [7]);
return [[{ account_locked: accountLocked }]];
}
};
class TestAPIError extends Error {
constructor(_status, body) {
super(body.message);
}
}
const lockHook = createAccountLockSessionHook(pool, TestAPIError);
await assert.rejects(lockHook({ userId: 7 }), /account is locked/i);
accountLocked = false;
await assert.doesNotReject(lockHook({ userId: 7 }));
});
+137
View File
@@ -0,0 +1,137 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerAuthRoutes = require('../src/web/routes/auth');
const registerAccountRoutes = require('../src/web/routes/admin/account');
function createApp() {
const handlers = {};
return {
handlers: handlers,
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
}
function settingsQuery(sql) {
return String(sql).includes('SELECT id, setting_key, setting_value FROM o_app_settings');
}
test('forgot password requests a Better Auth OTP for a verified account', async () => {
let requestedEmail = '';
let renderedResetPage = null;
const app = createApp();
const pool = {
async query(sql) {
if (settingsQuery(sql)) return [[{ setting_key: 'email.smtp_enabled', setting_value: true }]];
if (String(sql).includes('SELECT id, email, email_verified FROM a_users')) {
return [[{ id: 9, email: 'person@example.test', email_verified: true }]];
}
return [[]];
}
};
registerAuthRoutes(app, {
pool: pool,
pages: {
renderResetPasswordPage(message, email) {
renderedResetPage = { message: message, email: email };
return 'reset-page';
}
},
betterAuth: {
api: {
async requestPasswordResetEmailOTP({ body }) { requestedEmail = body.email; }
}
},
parseCookies() { return {}; },
hashSessionToken(value) { return value; },
sessionCookieName: 'session'
});
const response = { send(value) { this.body = value; return this; } };
await app.handlers['POST /forgot-password'][0]({ body: { identity: 'person' } }, response, (error) => { throw error; });
assert.equal(requestedEmail, 'person@example.test');
assert.equal(response.body, 'reset-page');
assert.equal(renderedResetPage.email, '');
assert.match(renderedResetPage.message, /reset code/);
});
test('password reset verifies the OTP through Better Auth', async () => {
let resetBody = null;
const app = createApp();
const pool = { async query(sql) { return settingsQuery(sql) ? [[]] : [[]]; } };
registerAuthRoutes(app, {
pool: pool,
pages: {
renderResetPasswordPage(message, email) { return message + ':' + email; },
renderForgotPasswordPage(message) { return message; }
},
betterAuth: {
api: {
async resetPasswordEmailOTP({ body }) { resetBody = body; }
}
},
validatePasswordStrength() { return ''; },
parseCookies() { return {}; },
hashSessionToken(value) { return value; },
sessionCookieName: 'session'
});
const response = { send(value) { this.body = value; return this; } };
await app.handlers['POST /reset-password'][0]({
body: { email: 'person@example.test', otp: '123456', password: 'NewPassword!123', confirm_password: 'NewPassword!123' }
}, response, (error) => { throw error; });
assert.deepEqual(resetBody, { email: 'person@example.test', otp: '123456', password: 'NewPassword!123' });
assert.match(response.body, /Password updated/);
});
test('pending email change verifies through Better Auth and syncs the app email', async () => {
const calls = [];
const app = createApp();
const pool = {
async query(sql, params) {
calls.push([String(sql), params]);
const text = String(sql);
if (settingsQuery(text)) return [[{ setting_key: 'email.smtp_enabled', setting_value: true }]];
if (text.includes('SELECT email, email_verified, pending_email FROM a_users')) {
return [[{ email: 'old@example.test', pending_email: 'new@example.test' }]];
}
if (text.includes('SELECT id, created_at, modified_at FROM a_users')) {
return [[{ id: 7, created_at: new Date(), modified_at: new Date() }]];
}
return [{ affectedRows: 1, insertId: 1 }];
}
};
let verifiedBody = null;
registerAccountRoutes(app, {
pool: pool,
common: {},
pages: {},
betterAuth: {
api: {
async changeEmailEmailOTP({ body, headers }) {
verifiedBody = { body: body, headers: headers };
}
}
},
fromNodeHeaders(headers) { return { cookie: headers.cookie }; },
parseCookies() { return {}; },
sessionCookieName: 'session'
});
const response = { redirect(url) { this.redirectedTo = url; } };
await app.handlers['POST /account/email/verify'][0]({
body: { otp: '654321' },
headers: { cookie: 'session=token' },
currentUser: { id: 7 }
}, response, (error) => { throw error; });
assert.deepEqual(verifiedBody, { body: { newEmail: 'new@example.test', otp: '654321' }, headers: { cookie: 'session=token' } });
assert.ok(calls.some(([sql, params]) => sql.includes('UPDATE a_users SET email = ?, email_verified = 1, pending_email = NULL') && params[0] === 'new@example.test'));
assert.match(response.redirectedTo, /Email%20address%20verified/);
});
+2 -4
View File
@@ -12,8 +12,7 @@ test('player control sync sends minimized authorization data to remote players o
async query() {
return [[{
username: 'operator',
password_hash: 'hash',
password_salt: 'salt'
password_hash: 'pbkdf2-sha256$310000$salt$hash'
}]];
}
},
@@ -44,8 +43,7 @@ test('player control sync sends minimized authorization data to remote players o
command: 'sync-local-control',
users: [{
username_hash: crypto.createHash('sha256').update('operator').digest('hex'),
password_hash: 'hash',
password_salt: 'salt'
password_hash: 'pbkdf2-sha256$310000$salt$hash'
}]
}
}]);
+11 -16
View File
@@ -9,7 +9,7 @@ const { WebSocket } = require('ws');
require('../src/common');
const { hashPassword } = require('../src/auth');
const { hashScryptPassword } = require('../src/auth');
const { createLocalControlService } = require('../src/player/local-control');
async function createTestService(options) {
@@ -52,15 +52,13 @@ test('local control leaves cached player media available to the static media rou
test('local control authenticates only cached eligible users and scopes commands to local runtime', async () => {
const { app, service, temporaryDirectory } = await createTestService();
const password = hashPassword('CorrectHorseBatteryStaple!');
const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
await service.saveUsers([{
id: 7,
name: 'Operator',
username: 'operator',
email: 'operator@example.com',
password_hash: password.hash,
password_salt: password.salt,
password_iterations: password.iterations
password_hash: password
}]);
await service.loadCache();
@@ -69,7 +67,8 @@ test('local control authenticates only cached eligible users and scopes commands
assert.equal(cachedUsers[0].name, undefined);
assert.equal(cachedUsers[0].email, undefined);
assert.equal(cachedUsers[0].username, undefined);
assert.equal(cachedUsers[0].password_iterations, undefined);
assert.equal(cachedUsers[0].password_hash, password);
assert.equal(cachedUsers[0].password_salt, undefined);
const server = app.listen(0);
try {
const address = server.address();
@@ -126,14 +125,12 @@ test('local control authenticates only cached eligible users and scopes commands
test('local control refuses login when the cached authorization is stale', async () => {
const { app, service, temporaryDirectory } = await createTestService({ cacheMaxAgeMs: 1 });
const password = hashPassword('CorrectHorseBatteryStaple!');
const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
await service.saveUsers([{
id: 7,
name: 'Operator',
username: 'operator',
password_hash: password.hash,
password_salt: password.salt,
password_iterations: password.iterations
password_hash: password
}]);
await service.loadCache();
await new Promise(function (resolve) { setTimeout(resolve, 5); });
@@ -155,11 +152,10 @@ test('local control refuses login when the cached authorization is stale', async
test('local control throttles repeated failed logins', async () => {
const { app, service, temporaryDirectory } = await createTestService({ loginRateLimitMaxAttempts: 2, loginRateLimitWindowMs: 60 * 1000 });
const password = hashPassword('CorrectHorseBatteryStaple!');
const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
await service.saveUsers([{
username: 'operator',
password_hash: password.hash,
password_salt: password.salt
password_hash: password
}]);
await service.loadCache();
@@ -186,11 +182,10 @@ test('local control throttles repeated failed logins', async () => {
test('local control closes live sockets when authorization is revoked', async () => {
const { server, service, temporaryDirectory } = await createTestService();
const password = hashPassword('CorrectHorseBatteryStaple!');
const password = await hashScryptPassword('CorrectHorseBatteryStaple!');
await service.saveUsers([{
username: 'operator',
password_hash: password.hash,
password_salt: password.salt
password_hash: password
}]);
await service.loadCache();
+62
View File
@@ -197,4 +197,66 @@ test('playlist create and update redirect to the canonical edit path', async ()
}, invalidRes, () => {});
assert.equal(invalidRes.statusCode, 400);
}
});
test('playlist audit compares persisted items and schedules without recreated row IDs', async () => {
let items = [{ id: 101, slide_id: 11, duration_seconds: 10 }];
let rules = [{ playlist_slide_id: 101, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }];
const events = [];
const connection = {
async commit() {}, async rollback() {}, release() {},
async query(sql, params) {
if (sql.includes('FROM c_playlist_slides WHERE playlist_id') && sql.startsWith('SELECT id, slide_id')) return [structuredClone(items)];
if (sql.includes('FROM c_playlist_slide_schedule_rules r')) return [structuredClone(rules)];
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }]];
if (sql.startsWith('DELETE FROM c_playlist_slides')) { items = []; rules = []; }
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
items = [{ id: 202, slide_id: params[1], duration_seconds: params[3] }];
return [{ insertId: 202 }];
}
if (sql.startsWith('INSERT INTO c_playlist_slide_schedule_rules')) {
rules.push({ playlist_slide_id: params[0], start_time: params[4] + ':00', end_time: params[5] + ':00', schedule_days_json: params[6] });
}
return [[]];
}
};
const { app, handlers } = createAppAndHandlers();
registerPlaylistRoutes(app, {
pool: { async getConnection() { return connection; } },
common: {
async fetchDuplicateName() { return null; },
async fetchPlaylistById() { return { id: 4, name: 'Loop', canvas_id: 7, fade_between_slides: 0, skip_unavailable_rtmp: 0 }; }
},
readArrayField(body, keys) {
for (const key of keys) {
if (Object.hasOwn(body, key)) return Array.isArray(body[key]) ? body[key] : [body[key]];
}
return [];
},
fetchScreensByPlaylistId: async () => [], getAuditUserId: () => 12,
recordRequestAuditEvent: async (_pool, _req, event) => { events.push(event); },
redirectAfterSave() {}, notifyPlayerScreens: async () => {}, broadcastDashboardState: async () => {},
requirePermission: () => () => {}
});
const body = {
name: 'Loop', slide_id: ['11'], row_key: ['row-11'], duration_seconds: ['10'],
schedule_rule_row_key: ['row-11'], schedule_rule_start_time: ['08:00'], schedule_rule_end_time: ['17:00'], schedule_rule_days_csv: ['1,2']
};
const save = async () => {
const response = createResponse();
await handlers['/playlists/:id'].at(-1)({ params: { id: '4' }, body }, response, error => { throw error; });
assert.equal(response.statusCode, 200);
return events.at(-1).details.changes;
};
assert.deepEqual(await save(), {});
body.duration_seconds = ['20'];
assert.deepEqual(await save(), { 'items[0].duration_seconds': { from: 10, to: 20 } });
body.schedule_rule_start_time = ['09:00'];
assert.deepEqual(await save(), { 'items[0].scheduleRules[0].start_time': { from: '08:00:00', to: '09:00:00' } });
body.slide_id = [];
body.row_key = [];
body.duration_seconds = [];
const removed = await save();
assert.deepEqual(removed['items[0].slide_id'], { from: 11, to: null });
assert.deepEqual(removed['items[0].scheduleRules[0].start_time'], { from: '09:00:00', to: null });
});
+71
View File
@@ -8,6 +8,77 @@ const rbacRoutesSource = fs.readFileSync(require.resolve('../src/web/routes/admi
const { buildPermissionSections, buildRbacEditViewModel } = require('../src/web/routes/settings/rbac/form-view-model');
const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8');
const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8');
for (const create of [true, false]) {
for (const enabled of [true, false]) {
test((create ? 'create' : 'update') + ' role persists two-factor requirement ' + enabled, async () => {
require('../src/common');
const registerRbacRoutes = require('../src/web/routes/admin/rbac');
const handlers = {};
const writes = [];
let auditEvent;
const connection = {
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
async query(sql, params) { writes.push({ sql, params }); return [{ insertId: 9 }]; }
};
registerRbacRoutes({
get() {},
post(path, ...routeHandlers) { handlers[path] = routeHandlers; }
}, {
pool: { async query() { return [[]]; }, async getConnection() { return connection; } },
common: {
validateMaxLength: value => value,
getSearchQuery: () => '',
getSortQuery: () => '',
getSortDirectionQuery: () => '',
fetchDuplicateName: async () => null
},
pages: {},
requirePermission: () => (_req, _res, next) => next(),
getAuditUserId: () => 3,
recordRequestAuditEvent: async (_pool, _req, event) => { auditEvent = event; },
readArrayField: () => [],
normalizePermissionKeys: value => value,
permissions: [],
rbacData: {
fetchRoleById: async () => ({ id: 9, name: 'Secure', description: '', require_two_factor: enabled ? 0 : 1 }),
fetchPermissions: async () => [],
fetchUsersWithRolesPage: async () => ({ users: [], currentPage: 1, totalItems: 0 }),
fetchUsersWithRoles: async () => [],
syncRoleUsers: async () => {}
}
});
const response = { redirect(url) { this.redirectedTo = url; } };
await handlers[create ? '/settings/roles' : '/settings/roles/:id'][1]({
params: { id: '9' }, query: {}, currentUser: { id: 3 },
body: { name: 'Secure', description: '', ...(enabled ? { require_two_factor: '1' } : {}) }
}, response, error => { throw error; });
assert.equal(writes.length, 1);
assert.match(writes[0].sql, /require_two_factor/);
assert.equal(writes[0].params[create ? 3 : 2], enabled ? 1 : 0);
assert.match(response.redirectedTo, /^\/settings\/roles\/9\/edit/);
if (create) assert.equal(auditEvent.details.requireTwoFactor, enabled);
else assert.deepEqual(auditEvent.details.changes.requireTwoFactor, { from: !enabled, to: enabled });
});
}
}
test('duplicating a role retains its two-factor requirement and the editor renders the toggle', () => {
require('../src/common');
const { buildDuplicateRole } = require('../src/web/routes/settings/rbac/duplicate');
const pages = require('../src/web/pages');
for (const enabled of [true, false]) {
const role = { id: 9, name: 'Secure', require_two_factor: enabled };
assert.equal(buildDuplicateRole(role).require_two_factor, enabled);
const html = pages.renderRbacEditPage(role, '', null, [], [], null);
const toggle = html.match(/<input id="role-require-two-factor"[^>]+>/)[0];
assert.equal(toggle.includes('checked'), enabled);
}
});
test('rbac form exposes bulk permission controls', () => {
assert.match(rbacFormTemplate, /name="permissions_present" value="1"/);
assert.match(rbacFormTemplate, /name="user_ids_present\[\]" value="\{\{id\}\}"/);
+4
View File
@@ -532,6 +532,9 @@ test('shared layout route is permission protected and updates existing rows with
assert.ok(calls.some(call => call[0] === 'thumbnail'));
assert.ok(calls.some(call => call[0] === 'notify'));
assert.ok(calls.some(call => call[0] === 'audit'));
const auditChanges = calls.find(call => call[0] === 'audit')[1].details.changes;
assert.deepEqual(auditChanges['regions.hero.x'], { from: 10, to: 120 });
assert.equal(auditChanges.regions, undefined);
const writes = calls.filter(call => /^UPDATE|DELETE|INSERT/.test(call[0]));
assert.equal(writes.length, 2);
assert.ok(writes.every(call => !/content_json|animation_json|DELETE|INSERT/.test(call[0])));
@@ -579,6 +582,7 @@ test('shared save clears only the current slide geometry and preserves saved con
const persisted = JSON.parse(writes[0][1][0]);
assert.deepEqual(persisted.hero, { ...savedContent.hero, layout: { animation_json: { loop: { preset: 'pulse' } } } });
assert.deepEqual(persisted.other, savedContent.other);
assert.equal(calls.find(call => call[0] === 'audit')[1].details.promotedSlideId, 42);
assert.deepEqual(writes[0][1].slice(2), [42, 7]);
assert.ok(calls.findIndex(call => call[0] === 'commit') > calls.findIndex(call => call === writes[0]));
});
+372
View File
@@ -5,6 +5,13 @@ require('../src/common');
const { getPendingMigrations } = require('../src/db/migrations');
async function getAuthMigrationStep(pool) {
const pending = await getPendingMigrations(pool, { currentVersion: '2.19.1' });
const migration = pending.find(item => item.version === '2.20.0');
assert.ok(migration, 'Expected the consolidated 2.20.0 migration');
return migration;
}
function createPool(responses) {
const queries = [];
return {
@@ -22,6 +29,41 @@ function createPool(responses) {
};
}
test('role two-factor migration adds an opt-in requirement for existing and fresh schemas', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_roles ADD COLUMN require_two_factor TINYINT(1) NOT NULL DEFAULT 0')));
const fs = require('node:fs');
assert.match(fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8'), /require_two_factor TINYINT\(1\) NOT NULL DEFAULT 0/);
});
test('passkey credential storage is defined once in the current schema snapshot', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
assert.equal(statements.some(sql => /CREATE TABLE IF NOT EXISTS a_(?:user_)?passkeys/.test(sql)), false);
assert.equal(statements.some(sql => /(?:RENAME TABLE a_passkeys|INSERT IGNORE INTO a_user_passkeys)/.test(sql)), false);
const fs = require('node:fs');
const schema = fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8');
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_passkeys/);
assert.match(schema, /credential_id VARCHAR\(512\) NOT NULL/);
});
test('the consolidated migration does not create and then rename user auth tables', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
assert.equal(statements.some(sql => /CREATE TABLE IF NOT EXISTS a_(?:two_factors|passkeys)/.test(sql)), false);
assert.equal(statements.some(sql => /(?:RENAME TABLE a_(?:two_factors|passkeys)|INSERT IGNORE INTO a_user_(?:two_factors|passkeys))/.test(sql)), false);
const fs = require('node:fs');
const schema = fs.readFileSync(require.resolve('../src/db/index.js'), 'utf8');
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_two_factors/);
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_user_passkeys/);
});
test('pending migrations are empty when the schema already matches the app version', async () => {
const pool = createPool([
{
@@ -49,6 +91,336 @@ test('pending migrations are empty when the schema already matches the app versi
assert.equal(pendingMigrations.length, 0);
});
test('2.20.1 release follows the consolidated 2.20.0 migration', async () => {
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.19.1' });
assert.deepEqual(pending.map(migration => migration.version), ['2.20.0', '2.20.1']);
});
test('2.20.1 removes retired security setting rows', async () => {
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.20.0' });
const migration = pending.find(item => item.version === '2.20.1');
assert.ok(migration, 'Expected the 2.20.1 security settings cleanup migration');
const pool = createPool([]);
await migration.run(pool);
assert.deepEqual(pool.queries, [[
'DELETE FROM o_app_settings WHERE setting_key IN (?, ?, ?, ?, ?)',
[
'security.session_lifetime_days',
'security.max_active_sessions',
'security.login_max_attempts',
'security.login_lockout_minutes',
'security.login_rate_limit_scope'
]
]]);
});
test('Better Auth migration extends app users and sessions with auth account tables', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && params && params[0] === 'a_users' && params[1] === 'email_verified_at';
},
result: [[{ column_count: 1 }]]
},
{
match(sql) {
return sql.includes('SELECT id, email, email_verified_at FROM a_users');
},
result: [[
{ id: 1, email: 'first@example.com', email_verified_at: new Date() },
{ id: 2, email: 'second@example.com', email_verified_at: new Date() },
{ id: 3, email: 'verified@example.com', email_verified_at: new Date() },
{ id: 4, email: 'pending@example.com', email_verified_at: null }
]]
}
]);
const authMigration = await getAuthMigrationStep(pool);
await authMigration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
const schema = require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8');
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_accounts/);
assert.match(schema, /CREATE TABLE IF NOT EXISTS a_verifications/);
assert.equal(statements.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS a_accounts')), false);
assert.equal(statements.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS a_verifications')), false);
assert.ok(statements.some(sql => sql.includes('ADD COLUMN email_verified')));
assert.ok(statements.some(sql => sql.includes('ADD COLUMN last_request')));
assert.ok(statements.includes('DELETE FROM a_sessions'));
assert.equal(statements.some(sql => sql.includes('CREATE TRIGGER')), false);
const emailUpdates = pool.queries
.filter(([sql]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?'))
.map(([, params]) => params);
assert.deepEqual(emailUpdates, [
['first@example.com', true, 1],
['second@example.com', true, 2],
['verified@example.com', true, 3],
['pending@example.com', false, 4]
]);
assert.equal(statements.some(sql => sql.includes('auth_email')), false);
assert.equal(statements.some(sql => sql.includes('uq_a_users_auth_email')), false);
assert.equal(statements.some(sql => sql.includes('app-user:%')), false);
assert.doesNotMatch(schema, /auth_email/);
});
test('consolidated Better Auth migration removes legacy password columns after credential migration', async () => {
const pool = createPool([
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] }
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_users DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations')));
assert.equal(pool.queries.some(([sql]) => String(sql).includes('MODIFY COLUMN password_hash CHAR(64) NULL')), false);
});
test('Better Auth passwordless migration tolerates fresh schemas without legacy password columns', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users';
},
result: [[{ column_count: 0 }]]
},
{ match(sql) { return sql.includes('information_schema.COLUMNS'); }, result: [[{ column_count: 1 }]] }
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.equal(pool.queries.some(([sql]) => String(sql).includes('MODIFY COLUMN password_hash')), false);
});
test('Email OTP migration removes obsolete link and lockout storage only', async () => {
const pool = createPool([
{
match(sql) { return sql.includes('information_schema.COLUMNS'); },
result: [[{ column_count: 1 }]]
},
{
match(_sql, params) { return Array.isArray(params) && params[0] === 'a_account_tokens'; },
result: [[{ table_count: 1 }]]
},
{
match(sql) { return sql.includes('SELECT LOWER(TRIM(email)) AS email_key'); },
result: [[]]
},
{
match() { return true; },
result: [[{ table_count: 0 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN pending_email_token_hash')));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN pending_email_expires_at')));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_login_at')));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_login_ip')));
assert.ok(statements.some(sql => sql.includes('DROP COLUMN last_failed_at')));
assert.ok(statements.some(sql => sql.includes('DROP TABLE a_account_tokens')));
assert.ok(statements.some(sql => sql.includes("DELETE FROM o_app_settings WHERE setting_key IN ('email.verification_button_alignment'")));
assert.ok(!statements.some(sql => sql.includes('DROP TABLE a_verifications')));
});
test('follow-up migration removes unused admin last-login fields', async () => {
const pool = createPool([
{
match(sql) { return sql.includes('information_schema.COLUMNS'); },
result: [[{ column_count: 1 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const alterations = pool.queries.map(([sql]) => String(sql));
assert.ok(alterations.includes('ALTER TABLE a_users DROP COLUMN last_login_at'));
assert.ok(alterations.includes('ALTER TABLE a_users DROP COLUMN last_login_ip'));
});
test('OTP expiry migration consolidates old settings and removes unused session creator', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('SELECT setting_key, setting_value FROM o_app_settings') && params[0] === 'email.otp_expiry_minutes';
},
result: [[
{ setting_key: 'email.verification_expiry_minutes', setting_value: 8 },
{ setting_key: 'email.reset_expiry_minutes', setting_value: 15 }
]]
},
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && params[0] === 'a_sessions' && params[1] === 'created_by';
},
result: [[{ column_count: 1 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO o_app_settings') && params[0] === 'email.otp_expiry_minutes' && params[1] === '8'));
assert.ok(pool.queries.some(([sql]) => String(sql).includes('DELETE FROM o_app_settings WHERE setting_key IN (?, ?)')));
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE a_sessions DROP COLUMN created_by')));
});
test('per-user password policy migrations remove both old global settings', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const deletedSettingKeys = pool.queries.filter(([sql]) => String(sql).includes('DELETE FROM o_app_settings WHERE setting_key = ?')).map(([, params]) => params[0]);
assert.ok(deletedSettingKeys.includes('security.require_password_change_after_admin_reset'));
assert.ok(deletedSettingKeys.includes('security.require_password_change_for_new_users'));
});
test('canonical email migration backfills synthetic addresses and drops duplicate email fields', async () => {
const pool = createPool([
{
match(sql) { return sql.includes('information_schema.COLUMNS'); },
result: [[{ column_count: 1 }]]
},
{
match(sql) {
return sql.includes('SELECT LOWER(TRIM(email)) AS email_key');
},
result: [[]]
},
{
match(sql, params) {
return sql.includes('SELECT id, email, email_verified_at FROM a_users') && params === undefined;
},
result: [[
{ id: 5, email: null, email_verified_at: null },
{ id: 6, email: 'verified@example.test', email_verified_at: new Date() }
]]
},
{
match(sql) { return sql.includes('information_schema.STATISTICS'); },
result: [[{ index_count: 0 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const queries = pool.queries;
assert.ok(queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'app-user-5@identity.invalid' && params[1] === false));
assert.ok(queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'verified@example.test' && params[1] === true));
assert.equal(queries.some(([sql]) => String(sql).includes('auth_email')), false);
assert.ok(queries.some(([sql]) => String(sql).includes('ALTER TABLE a_users DROP COLUMN email_verified_at')));
});
test('canonical email migration refuses duplicate real email addresses', async () => {
const pool = createPool([
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] },
{
match: sql => sql.includes('SELECT LOWER(TRIM(email)) AS email_key'),
result: [[{ email_key: 'duplicate@example.test', duplicate_count: 2 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await assert.rejects(migration.run(pool), /duplicate user email addresses/);
});
test('canonical email migration preserves email_verified on fresh schemas', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users' && params[1] === 'email_verified_at';
},
result: [[{ column_count: 0 }]]
},
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users' && params[1] === 'auth_email';
},
result: [[{ column_count: 0 }]]
},
{
match(sql) { return sql.includes('information_schema.COLUMNS'); },
result: [[{ column_count: 1 }]]
},
{
match(sql) { return sql.includes('SELECT LOWER(TRIM(email)) AS email_key'); },
result: [[]]
},
{
match(sql) { return sql.includes('SELECT id, email, email_verified FROM a_users'); },
result: [[{ id: 9, email: 'canonical@example.test', email_verified: 1 }]]
},
{
match(sql, params) { return sql.includes('information_schema.STATISTICS') && Array.isArray(params) && params[1] === 'uq_a_users_auth_email'; },
result: [[{ index_count: 0 }]]
},
{
match(sql) { return sql.includes("COLUMN_NAME = 'email'"); },
result: [[{ index_count: 1 }]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('UPDATE a_users SET email = ?, email_verified = ?') && params[0] === 'canonical@example.test' && params[1] === true));
assert.equal(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN email_verified_at')), false);
});
test('credential migration moves existing password records into Better Auth accounts', async () => {
const passwordHash = 'a'.repeat(64);
const passwordSalt = 'b'.repeat(32);
const pool = createPool([
{ match: sql => sql.includes('information_schema.COLUMNS'), result: [[{ column_count: 1 }]] },
{
match: sql => sql.includes('SELECT id, password_hash, password_salt, password_iterations, created_at, modified_at FROM a_users'),
result: [[
{ id: 12, password_hash: passwordHash, password_salt: passwordSalt, password_iterations: 310000, created_at: new Date(), modified_at: new Date() },
{ id: 13, password_hash: null, password_salt: null, password_iterations: null, created_at: new Date(), modified_at: new Date() }
]]
}
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO a_accounts') && params[3] === 'pbkdf2-sha256$310000$' + passwordSalt + '$' + passwordHash));
assert.equal(pool.queries.some(([sql, params]) => String(sql).includes('INSERT INTO a_accounts') && params[2] === 13), false);
assert.ok(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN password_hash, DROP COLUMN password_salt, DROP COLUMN password_iterations')));
});
test('credential migration leaves fresh Better Auth schemas without legacy password columns intact', async () => {
const pool = createPool([
{
match(sql, params) {
return sql.includes('information_schema.COLUMNS') && Array.isArray(params) && params[0] === 'a_users';
},
result: [[{ column_count: 0 }]]
},
{ match(sql) { return sql.includes('information_schema.COLUMNS'); }, result: [[{ column_count: 1 }]] }
]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.equal(pool.queries.some(([sql]) => String(sql).includes('SELECT id, password_hash, password_salt')), false);
assert.equal(pool.queries.some(([sql]) => String(sql).includes('DROP COLUMN password_hash')), false);
});
test('invitation removal migration drops the legacy table, permissions, and settings', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
const statements = pool.queries.map(([sql]) => String(sql));
assert.ok(statements.includes('DROP TABLE IF EXISTS a_user_invitations'));
assert.ok(statements.some(sql => sql.includes('DELETE FROM a_role_permissions') && sql.includes("permission_key LIKE 'invitations.%'")));
assert.ok(statements.some(sql => sql.includes("DELETE FROM a_permissions WHERE permission_key LIKE 'invitations.%'")));
assert.ok(statements.some(sql => sql.includes("DELETE FROM o_app_settings WHERE setting_key LIKE 'email.invitation_%'")));
});
test('Better Auth two-factor migration adds user state while the table comes from the schema snapshot', async () => {
const pool = createPool([]);
const migration = await getAuthMigrationStep(pool);
await migration.run(pool);
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ADD COLUMN two_factor_enabled')));
assert.equal(pool.queries.some(([sql]) => String(sql).includes('CREATE TABLE IF NOT EXISTS a_two_factors')), false);
assert.match(require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8'), /CREATE TABLE IF NOT EXISTS a_user_two_factors/);
});
test('query index migration upgrades a 2.17.4 installation', async () => {
const queries = [];
const pool = {
+110 -7
View File
@@ -6,6 +6,70 @@ require('../src/common');
const registerSettingsRoutes = require('../src/web/routes/settings/routes');
const { ANNOUNCEMENT_ICON_CATALOG } = require('../src/data/announcement-icons');
for (const failReset of [false, true]) {
test('global login lockout reset ' + (failReset ? 'forwards failures' : 'uses its own permission and audits the action'), async () => {
const handlers = {};
const permissions = [];
const queries = [];
const resetError = new Error('Reset failed');
let auditEvent;
let forwardedError;
registerSettingsRoutes({
get() {},
post(path, ...routeHandlers) { handlers[path] = routeHandlers; }
}, {
pages: {},
pool: {
async query(sql, params) {
queries.push({ sql, params });
if (failReset) throw resetError;
return [{ affectedRows: 3 }];
}
},
requirePermission(permission) {
const middleware = (_req, _res, next) => next();
permissions.push({ permission, middleware });
return middleware;
},
recordRequestAuditEvent: async (_pool, _req, event) => { auditEvent = event; }
});
const route = handlers['/settings/system/login-lockouts/reset'];
assert.equal(permissions.find(entry => entry.middleware === route[0]).permission, 'login-lockouts.allow');
const response = { redirect(url) { this.redirectedTo = url; } };
await route[1]({ currentUser: { id: 3 } }, response, error => { forwardedError = error; });
assert.deepEqual(queries, [{
sql: 'DELETE FROM a_login_attempts WHERE rate_key LIKE ? OR rate_key LIKE ?',
params: ['%|/sign-in/%', '%|/two-factor/verify-%']
}]);
if (failReset) {
assert.equal(forwardedError, resetError);
assert.equal(auditEvent, undefined);
assert.equal(response.redirectedTo, undefined);
} else {
assert.equal(auditEvent.eventType, 'system-settings.login_lockouts_reset');
assert.equal(auditEvent.actorUserId, 3);
assert.equal(response.redirectedTo, '/settings/system?message=Sign-in%20and%20two-factor%20request%20rate%20limits%20reset%20for%20all%20clients.#security-sessions');
}
});
}
test('login lockout reset permission is separate from settings update in the UI', () => {
const { renderView } = require('../src/web/view');
const { PERMISSIONS } = require('../src/rbac');
assert.ok(PERMISSIONS.some(permission => permission.key === 'login-lockouts.allow'));
for (const permissionKeys of [['system-settings.read'], ['system-settings.read', 'system-settings.update'], ['system-settings.read', 'login-lockouts.allow']]) {
const html = renderView('settings/system/index', { currentUser: { id: 3, permissionKeys }, mediaSettings: {} });
const canReset = permissionKeys.includes('login-lockouts.allow');
assert.equal(html.includes('id="reset-login-lockouts-form"'), canReset);
assert.equal(html.includes('form="reset-login-lockouts-form"'), canReset);
if (canReset) assert.match(html, /data-confirm-message="Clear sign-in and two-factor request rate limits for all clients\?/);
assert.equal(html.includes('name="session_lifetime_days"'), false);
assert.equal(html.includes('name="max_active_sessions"'), false);
assert.equal(html.includes('name="login_max_attempts"'), false);
assert.equal(html.includes('name="allow_admin_email_verification_bypass"'), true);
}
});
test('settings route renders the overview', async () => {
const handlers = {};
const queries = [];
@@ -31,7 +95,7 @@ test('settings route renders the overview', async () => {
}
},
requirePermission(permissionKey) {
assert.ok(['system-settings.read', 'system-settings.update'].includes(permissionKey));
assert.ok(['system-settings.read', 'system-settings.update', 'login-lockouts.allow'].includes(permissionKey));
return function (_req, _res, next) {
next();
};
@@ -48,7 +112,7 @@ test('settings route renders the overview', async () => {
});
assert.equal(pageResponse.body, 'settings-page');
assert.equal(Object.keys(handlers).length, 2);
assert.equal(Object.keys(handlers).length, 3);
assert.ok(handlers['/settings/system']);
assert.ok(handlers['POST /settings/system']);
@@ -70,7 +134,7 @@ test('settings route renders the overview', async () => {
return entry.sql && /DELETE FROM o_app_settings/.test(entry.sql);
}).forEach(function (entry) {
assert.equal((entry.sql.match(/\?/g) || []).length, entry.params.length);
assert.ok(entry.params.includes('email.invitation_body'));
assert.equal(entry.params.includes('email.invitation_body'), false);
});
assert.match(saveResponse.redirectedTo, /^\/settings\/system\?message=/);
@@ -103,18 +167,21 @@ test('settings route renders the overview', async () => {
}
};
await handlers['POST /settings/system'][1]({
body: { settings_section: 'security', session_lifetime_days: '30', allow_user_session_revocation: '1', max_active_sessions: '5', login_max_attempts: '5', login_lockout_minutes: '15', login_rate_limit_scope: 'both', password_min_length: '12', password_min_categories: '3' },
body: { settings_section: 'security', allow_user_session_revocation: '1', password_min_length: '12', password_min_categories: '3', allow_admin_email_verification_bypass: '1' },
currentUser: { id: 7 }
}, securityResponse, function (error) {
throw error;
});
assert.match(securityResponse.redirectedTo, /security-sessions/);
assert.ok(queries.some(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && entry.params[0] === 'security.session_lifetime_days';
}));
assert.ok(queries.some(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && entry.params[0] === 'security.password_min_length';
}));
assert.ok(queries.some(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && entry.params[0] === 'security.allow_admin_email_verification_bypass' && entry.params[1] === 'true';
}));
assert.equal(queries.some(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && ['security.session_lifetime_days', 'security.max_active_sessions', 'security.login_max_attempts', 'security.login_lockout_minutes', 'security.login_rate_limit_scope'].includes(entry.params[0]);
}), false);
const mediaInsertQueries = queries.filter(function (entry) {
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings');
@@ -178,3 +245,39 @@ test('email settings can send a test email to the logged-in user', async () => {
assert.equal(sentMessages[0].message.subject, 'Pulse Signage SMTP test email');
assert.match(response.redirectedTo, /Test%20email%20sent%20to%20admin%40example\.test/);
});
test('email settings save one shared OTP expiry value', async () => {
const handlers = {};
const queries = [];
const app = {
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
};
registerSettingsRoutes(app, {
pages: {},
pool: {
async query(sql, params) {
queries.push([String(sql), params]);
if (String(sql).includes('SELECT id, setting_key, setting_value FROM o_app_settings')) return [[]];
return [{ affectedRows: 1 }];
}
},
requirePermission() { return function (_req, _res, next) { next(); }; }
});
const response = { redirect(url) { this.redirectedTo = url; } };
await handlers['POST /settings/system'][1]({
body: {
settings_section: 'email',
smtp_enabled: '0',
smtp_port: '587',
smtp_security: 'starttls',
otp_expiry_minutes: '12',
},
currentUser: { id: 7 }
}, response, (error) => { throw error; });
assert.ok(queries.some(([sql, params]) => sql.includes('INSERT INTO o_app_settings') && params[0] === 'email.otp_expiry_minutes' && params[1] === '12'));
assert.ok(!queries.some(([, params]) => Array.isArray(params) && ['email.verification_expiry_minutes', 'email.reset_expiry_minutes'].includes(params[0])));
assert.match(response.redirectedTo, /#email-delivery$/);
});
+2 -2
View File
@@ -227,7 +227,7 @@ test('2.19.1 migration rebuilds only managed font references for all content in
]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.19.0' });
assert.deepEqual(pending.map(migration => migration.version), ['2.19.1']);
assert.deepEqual(pending.map(migration => migration.version), ['2.19.1', '2.20.0', '2.20.1']);
await pending[0].run(pool);
const deletions = pool.queries.filter(([sql]) => sql.startsWith('DELETE'));
@@ -243,7 +243,7 @@ test('2.19.1 migration rebuilds only managed font references for all content in
assert.ok(pool.queries.some(([sql, params]) => sql.startsWith('SELECT id, template_id, region_key') && params[0] === 900));
assert.ok(!pool.queries.some(([sql]) => /^(UPDATE|ALTER|TRUNCATE)/.test(sql)));
assert.deepEqual(pool.queries.slice(-1), [['COMMIT']]);
assert.deepEqual(await getPendingMigrations(pool, { currentVersion: '2.19.1' }), []);
assert.deepEqual((await getPendingMigrations(pool, { currentVersion: '2.19.1' })).map(migration => migration.version), ['2.20.0', '2.20.1']);
});
test('2.19.1 font repair rolls back without releasing the caller-owned connection on failure', async () => {
+77
View File
@@ -2,7 +2,10 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
require('../src/common');
const { buildUsersEditViewModel, buildUsersAddViewModel } = require('../src/web/routes/settings/users/form-view-model');
const rbacData = require('../src/web/lib/auth/rbac-data');
const usersFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/users/form.hbs'), 'utf8');
test('user edit view model includes delete confirmation text', () => {
@@ -35,4 +38,78 @@ test('user form keeps username editable in the admin section', () => {
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=/);
});
test('user edit view model carries Better Auth sign-in methods without provider account ids', async () => {
const pool = {
async query(sql) {
if (String(sql).includes('FROM a_users u')) {
return [[{ id: 12, name: 'User', username: 'user', email: 'user@example.test', email_verified: 1, two_factor_enabled: 0, role_ids_csv: '' }]];
}
if (String(sql).includes('FROM a_accounts')) {
return [[{ provider_id: 'credential', account_id: '12' }, { provider_id: 'google', account_id: 'google-subject' }]];
}
if (String(sql).includes('FROM a_user_passkeys')) return [[{ id: 'passkey-1', name: 'Work laptop', created_at: new Date() }]];
return [[]];
}
};
const user = await rbacData.fetchUserWithRoles(pool, 12);
const model = buildUsersEditViewModel(user, '', { id: 1 }, []);
assert.deepEqual(model.authMethods, [
{ providerId: 'credential', label: 'Password', isCredential: true },
{ providerId: 'google', label: 'Google', isCredential: false }
]);
assert.equal(model.user.twoFactorEnabled, false);
assert.deepEqual(model.passkeys, [{ id: 'passkey-1', name: 'Work laptop', created_at: model.passkeys[0].created_at }]);
assert.equal(model.user.canUnlinkPasskeys, true);
assert.doesNotMatch(JSON.stringify(model.authMethods), /google-subject/);
assert.ok(usersFormTemplate.includes('Sign-in methods'));
assert.ok(usersFormTemplate.includes('{{#if isEdit}}'));
assert.doesNotMatch(usersFormTemplate, /Linked SSO or OAuth accounts will appear here when configured\./);
assert.ok(usersFormTemplate.includes('user.passkeys'));
assert.ok(usersFormTemplate.includes('data-admin-passkey-group'));
assert.ok(usersFormTemplate.includes('data-admin-passkey-list'));
assert.ok(usersFormTemplate.includes('bi-arrow-return-right'));
assert.ok(usersFormTemplate.includes('/passkeys/{{id}}/unlink'));
assert.ok(usersFormTemplate.includes('list-group-item d-flex align-items-start gap-3 px-0'));
});
test('password-only users still see the available two-factor option', () => {
const model = buildUsersEditViewModel({
id: 12,
inUse: false,
roleIds: [],
twoFactorEnabled: false,
authMethods: [{ providerId: 'credential', label: 'Password' }]
}, '', { id: 1 }, []);
assert.equal(model.user.twoFactorEnabled, false);
assert.ok(usersFormTemplate.includes('Two-step verification'));
assert.ok(usersFormTemplate.includes('Not enabled'));
});
test('admin edit shows two-factor status as an additional sign-in method', async () => {
const pool = {
async query(sql) {
if (String(sql).includes('FROM a_users u')) {
return [[{ id: 13, name: 'User', username: 'user', email: 'user@example.test', email_verified: 1, two_factor_enabled: 1, role_ids_csv: '' }]];
}
if (String(sql).includes('FROM a_accounts')) return [[{ provider_id: 'credential', account_id: '13' }]];
return [[]];
}
};
const user = await rbacData.fetchUserWithRoles(pool, 13);
const model = buildUsersEditViewModel(user, '', { id: 1 }, []);
assert.equal(model.user.twoFactorEnabled, true);
assert.ok(usersFormTemplate.includes('Enabled'));
});
test('user edit view model withholds passkey unlink from the account owner', () => {
const user = { id: 12, passkeys: [{ id: 'passkey-1', name: 'Work laptop' }] };
const model = buildUsersEditViewModel(user, '', { id: 12 }, []);
assert.equal(model.user.canUnlinkPasskeys, false);
assert.equal(model.passkeys.length, 1);
});
+39
View File
@@ -4,6 +4,45 @@ const path = require('path');
const registerMiddleware = require('../src/web/middleware');
test('role-required two-factor enrollment gates pages and APIs while allowing setup 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 gate = app.middlewares[app.middlewares.length - 1][0];
const actor = { id: 7, requiresTwoFactor: true, twoFactorEnabled: false };
for (const requestPath of ['/dashboard', '/slides/12', '/account/profile', '/api/clients']) {
let passed = false;
const res = {
redirect(url) { this.redirectedTo = url; },
status(code) { this.statusCode = code; return this; },
json(body) { this.body = body; }
};
gate({ method: 'GET', path: requestPath, currentUser: actor, authenticatedUser: actor }, res, () => { passed = true; });
assert.equal(passed, false);
if (requestPath.startsWith('/api/')) {
assert.equal(res.statusCode, 403);
assert.equal(res.body.code, 'TWO_FACTOR_ENROLLMENT_REQUIRED');
} else assert.equal(res.redirectedTo, '/account');
}
for (const requestPath of ['/account', '/logout', '/account/password']) {
let passed = false;
gate({ method: 'GET', path: requestPath, currentUser: actor, authenticatedUser: actor }, {}, () => { passed = true; });
assert.equal(passed, true);
}
for (const actorUser of [{ ...actor, twoFactorEnabled: true }, { ...actor, requiresTwoFactor: false }]) {
let passed = false;
gate({ method: 'GET', path: '/dashboard', currentUser: { id: 19, requiresTwoFactor: true, twoFactorEnabled: false }, authenticatedUser: actorUser }, {}, () => { passed = true; });
assert.equal(passed, true);
}
});
test('web middleware bypasses auth for internal player sync routes', () => {
const calls = [];
const app = {