381 lines
15 KiB
JavaScript
381 lines
15 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
require('../src/common');
|
|
|
|
const registerAccountRoutes = require('../src/web/routes/admin/account');
|
|
const pages = require('../src/web/pages');
|
|
const { validatePasswordStrength } = require('../src/auth');
|
|
|
|
test('required-password page shows only password controls and uses an inline warning', () => {
|
|
const html = pages.renderRequiredPasswordPage({ id: 5, username: 'demo' }, {
|
|
minimumLength: 12,
|
|
minimumCategories: 3,
|
|
requireLowercase: false,
|
|
requireUppercase: false,
|
|
requireNumber: false,
|
|
requireSymbol: false
|
|
}, 'Current password is incorrect.');
|
|
|
|
assert.match(html, /Password change required/);
|
|
assert.match(html, /Please change your password before continuing\./);
|
|
assert.match(html, /Current password is incorrect\./);
|
|
assert.match(html, /name="current_password"/);
|
|
assert.match(html, /name="new_password"/);
|
|
assert.match(html, /name="confirm_password"/);
|
|
assert.doesNotMatch(html, /account-profile-form|name="email"|Active sessions/);
|
|
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 = {
|
|
get(path, ...routeHandlers) { handlers[path] = routeHandlers; },
|
|
post() {}
|
|
};
|
|
const deps = {
|
|
pool: { async query() { return [[]]; } },
|
|
common: {},
|
|
pages: {
|
|
renderRequiredPasswordPage(user, requirements) {
|
|
return user.username + ':' + requirements.minimumLength;
|
|
}
|
|
},
|
|
formatDashboardDate: () => '',
|
|
getAuditUserId: () => 5,
|
|
verifyPassword: () => true,
|
|
validatePasswordStrength: validatePasswordStrength,
|
|
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1 }),
|
|
createUserSession: async () => 'token',
|
|
setSessionCookie: () => {}
|
|
};
|
|
registerAccountRoutes(app, deps);
|
|
|
|
const forcedResponse = {
|
|
send(value) { this.body = value; },
|
|
redirect(url) { this.redirectedTo = url; }
|
|
};
|
|
await handlers['/account/password'][0]({ currentUser: { id: 5, username: 'demo', mustChangePassword: true } }, forcedResponse, (error) => { throw error; });
|
|
|
|
const regularResponse = {
|
|
send(value) { this.body = value; },
|
|
redirect(url) { this.redirectedTo = url; }
|
|
};
|
|
await handlers['/account/password'][0]({ currentUser: { id: 5, username: 'demo', mustChangePassword: false } }, regularResponse, (error) => { throw error; });
|
|
|
|
assert.equal(forcedResponse.body, 'demo:10');
|
|
assert.equal(regularResponse.redirectedTo, '/account');
|
|
});
|
|
|
|
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;
|
|
},
|
|
get() {}
|
|
};
|
|
|
|
const deps = {
|
|
pool: {
|
|
async query(sql) {
|
|
if (sql.includes('UPDATE a_users SET must_change_password = 0')) {
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) {
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
return [[]];
|
|
}
|
|
},
|
|
common: {},
|
|
pages: {},
|
|
betterAuth: {
|
|
api: {
|
|
async changePassword(request) { changePasswordRequest = request; }
|
|
}
|
|
},
|
|
fromNodeHeaders(headers) { return headers; },
|
|
formatDashboardDate: () => '',
|
|
getAuditUserId: () => 9,
|
|
validatePasswordStrength: validatePasswordStrength,
|
|
clearSessionCookie() {}
|
|
};
|
|
|
|
registerAccountRoutes(app, deps);
|
|
|
|
const routeHandlers = handlers['/account/password'];
|
|
assert.equal(Array.isArray(routeHandlers), true);
|
|
|
|
const req = {
|
|
body: {
|
|
current_password: 'current-password',
|
|
new_password: 'NewPassword!1',
|
|
confirm_password: 'NewPassword!1'
|
|
},
|
|
headers: { cookie: 'digital_signage_session=signed-token' },
|
|
currentUser: { id: 5 }
|
|
};
|
|
const res = {
|
|
redirect(url) {
|
|
this.redirectedTo = url;
|
|
},
|
|
status() {
|
|
return this;
|
|
},
|
|
send() {
|
|
return this;
|
|
}
|
|
};
|
|
|
|
await routeHandlers[0](req, res, () => {});
|
|
|
|
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.');
|
|
}); |