修复认证繁忙时前端恢复重试并补齐远程测试
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-17 12:33:25 +08:00
parent ce8285f7cc
commit 461d8b2237
11 changed files with 306 additions and 3 deletions

View File

@ -21,6 +21,7 @@ export async function postPublicRecovery(endpoint, payload) {
return {
ok: response.ok,
status: response.status,
data: result
}
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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)
}
})

View File

@ -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
);
});

View File

@ -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', '/'])
})

View File

@ -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)
})

View File

@ -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, '')
})

View File

@ -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')

View File

@ -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())