fix: validate account creation password
This commit is contained in:
1 parent
3ed3af1feb
commit
203bfb3246
5 files changed
+249
-10
No files matched your search
@@ -0,0 +1,61 @@
|
|||||||
|
# Task: Fix account registration invalid request parameters
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260902-registration-invalid-params-59f94692
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260902-registration-invalid-params-59f94692-registration-invalid-params-59f94692
|
||||||
|
- Worktree: /Users/inmanx/Documents/lwltAPI-registration-invalid-params-59f94692
|
||||||
|
- Base commit: 3ed3af1feb503358b98fb57d3e8d97ab59d98129
|
||||||
|
- Owner: codex
|
||||||
|
- Status: Ready for Integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Diagnose the current account-creation failure reported as “请求参数不符合要求”。
|
||||||
|
- Correlate the operator form contract, API payload, server-side validation, privacy-safe runtime diagnostics, and account authorization tests.
|
||||||
|
- Add a narrowly scoped client-side validation and error-feedback fix plus regression coverage.
|
||||||
|
- Do not create or change a real account, read secrets or request payloads, deploy, restart the service, or modify ERP behavior.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- Preserve the accepted fixed-scope `admin` / `team_lead` / `user` account and authorization model.
|
||||||
|
- Keep the server-side 12–512-character password boundary unchanged; prevent invalid form input from reaching the API and retain a safe backend-validation fallback.
|
||||||
|
- Use runtime diagnostics only for validation field names; do not persist account names, passwords, request bodies, or other user data.
|
||||||
|
- Treat the concurrent compact-dashboard task as a file-level overlap warning only; keep this change limited to the account form and its tests for later reconciliation.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Privacy-safe diagnostics from the running standard service showed the two recent HTTP validation failures both had only `validation_paths=["password"]`; no request content was inspected.
|
||||||
|
- Confirmed the mismatch: the API requires a 12–512-character password, while the account form used `novalidate` and submitted without an equivalent client-side check, causing a short password to surface only as the generic Zod response.
|
||||||
|
- Added deterministic account-create validation before any API request. Invalid username, password, or role values now show a Chinese field-specific message, mark and focus the relevant field, and do not send a request.
|
||||||
|
- Preserved safe backend fallback handling by retaining `error_code` and validation paths from API errors and mapping a server-side password rejection to the same actionable message.
|
||||||
|
- Added the password requirement beside the form field and added focused regression coverage for short-password rejection, valid request normalization, backend fallback messaging, and visible form guidance.
|
||||||
|
- No live account, database, service process, deployment, ERP state, or external system was changed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Focused account-form regression: 4/4 passed.
|
||||||
|
- Focused account-authorization regression: 8/8 passed.
|
||||||
|
- `node --check LianSyn-platform/app.js`: passed.
|
||||||
|
- `git diff --check`: passed before the final documentation update.
|
||||||
|
- `node --run check:repo`: 10/10 passed.
|
||||||
|
- `node --run check`: passed.
|
||||||
|
- `node --run test:control-plane`: 153/153 passed.
|
||||||
|
- `node --run test:legacy`: 260/260 passed, including the new four tests.
|
||||||
|
- `node --run build`: passed.
|
||||||
|
- `check_project_docs.py`: passed.
|
||||||
|
- `check_doc_drift.py --task-id 20260902-registration-invalid-params-59f94692`: passed.
|
||||||
|
- Verification used the bundled Node runtime and a temporary ignored `node_modules` symlink to the existing dependency tree because Node was not on the isolated shell `PATH`.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Integrate this isolated feature change with the concurrent dashboard layout work, then restart or redeploy the standard service only under separate explicit authorization before expecting the live `/accounts` page to change.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- None recorded.
|
||||||
|
|
||||||
|
## Supporting Records
|
||||||
|
|
||||||
|
- [Account registration password validation evidence](../../50-evidence/topics/20260902-registration-invalid-params-59f94692__account-registration-password-validation.md)
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Account Registration Password Validation Evidence
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- Read-only inspection on 2026-09-02 of the standard service's privacy-safe structured diagnostics.
|
||||||
|
- Active account form, API request builder, `accountCreateSchema`, authentication validation, and account authorization tests at base commit `3ed3af1feb503358b98fb57d3e8d97ab59d98129`.
|
||||||
|
|
||||||
|
## Finding
|
||||||
|
|
||||||
|
- The two recent `http.request.invalid` events both reported only the validation path `password`.
|
||||||
|
- The server contract requires an initial password length of 12–512 characters.
|
||||||
|
- The HTML field declared the same `minlength` and `maxlength`, but the account form used `novalidate` and the JavaScript request path did not perform its own validation before calling `/api/accounts`.
|
||||||
|
- Therefore a short initial password reached server-side Zod validation and the UI displayed the generic response “请求参数不符合要求。” instead of the actual password requirement.
|
||||||
|
|
||||||
|
## Privacy And Safety
|
||||||
|
|
||||||
|
- Only diagnostic event type and validation field paths were extracted.
|
||||||
|
- No password, account name, request body, environment file, database row, or other business/user content was read or stored.
|
||||||
|
- No live account request or runtime mutation was performed.
|
||||||
|
|
||||||
|
## Confidence
|
||||||
|
|
||||||
|
- High. Runtime field-path evidence, the active form behavior, and the server schema all identify the same password-length mismatch.
|
||||||
|
|
||||||
|
## Stale Trigger
|
||||||
|
|
||||||
|
- Reassess if the password contract, account form submission flow, or generic API validation response changes.
|
||||||
@@ -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
@@ -262,7 +262,11 @@ async function apiRequest(path, options = {}) {
|
|||||||
showLoginPanel('登录已过期,请重新登录。');
|
showLoginPanel('登录已过期,请重新登录。');
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
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 || {};
|
return payload || {};
|
||||||
}
|
}
|
||||||
@@ -891,6 +895,63 @@ function accountRoleLabel(role) {
|
|||||||
return '普通用户';
|
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() {
|
function renderAccounts() {
|
||||||
const container = $('#accountList');
|
const container = $('#accountList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -1048,19 +1109,24 @@ async function syncAccounts() {
|
|||||||
|
|
||||||
async function createAccountFromForm() {
|
async function createAccountFromForm() {
|
||||||
if (!isAdministrator() || accountSettingsBusy) return;
|
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;
|
accountSettingsBusy = true;
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
const message = $('#accountMessage');
|
const message = $('#accountMessage');
|
||||||
try {
|
try {
|
||||||
const result = await apiRequest('/api/accounts', {
|
const result = await apiRequest('/api/accounts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: validation.body
|
||||||
username: String($('#accountUsername').value || '').trim(),
|
|
||||||
password: String($('#accountPassword').value || ''),
|
|
||||||
role: $('#accountRole').value,
|
|
||||||
must_change_password: $('#accountMustChangePassword').checked,
|
|
||||||
business_route_ids: []
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (result.account) accountList = [...accountList.filter((item) => item.id !== result.account.id), result.account]
|
if (result.account) accountList = [...accountList.filter((item) => item.id !== result.account.id), result.account]
|
||||||
.sort((left, right) => left.username.localeCompare(right.username, 'zh-CN'));
|
.sort((left, right) => left.username.localeCompare(right.username, 'zh-CN'));
|
||||||
@@ -5424,7 +5490,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
createAccountFromForm().catch((error) => {
|
createAccountFromForm().catch((error) => {
|
||||||
const message = $('#accountMessage');
|
const message = $('#accountMessage');
|
||||||
if (message) message.textContent = error.message || String(error);
|
if (message) message.textContent = accountCreationErrorMessage(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
$('#accountList')?.addEventListener('change', (event) => {
|
$('#accountList')?.addEventListener('change', (event) => {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<form id="accountForm" class="channel-form" novalidate>
|
<form id="accountForm" class="channel-form" novalidate>
|
||||||
<label>账号<input id="accountUsername" maxlength="160" autocomplete="off" required></label>
|
<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>角色<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>
|
<label class="history-select-all"><input id="accountMustChangePassword" type="checkbox" checked>首次登录必须修改密码</label>
|
||||||
<button type="submit">创建账号</button>
|
<button type="submit">创建账号</button>
|
||||||
|
|||||||
Reference in new issue
Block a user