From 461d8b223709b15687df65c9d7a9159108be1b3e Mon Sep 17 00:00:00 2001 From: smail Date: Thu, 17 Sep 2026 12:33:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=A4=E8=AF=81=E7=B9=81?= =?UTF-8?q?=E5=BF=99=E6=97=B6=E5=89=8D=E7=AB=AF=E6=81=A2=E5=A4=8D=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E5=B9=B6=E8=A1=A5=E9=BD=90=E8=BF=9C=E7=A8=8B=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/utils/publicRecoveryHttp.js | 1 + frontend/src/views/Login/Login.js | 4 +- .../PasswordRecovery/PasswordRecovery.js | 6 ++ .../PublicPasswordReset.js | 10 ++- test/frontend/api-blob.test.mjs | 14 ++++ test/frontend/auth.test.mjs | 7 ++ test/frontend/login-capacity.test.mjs | 34 ++++++++ test/frontend/public-password-reset.test.mjs | 80 +++++++++++++++++++ test/frontend/recovery-capacity.test.mjs | 78 ++++++++++++++++++ test/frontend/vue.mjs | 2 +- test/tools/run_frontend_review.py | 73 +++++++++++++++++ 11 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 test/frontend/login-capacity.test.mjs create mode 100644 test/frontend/recovery-capacity.test.mjs create mode 100644 test/tools/run_frontend_review.py diff --git a/frontend/src/utils/publicRecoveryHttp.js b/frontend/src/utils/publicRecoveryHttp.js index 55b4ba9..0102621 100644 --- a/frontend/src/utils/publicRecoveryHttp.js +++ b/frontend/src/utils/publicRecoveryHttp.js @@ -21,6 +21,7 @@ export async function postPublicRecovery(endpoint, payload) { return { ok: response.ok, + status: response.status, data: result } } diff --git a/frontend/src/views/Login/Login.js b/frontend/src/views/Login/Login.js index 2ee72ae..6fae0a2 100644 --- a/frontend/src/views/Login/Login.js +++ b/frontend/src/views/Login/Login.js @@ -42,7 +42,9 @@ export function usePage() { globalStatus.setUser(res.data, true) await router.replace(redirect) } catch (error) { - ElMessage.error("登录请求失败,请稍后重试") + ElMessage.error(error.response?.status === 503 + ? "认证服务暂时繁忙,请稍后重试" + : "登录请求失败,请稍后重试") } finally { loading.value = false } diff --git a/frontend/src/views/PasswordRecovery/PasswordRecovery.js b/frontend/src/views/PasswordRecovery/PasswordRecovery.js index 177fa72..555d68f 100644 --- a/frontend/src/views/PasswordRecovery/PasswordRecovery.js +++ b/frontend/src/views/PasswordRecovery/PasswordRecovery.js @@ -281,6 +281,12 @@ export function usePage() { acceptVerificationTicket(result.data.ticket) } catch (err) { if (version !== requestVersion) return + // 临时不可用不代表恢复码错误,保留输入及附加验证凭证。 + if ([408, 429, 500, 502, 503, 504].includes(err.response?.status) + || (err.isAxiosError && !err.response)) { + adminRecoveryError.value = '验证服务暂时不可用,请稍后重试' + return + } recovery.recoveryCode = '' continuationAttempts++ if (continuationAttempts >= 5) { diff --git a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js index a55b351..9c15397 100644 --- a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js +++ b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js @@ -94,6 +94,10 @@ export function usePage() { if (resetToken.value !== token || page.value !== 'reset') { return } + if (result.status === 429 || result.status >= 500) { + errorMessage.value = '暂时无法验证恢复链接,请稍后刷新重试。' + return + } if (!result.ok || result.data?.available !== true) { errorMessage.value = '恢复链接无效或已失效,请重新申请。' return @@ -139,6 +143,10 @@ export function usePage() { new_password: form.newPassword, recovery_code: requiresRecoveryCode.value ? form.recoveryCode.trim() : '' }) + if (result.status === 429 || result.status >= 500) { + errorMessage.value = '重置服务暂时不可用,请稍后重试。' + return + } if (!result.ok || result.data?.reset !== true) { errorMessage.value = '重置失败。恢复链接可能已失效,请重新申请。' return @@ -149,7 +157,7 @@ export function usePage() { form.recoveryCode = '' page.value = 'success' } catch { - errorMessage.value = '重置失败。恢复链接可能已失效,请重新申请。' + errorMessage.value = '暂时无法确认重置结果,请稍后重试;若链接已失效,请尝试使用新密码登录。' } finally { loading.value = false } diff --git a/test/frontend/api-blob.test.mjs b/test/frontend/api-blob.test.mjs index 61a77b1..c44359e 100644 --- a/test/frontend/api-blob.test.mjs +++ b/test/frontend/api-blob.test.mjs @@ -71,3 +71,17 @@ test('只有真实登录失效的 Blob 错误才跳转登录', async () => { await assert.rejects(api.failure({ response: res, config: res.config })) assert.equal(api.redirects(), 1) }) + +test('认证繁忙保留错误码和中文提示,不跳转或清除登录', async () => { + const api = fixture() + for (const path of ['/api/v1/login', '/api/v1/user/info', '/api/v1/security/recovery/verify']) { + const res = response({ errorNo: 503, errorMsg: '认证服务暂时繁忙,请稍后重试', data: { code: 'AUTH_BUSY' } }, 503) + res.config.url = path + await assert.rejects(api.failure({ response: res, config: res.config }), result => { + assert.equal(result.apiErrorCode, 'AUTH_BUSY') + assert.match(result.message, /稍后重试/) + return true + }) + assert.equal(api.redirects(), 0) + } +}) diff --git a/test/frontend/auth.test.mjs b/test/frontend/auth.test.mjs index af45966..a0804df 100644 --- a/test/frontend/auth.test.mjs +++ b/test/frontend/auth.test.mjs @@ -85,3 +85,10 @@ test("并发认证刷新只执行一次请求并返回同一个结果", async () assert.equal(await third, AUTH_STATUS.UNAUTHENTICATED); assert.equal(requestCount, 2); }); + +test("认证接口繁忙不判定为登录失效", () => { + assert.equal( + classifyAuthenticationError({response: {status: 503, data: {errorNo: 503}}}, {authenticationRequest: true}), + AUTH_STATUS.UNAVAILABLE + ); +}); diff --git a/test/frontend/login-capacity.test.mjs b/test/frontend/login-capacity.test.mjs new file mode 100644 index 0000000..8f721f5 --- /dev/null +++ b/test/frontend/login-capacity.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { compileFunction } from 'node:vm' +import { reactive, ref } from './vue.mjs' + +const source = readFileSync(new URL('../../frontend/src/views/Login/Login.js', import.meta.url), 'utf8') +const createPage = compileFunction( + source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()', + ['reactive', 'ref', 'ElMessage', 'useRouter', 'lang', 'http', 'useGlobalStatusStore', 'getSafeLoginRedirect'] +) + +test('登录繁忙保留输入,不修改会话,释放按钮后允许重试', async () => { + const messages = [] + const updates = [] + let busy = true + const page = createPage(reactive, ref, { error: message => messages.push(message) }, + () => ({ currentRoute: { value: { query: {} } }, replace: async path => updates.push(path) }), {}, + { post: async () => { + if (busy) throw { response: { status: 503 } } + return { errorNo: 0, data: { account: 'alice' } } + } }, + () => ({ clearPendingLogin: () => updates.push('clear-pending'), setUser: () => updates.push('set-user') }), + () => '/') + Object.assign(page.form, { account: 'alice', password: 'OldPass123' }) + await page.onSubmit() + assert.equal(page.form.password, 'OldPass123') + assert.equal(page.loading.value, false) + assert.deepEqual(updates, []) + assert.match(messages[0], /繁忙.*重试/) + busy = false + await page.onSubmit() + assert.deepEqual(updates, ['clear-pending', 'set-user', '/']) +}) diff --git a/test/frontend/public-password-reset.test.mjs b/test/frontend/public-password-reset.test.mjs index 708b44f..52abd0c 100644 --- a/test/frontend/public-password-reset.test.mjs +++ b/test/frontend/public-password-reset.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { compileFunction } from 'node:vm' +import { computed, reactive, ref } from './vue.mjs' const script = readFileSync(new URL('../../frontend/src/views/PublicPasswordReset/PublicPasswordReset.js', import.meta.url), 'utf8') const template = readFileSync(new URL('../../frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue', import.meta.url), 'utf8') @@ -10,3 +12,81 @@ test('匿名恢复页仅在后端明确确认链接可用时显示密码表单', assert.match(template, /v-else-if="available"/) assert.match(template, /恢复链接无效或已失效,请重新申请。/) }) + +const createPage = compileFunction( + script.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()', + ['computed', 'reactive', 'ref', 'onMounted', 'onBeforeUnmount', 'isValidPasswordLength', 'postPublicRecovery', 'window'] +) + +async function fixture(t, requirements = { ok: true, status: 200, data: { available: true, requires_recovery_code: true } }) { + let mounted + let response = async () => ({ ok: true, status: 200, data: { reset: true } }) + const requests = [] + const page = createPage(computed, reactive, ref, callback => { mounted = callback }, callback => t.after(callback), () => true, + async (endpoint, payload) => { + requests.push({ endpoint, payload }) + return endpoint === 'requirements' ? requirements : response() + }, { + location: { hash: '#/reset-password/retry-token' }, + addEventListener() {}, + removeEventListener() {} + }) + mounted() + await new Promise(resolve => setImmediate(resolve)) + Object.assign(page.form, { newPassword: 'NewPass123', confirmPassword: 'NewPass123', recoveryCode: 'offline-code' }) + return { page, requests, respond: value => { response = value } } +} + +test('匿名恢复繁忙保留链接和输入,成功重试后清空秘密', async t => { + const { page, requests, respond } = await fixture(t) + respond(async () => ({ ok: false, status: 503, data: { reset: false } })) + for (let index = 0; index < 12; index++) { + await page.completeReset() + assert.equal(page.page.value, 'reset') + assert.equal(page.form.newPassword, 'NewPass123') + assert.equal(page.form.recoveryCode, 'offline-code') + assert.equal(page.loading.value, false) + assert.match(page.errorMessage.value, /稍后重试/) + assert.doesNotMatch(page.errorMessage.value, /已失效/) + } + respond(async () => ({ ok: true, status: 200, data: { reset: true } })) + await page.completeReset() + assert.equal(requests.at(-1).payload.token, 'retry-token') + assert.equal(page.page.value, 'success') + assert.equal(page.form.newPassword, '') + assert.equal(page.form.recoveryCode, '') +}) + +test('匿名恢复服务临时不可用不误报链接失效,也不显示密码表单', async t => { + const { page } = await fixture(t, { ok: false, status: 503, data: {} }) + assert.equal(page.available.value, false) + assert.match(page.errorMessage.value, /稍后刷新重试/) + assert.doesNotMatch(page.errorMessage.value, /已失效/) +}) + +test('匿名恢复网络错误不声称已重置,并保留重试输入', async t => { + const { page, respond } = await fixture(t) + respond(async () => { throw new Error('network') }) + await page.completeReset() + assert.equal(page.page.value, 'reset') + assert.equal(page.form.newPassword, 'NewPass123') + assert.match(page.errorMessage.value, /无法确认/) +}) + +test('匿名恢复请求保留繁忙状态码,不携带会话或启用缓存', async () => { + const transport = readFileSync(new URL('../../frontend/src/utils/publicRecoveryHttp.js', import.meta.url), 'utf8') + const createTransport = compileFunction( + transport.replace('export async function', 'async function') + '\nreturn postPublicRecovery', ['fetch'] + ) + const post = createTransport(async (url, options) => { + assert.equal(url, '/rest/api/recovery/complete') + assert.equal(options.credentials, 'omit') + assert.equal(options.cache, 'no-store') + assert.equal(options.referrerPolicy, 'no-referrer') + return { ok: false, status: 503, json: async () => ({ reset: false }) } + }) + const result = await post('complete', { token: 'test-token' }) + assert.equal(result.ok, false) + assert.equal(result.status, 503) + assert.equal(result.data.reset, false) +}) diff --git a/test/frontend/recovery-capacity.test.mjs b/test/frontend/recovery-capacity.test.mjs new file mode 100644 index 0000000..1a47eb2 --- /dev/null +++ b/test/frontend/recovery-capacity.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { compileFunction } from 'node:vm' +import { computed, reactive, ref, watch } from './vue.mjs' + +const source = readFileSync(new URL('../../frontend/src/views/PasswordRecovery/PasswordRecovery.js', import.meta.url), 'utf8') +const createPage = compileFunction( + source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()', + ['computed', 'reactive', 'ref', 'watch', 'onBeforeUnmount', 'ElMessage', 'http', 'isValidPasswordLength'] +) + +async function fixture(t) { + let response = async () => ({ errorNo: 0, data: { ticket: 'retry-ticket' } }) + const requests = [] + const page = createPage(computed, reactive, ref, watch, callback => t.after(callback), + { success() {}, error() {}, warning() {}, info() {} }, + { post: async (path, payload) => { + requests.push({ path, payload }) + if (requests.length === 1) { + return { errorNo: 0, data: { requires_recovery_code: true, continuation: 'retry-continuation', expires_in: 300 } } + } + return response() + } }, () => true) + Object.assign(page.recovery, { identifier: 'alice', email: 'alice@example.com' }) + Object.assign(page.recovery, { challengeId: 'challenge', code: '123456' }) + await page.handleStepOneNext() + assert.equal(page.adminRecoveryVisible.value, true) + page.recovery.recoveryCode = 'offline-code' + return { page, requests, respond: value => { response = value } } +} + +for (const status of [408, 429, 500, 502, 503, 504, null]) { + test(`附加恢复验证临时错误 ${status ?? '网络'} 不消耗尝试或清空凭证`, async t => { + const { page, requests, respond } = await fixture(t) + respond(async () => { throw Object.assign(new Error('temporary'), { + isAxiosError: true, response: status === null ? undefined : { status } + }) }) + for (let index = 0; index < 12; index++) { + await page.confirmAdminVerification() + assert.equal(page.adminRecoveryVisible.value, true) + assert.equal(page.recovery.recoveryCode, 'offline-code') + assert.equal(page.loading.value, false) + assert.match(page.adminRecoveryError.value, /稍后重试/) + } + respond(async () => ({ errorNo: 0, data: { ticket: 'retry-ticket' } })) + await page.confirmAdminVerification() + assert.equal(requests.at(-1).payload.continuation, 'retry-continuation') + assert.equal(page.currentStep.value, 2) + }) +} + +test('真实恢复码错误仍受五次前端尝试限制', async t => { + const { page, respond } = await fixture(t) + respond(async () => ({ errorNo: 400 })) + for (let index = 0; index < 5; index++) { + page.recovery.recoveryCode = 'wrong-code' + await page.confirmAdminVerification() + assert.equal(page.recovery.recoveryCode, '') + assert.equal(page.adminRecoveryVisible.value, index < 4) + } + assert.match(page.verificationState.value, /重新获取/) +}) + +test('设置新密码繁忙保留票据,成功后才清空密码', async t => { + const { page, requests, respond } = await fixture(t) + await page.confirmAdminVerification() + Object.assign(page.recovery, { newPassword: 'NewPass123', confirmPassword: 'NewPass123' }) + respond(async () => { throw Object.assign(new Error('繁忙'), { response: { status: 503 } }) }) + await page.handleStepTwoNext() + assert.equal(page.currentStep.value, 2) + assert.equal(page.recovery.newPassword, 'NewPass123') + respond(async () => ({ errorNo: 0, data: { reset: true } })) + await page.handleStepTwoNext() + assert.equal(requests.at(-1).payload.ticket, 'retry-ticket') + assert.equal(page.currentStep.value, 3) + assert.equal(page.recovery.newPassword, '') +}) diff --git a/test/frontend/vue.mjs b/test/frontend/vue.mjs index 420d466..aac7c4b 100644 --- a/test/frontend/vue.mjs +++ b/test/frontend/vue.mjs @@ -2,4 +2,4 @@ import { createRequire } from 'node:module' // 共用前端依赖,不在测试目录安装第二份 Vue。 const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) -export const { computed, reactive, ref } = require('vue') +export const { computed, reactive, ref, watch } = require('vue') diff --git a/test/tools/run_frontend_review.py b/test/tools/run_frontend_review.py new file mode 100644 index 0000000..67071b9 --- /dev/null +++ b/test/tools/run_frontend_review.py @@ -0,0 +1,73 @@ +"""在远程受限服务逐文件运行前端测试,保留日志并拒绝空测试通过。""" + +import argparse +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import time + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('directory') + parser.add_argument('--worker', action='store_true') + args = parser.parse_args() + root = Path(args.directory).resolve(strict=True) + assert root.parent == Path('/var/tmp') and root.name.startswith('smail-review-') + if not args.worker: + assert os.geteuid() == 0 + memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines()) + assert int(memory['MemAvailable'].split()[0]) >= 256 * 1024, '可用内存不足' + command = [ + 'systemd-run', '--wait', '--pipe', '--unit=smail-frontend-tests-' + time.strftime('%Y%m%d-%H%M%S'), + '-p', 'User=smail', '-p', 'Group=smail', '-p', 'MemoryMax=128M', '-p', 'MemorySwapMax=0', + '-p', 'TasksMax=128', '-p', 'CPUQuota=25%', '-p', 'RuntimeMaxSec=600s', + '-p', 'TimeoutStopSec=10s', '-p', 'KillMode=control-group', '-p', 'OOMPolicy=stop', + '-p', 'PrivateTmp=yes', '-p', 'PrivateNetwork=yes', '-p', 'BindPaths=' + str(root), + '-p', 'ProtectSystem=strict', '-p', 'ProtectHome=yes', '-p', 'InaccessiblePaths=/opt/smail /run/smail', + '-p', 'NoNewPrivileges=yes', '-p', 'ReadWritePaths=' + str(root), '-p', 'WorkingDirectory=' + str(root), + 'python3', str(root / 'run_frontend_review.py'), str(root), '--worker', + ] + return subprocess.run(command).returncode + assert os.geteuid() != 0 + node = root / 'node-v22.23.2-linux-x64/bin/node' + tests = sorted((root / 'test/frontend').glob('*.test.mjs')) + assert tests, '未发现前端测试' + output = root / ('results-' + time.strftime('%Y%m%d-%H%M%S')) + output.mkdir(mode=0o700) + environment = {name: value for name, value in os.environ.items() if not name.startswith('SMAIL_')} + environment['NODE_OPTIONS'] = '--max-old-space-size=48' + results = [] + for test in tests: + log = output / (test.name + '.log') + started = time.monotonic() + with log.open('w') as stream: + process = subprocess.Popen([str(node), '--test', str(test)], cwd=root, env=environment, + stdout=stream, stderr=subprocess.STDOUT, start_new_session=True) + try: + code = process.wait(timeout=90) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + code = 124 + text = log.read_text(encoding='utf-8', errors='replace') + counts = {key: int(value) for key, value in re.findall(r'^# (tests|pass|fail|skipped) (\d+)$', text, re.M)} + code = code or (0 if counts.get('tests', 0) > 0 and counts.get('fail') == 0 else 2) + result = dict(test=test.name, code=code, seconds=round(time.monotonic() - started, 2), **counts) + results.append(result) + (output / 'results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding='utf-8') + print(json.dumps(result, ensure_ascii=False), flush=True) + if code: + print(text, flush=True) + print(json.dumps({'files': len(results), 'passed': sum(item.get('pass', 0) for item in results), + 'failed': sum(item.get('fail', 0) for item in results), + 'skipped': sum(item.get('skipped', 0) for item in results)}, ensure_ascii=False), flush=True) + print('测试记录:' + str(output), flush=True) + return int(any(item['code'] for item in results)) + + +if __name__ == '__main__': + raise SystemExit(main())