Release v2.20.1: migrate authentication to Better Auth
This commit is contained in:
1 parent
e9a53f577c
commit
fba4f99ff7
92 files changed
+5085
-1943
No files matched your search
@@ -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)\]\]/);
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.');
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}]
|
||||
}
|
||||
}]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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\}\}"/);
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
|
||||
@@ -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
@@ -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$/);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user