This commit is contained in:
parent
ce8285f7cc
commit
461d8b2237
@ -21,6 +21,7 @@ export async function postPublicRecovery(endpoint, payload) {
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
data: result
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
34
test/frontend/login-capacity.test.mjs
Normal file
34
test/frontend/login-capacity.test.mjs
Normal 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', '/'])
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
|
||||
78
test/frontend/recovery-capacity.test.mjs
Normal file
78
test/frontend/recovery-capacity.test.mjs
Normal 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, '')
|
||||
})
|
||||
@ -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')
|
||||
|
||||
73
test/tools/run_frontend_review.py
Normal file
73
test/tools/run_frontend_review.py
Normal 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())
|
||||
Loading…
Reference in New Issue
Block a user