fix: validate account creation password

This commit is contained in:
inman committed 2026-09-02 11:00:13 +08:00
1 parent 3ed3af1feb
commit 203bfb3246
5 files changed
+249 -10

No files matched your search

@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import vm from 'node:vm';
const [appSource, indexSource] = await Promise.all([
readFile(new URL('./app.js', import.meta.url), 'utf8'),
readFile(new URL('./index.html', import.meta.url), 'utf8')
]);
function loadNamedFunction(name) {
const start = appSource.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} must exist`);
const signatureEnd = appSource.indexOf(') {', start);
assert.notEqual(signatureEnd, -1, `${name} must have a complete signature`);
const bodyStart = signatureEnd + 2;
let depth = 0;
let end = -1;
for (let index = bodyStart; index < appSource.length; index += 1) {
if (appSource[index] === '{') depth += 1;
if (appSource[index] === '}') {
depth -= 1;
if (depth === 0) {
end = index + 1;
break;
}
}
}
assert.notEqual(end, -1, `${name} must have a complete body`);
const context = {};
vm.runInNewContext(`${appSource.slice(start, end)}; globalThis.loaded = ${name};`, context);
return context.loaded;
}
test('account creation rejects a short password before sending the request', () => {
const validate = loadNamedFunction('validateAccountCreationValues');
const result = validate({
username: 'operator',
password: '12345678901',
role: 'user',
mustChangePassword: true
});
assert.equal(result.ok, false);
assert.equal(result.field, 'accountPassword');
assert.equal(result.message, '初始密码必须为 12—512 个字符。');
const createStart = appSource.indexOf('async function createAccountFromForm()');
const createEnd = appSource.indexOf('\n}\n\nasync function updateManagedAccount', createStart);
assert.notEqual(createStart, -1);
assert.notEqual(createEnd, -1);
const createSource = appSource.slice(createStart, createEnd);
assert.ok(createSource.indexOf('if (!validation.ok)') < createSource.indexOf("apiRequest('/api/accounts'"));
assert.match(createSource, /if \(!validation\.ok\) \{[\s\S]+return;[\s\S]+apiRequest\('\/api\/accounts'/u);
});
test('account creation normalizes valid form values into the server contract', () => {
const validate = loadNamedFunction('validateAccountCreationValues');
const result = validate({
username: ' TeamLead ',
password: 'correct-horse-battery-staple',
role: 'team_lead',
mustChangePassword: true
});
assert.equal(result.ok, true);
assert.equal(result.body.username, 'TeamLead');
assert.equal(result.body.password, 'correct-horse-battery-staple');
assert.equal(result.body.role, 'team_lead');
assert.equal(result.body.must_change_password, true);
assert.equal(Array.isArray(result.body.business_route_ids), true);
assert.equal(result.body.business_route_ids.length, 0);
});
test('account creation translates backend password validation into an actionable message', () => {
const messageFor = loadNamedFunction('accountCreationErrorMessage');
const message = messageFor({
errorCode: 'invalid_request',
details: ['password'],
message: '请求参数不符合要求。'
});
assert.equal(message, '初始密码必须为 12—512 个字符。');
});
test('account form states the password requirement beside the field', () => {
assert.match(indexSource, /初始密码(12—512 个字符)<input id="accountPassword"/u);
assert.match(indexSource, /id="accountPassword"[^>]+minlength="12"[^>]+maxlength="512"/u);
});
+75 -9
View File
@@ -262,7 +262,11 @@ async function apiRequest(path, options = {}) {
showLoginPanel('登录已过期,请重新登录。');
}
if (!response.ok) {
throw new Error(payload?.message || payload?.error || `请求失败:HTTP ${response.status}`);
const requestError = new Error(payload?.message || payload?.error || `请求失败:HTTP ${response.status}`);
requestError.status = response.status;
requestError.errorCode = payload?.error_code || '';
requestError.details = payload?.details;
throw requestError;
}
return payload || {};
}
@@ -891,6 +895,63 @@ function accountRoleLabel(role) {
return '普通用户';
}
function validateAccountCreationValues(values = {}) {
const username = String(values.username || '').trim();
const password = String(values.password || '');
const role = String(values.role || '');
if (!username || username.length > 160) {
return { ok: false, field: 'accountUsername', message: '账号必须为 1—160 个字符。' };
}
if (password.length < 12 || password.length > 512) {
return { ok: false, field: 'accountPassword', message: '初始密码必须为 12—512 个字符。' };
}
if (!['admin', 'team_lead', 'user'].includes(role)) {
return { ok: false, field: 'accountRole', message: '请选择有效的账号角色。' };
}
return {
ok: true,
body: {
username,
password,
role,
must_change_password: values.mustChangePassword === true,
business_route_ids: []
}
};
}
function accountCreationErrorMessage(error) {
const details = Array.isArray(error?.details) ? error.details.map((item) => String(item || '')) : [];
if (error?.errorCode === 'invalid_request') {
if (details.some((path) => path === 'password' || path.startsWith('password.'))) {
return '初始密码必须为 12—512 个字符。';
}
if (details.some((path) => path === 'username' || path.startsWith('username.'))) {
return '账号必须为 1—160 个字符。';
}
if (details.some((path) => path === 'role' || path.startsWith('role.'))) {
return '请选择有效的账号角色。';
}
}
return error?.message || String(error);
}
function showAccountCreationValidationError(validation) {
const message = $('#accountMessage');
if (message) message.textContent = validation.message;
const field = validation.field ? document.getElementById(validation.field) : null;
if (field) {
field.setAttribute('aria-invalid', 'true');
field.focus();
}
}
function clearAccountCreationValidationErrors() {
for (const fieldId of ['accountUsername', 'accountPassword', 'accountRole']) {
document.getElementById(fieldId)?.removeAttribute('aria-invalid');
}
}
function renderAccounts() {
const container = $('#accountList');
if (!container) return;
@@ -1048,19 +1109,24 @@ async function syncAccounts() {
async function createAccountFromForm() {
if (!isAdministrator() || accountSettingsBusy) return;
const validation = validateAccountCreationValues({
username: $('#accountUsername').value,
password: $('#accountPassword').value,
role: $('#accountRole').value,
mustChangePassword: $('#accountMustChangePassword').checked
});
if (!validation.ok) {
showAccountCreationValidationError(validation);
return;
}
clearAccountCreationValidationErrors();
accountSettingsBusy = true;
renderAccounts();
const message = $('#accountMessage');
try {
const result = await apiRequest('/api/accounts', {
method: 'POST',
body: {
username: String($('#accountUsername').value || '').trim(),
password: String($('#accountPassword').value || ''),
role: $('#accountRole').value,
must_change_password: $('#accountMustChangePassword').checked,
business_route_ids: []
}
body: validation.body
});
if (result.account) accountList = [...accountList.filter((item) => item.id !== result.account.id), result.account]
.sort((left, right) => left.username.localeCompare(right.username, 'zh-CN'));
@@ -5424,7 +5490,7 @@ document.addEventListener('DOMContentLoaded', async () => {
event.preventDefault();
createAccountFromForm().catch((error) => {
const message = $('#accountMessage');
if (message) message.textContent = error.message || String(error);
if (message) message.textContent = accountCreationErrorMessage(error);
});
});
$('#accountList')?.addEventListener('change', (event) => {
+1 -1
View File
@@ -96,7 +96,7 @@
</div>
<form id="accountForm" class="channel-form" novalidate>
<label>账号<input id="accountUsername" maxlength="160" autocomplete="off" required></label>
<label>初始密码<input id="accountPassword" type="password" minlength="12" maxlength="512" autocomplete="new-password" required></label>
<label>初始密码(12—512 个字符)<input id="accountPassword" type="password" minlength="12" maxlength="512" autocomplete="new-password" required></label>
<label>角色<select id="accountRole"><option value="user">普通用户</option><option value="team_lead">组长</option><option value="admin">管理员</option></select></label>
<label class="history-select-all"><input id="accountMustChangePassword" type="checkbox" checked>首次登录必须修改密码</label>
<button type="submit">创建账号</button>