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
@@ -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.');
|
||||
});
|
||||
Reference in new issue
Block a user