Release v2.20.1: migrate authentication to Better Auth

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

No files matched your search

+258 -10
View File
@@ -27,6 +27,197 @@ test('required-password page shows only password controls and uses an inline war
assert.doesNotMatch(html, /id="app-toast"/);
});
test('account page presents Better Auth identity, credential, and session controls', () => {
const html = pages.renderAccountPage({
id: 5,
username: 'demo',
name: 'Demo',
email: 'demo@example.test',
email_verified: true
}, '', '/dashboard', true, [], {
minimumLength: 10,
minimumCategories: 3,
requireLowercase: false,
requireUppercase: false,
requireNumber: false,
requireSymbol: false
});
assert.match(html, /Profile and email/);
assert.match(html, /Verified/);
assert.match(html, /Active sessions/);
assert.match(html, /Change password/);
assert.equal((html.match(/name="current_password"/g) || []).length, 2);
assert.doesNotMatch(html, /Confirmation|data-account-current-password|type="hidden" name="current_password"/);
});
test('two-factor account controls show setup or management only for the authenticated user', () => {
const requirements = { minimumLength: 10, minimumCategories: 3 };
const render = user => pages.renderAccountPage(user, '', '/dashboard', false, [], requirements);
const setupHtml = render({ id: 5, username: 'demo', twoFactorEnabled: false });
const enabledHtml = render({ id: 5, username: 'demo', twoFactorEnabled: true });
const viewAsHtml = render({ id: 8, username: 'preview', twoFactorEnabled: false, isViewingAs: true });
assert.match(setupHtml, /data-two-factor-enable/);
assert.match(setupHtml, /data-two-factor-setup hidden/);
assert.doesNotMatch(setupHtml, /optional/i);
assert.match(setupHtml, /id="two-factor-setup-modal"/);
assert.match(setupHtml, /class="card-body py-0"[\s\S]*class="list-group list-group-flush"/);
assert.match(setupHtml, /list-group-item d-flex align-items-center justify-content-between gap-3 px-0/);
assert.match(setupHtml, /data-two-factor-download/);
assert.match(setupHtml, /aria-label="Download recovery codes"/);
assert.doesNotMatch(setupHtml, /modal-body[^]*?<hr>/);
assert.doesNotMatch(setupHtml, /data-two-factor-disable/);
assert.match(enabledHtml, /data-two-factor-disable/);
assert.match(enabledHtml, /data-two-factor-regenerate-codes/);
assert.match(enabledHtml, /id="two-factor-manage-modal"/);
assert.match(enabledHtml, /aria-label="Download recovery codes"/);
assert.doesNotMatch(enabledHtml, /Once enabled, two-step verification is required at password sign-in\./);
assert.match(enabledHtml, /data-bs-target="#two-factor-manage-modal"/);
assert.match(enabledHtml, /id="two-factor-manage-modal"/);
assert.doesNotMatch(viewAsHtml, /Two-step verification|data-two-factor-enable/);
});
test('passkey management is account-scoped and hidden while TOTP policy requires password sign-in', () => {
const requirements = { minimumLength: 10, minimumCategories: 3 };
const render = (user, passkeys) => pages.renderAccountPage(user, '', '/dashboard', false, [], requirements, [], passkeys);
const available = render({ id: 5, username: 'demo', twoFactorEnabled: false }, [{ id: 'pk-1', name: 'Laptop' }]);
const protectedAccount = render({ id: 5, username: 'demo', twoFactorEnabled: true }, [{ id: 'pk-1', name: 'Laptop' }]);
const viewAs = render({ id: 8, username: 'preview', isViewingAs: true }, []);
assert.match(available, /data-passkey-add/);
assert.match(available, /id="passkey-setup-modal"/);
assert.match(available, /data-passkey-add-trigger[^>]*data-bs-toggle="modal"[^>]*data-bs-target="#passkey-setup-modal"/);
assert.doesNotMatch(available, /optional/i);
assert.match(available, /data-passkey-group[\s\S]*?data-passkey-list/);
assert.match(available, /data-passkey-list[\s\S]*?data-passkey-delete="pk-1"/);
assert.match(available, /<li class="list-group-item d-flex align-items-center justify-content-between gap-3 border-0 px-0 py-1 lh-sm">[\s\S]*Laptop/);
assert.match(available, /bi-arrow-return-right[^>]*aria-hidden="true"/);
assert.match(available, /Laptop/);
assert.match(available, /data-passkey-delete="pk-1"/);
assert.match(available, /js\/account\/passkeys\.js/);
assert.doesNotMatch(protectedAccount, /data-passkey-add/);
assert.match(protectedAccount, /Unavailable while authenticator verification is enabled/);
assert.doesNotMatch(viewAs, /data-passkey-add|data-passkey-delete/);
});
test('role-required two-factor enrollment is visible and cannot be unlinked from My account', () => {
const render = twoFactorEnabled => pages.renderAccountPage({ id: 5, username: 'demo', requiresTwoFactor: true, twoFactorEnabled }, '', '/dashboard', false, []);
const pending = render(false);
assert.match(pending, /Required by role/);
assert.match(pending, /Set up an authenticator to continue/);
assert.match(pending, /data-two-factor-enable/);
const enabled = render(true);
assert.doesNotMatch(enabled, /data-two-factor-disable/);
assert.match(enabled, /data-two-factor-regenerate-codes/);
assert.match(enabled, />Manage<\/button>/);
});
test('account email status and verification action stay inline with the address field', () => {
const requirements = {
minimumLength: 10,
minimumCategories: 3,
requireLowercase: false,
requireUppercase: false,
requireNumber: false,
requireSymbol: false
};
const render = user => pages.renderAccountPage(user, '', '/dashboard', false, [], requirements);
const verifiedHtml = render({ username: 'demo', name: 'Demo', email: 'demo@example.test', email_verified: true });
assert.match(verifiedHtml, /<label for="account-email"[^>]*>[\s\S]*<span class="small text-success">Verified<\/span>/);
assert.doesNotMatch(verifiedHtml, /badge|Send code|Resend code/);
const unverifiedHtml = render({ username: 'demo', name: 'Demo', email: 'demo@example.test', email_verified: false });
assert.match(unverifiedHtml, /<div class="input-group">[\s\S]*name="email"[\s\S]*form="account-email-verification-form">Send code/);
assert.match(unverifiedHtml, /action="\/account\/email\/send-verification"/);
const pendingHtml = render({ username: 'demo', name: 'Demo', email: 'demo@example.test', pending_email: 'new@example.test' });
assert.match(pendingHtml, /Change pending/);
assert.match(pendingHtml, /form="account-email-verification-form">Resend code/);
assert.match(pendingHtml, /action="\/account\/email"/);
assert.match(pendingHtml, /name="otp"/);
assert.doesNotMatch(pendingHtml, /Cancel change|Cancel verification|\/account\/email\/cancel/);
});
test('account page clears pending_email when its Better Auth OTP is missing or expired', async () => {
const handlers = {};
const queries = [];
let renderedUser = null;
const app = {
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
post() {}
};
const pool = {
async query(sql, params) {
const text = String(sql);
queries.push([text, params]);
if (text.includes('SELECT id, setting_key, setting_value FROM o_app_settings')) {
return [[{ id: 1, setting_key: 'security.allow_user_session_revocation', setting_value: 'false' }]];
}
if (text.includes('UPDATE a_users') && text.includes('NOT EXISTS')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
};
registerAccountRoutes(app, {
pool: pool,
common: {},
pages: {
renderAccountPage(user) { renderedUser = Object.assign({}, user); return 'account'; }
}
});
const req = {
currentUser: { id: 7, username: 'demo', email: 'old@example.test', pending_email: 'new@example.test' },
query: {}
};
const res = { send(value) { this.body = value; }, status() { return this; } };
await handlers['/account'][0](req, res);
assert.ok(queries.some(([sql, params]) => sql.includes('DELETE FROM a_verifications WHERE identifier = ? AND expires_at <= NOW()') && params[0] === 'change-email-otp-old@example.test-new@example.test'));
assert.ok(queries.some(([sql, params]) => sql.includes('UPDATE a_users') && sql.includes('NOT EXISTS') && params[1] === 'new@example.test'));
assert.equal(renderedUser.pending_email, null);
assert.equal(res.body, 'account');
});
test('account page preserves pending_email while a Better Auth OTP is still active', async () => {
const handlers = {};
let renderedUser = null;
const app = {
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
post() {}
};
const pool = {
async query(sql) {
if (String(sql).includes('SELECT id, setting_key, setting_value FROM o_app_settings')) {
return [[{ id: 1, setting_key: 'security.allow_user_session_revocation', setting_value: 'false' }]];
}
if (String(sql).includes('UPDATE a_users') && String(sql).includes('NOT EXISTS')) {
return [{ affectedRows: 0 }];
}
return [[]];
}
};
registerAccountRoutes(app, {
pool: pool,
common: {},
pages: {
renderAccountPage(user) { renderedUser = Object.assign({}, user); return 'account'; }
}
});
const pendingEmail = 'new@example.test';
const req = {
currentUser: { id: 7, username: 'demo', email: 'old@example.test', pending_email: pendingEmail },
query: {}
};
const res = { send(value) { this.body = value; }, status() { return this; } };
await handlers['/account'][0](req, res);
assert.equal(renderedUser.pending_email, pendingEmail);
});
test('required-password route renders the password-only page and redirects after the requirement clears', async () => {
const handlers = {};
const app = {
@@ -67,8 +258,9 @@ test('required-password route renders the password-only page and redirects after
assert.equal(regularResponse.redirectedTo, '/account');
});
test('account password update redirects back to the account page', async () => {
test('account password update delegates to Better Auth and signs out', async () => {
const handlers = {};
let changePasswordRequest = null;
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
@@ -79,10 +271,7 @@ test('account password update redirects back to the account page', async () => {
const deps = {
pool: {
async query(sql) {
if (sql.includes('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1')) {
return [[{ id: 5, password_hash: 'hash', password_salt: 'salt', password_iterations: 1 }]];
}
if (sql.includes('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?')) {
if (sql.includes('UPDATE a_users SET must_change_password = 0')) {
return [{ affectedRows: 1 }];
}
if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) {
@@ -93,13 +282,16 @@ test('account password update redirects back to the account page', async () => {
},
common: {},
pages: {},
betterAuth: {
api: {
async changePassword(request) { changePasswordRequest = request; }
}
},
fromNodeHeaders(headers) { return headers; },
formatDashboardDate: () => '',
getAuditUserId: () => 9,
verifyPassword: () => true,
validatePasswordStrength: validatePasswordStrength,
hashPassword: () => ({ hash: 'new-hash', salt: 'new-salt', iterations: 2 }),
createUserSession: async () => 'session-token',
setSessionCookie: () => {}
clearSessionCookie() {}
};
registerAccountRoutes(app, deps);
@@ -113,6 +305,7 @@ test('account password update redirects back to the account page', async () => {
new_password: 'NewPassword!1',
confirm_password: 'NewPassword!1'
},
headers: { cookie: 'digital_signage_session=signed-token' },
currentUser: { id: 5 }
};
const res = {
@@ -129,5 +322,60 @@ test('account password update redirects back to the account page', async () => {
await routeHandlers[0](req, res, () => {});
assert.equal(res.redirectedTo, '/account?message=Password%20updated.');
assert.deepEqual(changePasswordRequest.body, {
currentPassword: 'current-password',
newPassword: 'NewPassword!1',
revokeOtherSessions: true
});
assert.deepEqual(changePasswordRequest.headers, req.headers);
assert.equal(res.redirectedTo, '/login?message=Password%20updated.%20Sign%20in%20again.');
});
test('account profile verifies the current password through Better Auth', async () => {
const handlers = {};
let verificationRequest = null;
const app = {
post(path, ...routeHandlers) { handlers[path] = routeHandlers; },
get() {}
};
registerAccountRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('SELECT id, username, name, email, pending_email FROM a_users')) {
return [[{ id: 5, username: 'demo', name: 'Demo', email: 'demo@example.test', pending_email: null }]];
}
return [{ affectedRows: 1 }];
}
},
common: {
fetchDuplicateName: async () => null,
validateMaxLength(value) { return String(value || '').trim(); }
},
pages: {},
betterAuth: {
api: {
async verifyPassword(request) {
verificationRequest = request;
return { status: true };
}
}
},
fromNodeHeaders(headers) { return { cookie: headers.cookie }; },
formatDashboardDate: () => '',
getAuditUserId: () => 5
});
const req = {
body: { name: 'Demo', username: 'demo', email: 'demo@example.test', current_password: 'current-password' },
headers: { cookie: 'auth-session=session-token' },
currentUser: { id: 5 }
};
const res = { redirect(url) { this.redirectedTo = url; } };
await handlers['/account/profile'][0](req, res, error => { throw error; });
assert.deepEqual(verificationRequest, {
body: { password: 'current-password' },
headers: { cookie: 'auth-session=session-token' }
});
assert.equal(res.redirectedTo, '/account?message=Account%20details%20updated.');
});