修复审计导出错误解析并接入密码确认重试
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 19:05:58 +08:00
parent d941074095
commit 9855a20906
5 changed files with 156 additions and 2 deletions

View File

@ -0,0 +1,18 @@
const maximumErrorBytes = 1024 * 1024
// 下载接口的 JSON 业务错误也会被 Axios 包装成 Blob。
// 仅解开小体积错误信封,真实 CSV、JSON 导出和未知内容保持原样。
export async function decodeBlobAPIError(response) {
const payload = response?.data
if (!(payload instanceof Blob) || payload.size > maximumErrorBytes) return
const contentType = payload.type || response.headers?.['content-type'] || ''
if (!/(?:application|text)\/(?:[\w.-]+\+)?json\b/i.test(contentType)) return
try {
const body = JSON.parse(await payload.text())
if (body && !Array.isArray(body) && typeof body.errorNo === 'number' && body.errorNo !== 0) {
response.data = body
}
} catch {
// 非 JSON 内容仍交给原有下载校验处理,不能掩盖原始 HTTP 错误。
}
}

View File

@ -2,6 +2,7 @@ import axios from 'axios'
import lang from '../i18n/i18n';
import {router} from "@/router";
import {createLoginRedirectHandler} from "@/utils/navigation.mjs";
import { decodeBlobAPIError } from './apiResponse.mjs'
//创建axios的一个实例
const http = axios.create({
@ -72,6 +73,7 @@ function isAuthenticationFailureError(error) {
http.interceptors.response.use(async (response) => {
//响应成功
await decodeBlobAPIError(response)
if (response.data?.errorNo === 403
&& response.data?.data?.code !== 'RECENT_AUTH_REQUIRED'
&& !isSetupRequest(response.config)
@ -81,6 +83,7 @@ http.interceptors.response.use(async (response) => {
return response.data;
}, async (error) => {
//响应错误
await decodeBlobAPIError(error.response)
if (error.response && error.response.status) {
let message = ""
switch (error.response.status) {

View File

@ -727,7 +727,9 @@ export function usePage(pageKey = 'base') {
if (!canExportAudit.value || working.value) return
working.value = format === 'csv' ? 'export-csv' : 'export'
try {
const response = await http.post('/api/v1/audits/config', { format, limit: 500 }, { responseType: 'blob' })
const response = await requestWithRecentAuthentication(() => http.post(
'/api/v1/audits/config', { format, limit: 500 }, { responseType: 'blob' }
))
if (!(response instanceof Blob)) {
throw new Error(response?.errorMsg || '审计导出响应格式不正确')
}
@ -747,7 +749,7 @@ export function usePage(pageKey = 'base') {
URL.revokeObjectURL(url)
ElMessage.success('审计记录已导出')
} catch (error) {
ElMessage.error(error.message || '审计导出失败')
if (!isRecentAuthenticationCancelled(error)) ElMessage.error(error.message || '审计导出失败')
} finally {
working.value = ''
}

View File

@ -0,0 +1,73 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import test from 'node:test'
import { decodeBlobAPIError } from '../../frontend/src/utils/apiResponse.mjs'
const source = readFileSync(new URL('../../frontend/src/utils/axios.js', import.meta.url), 'utf8')
const createInterceptors = compileFunction(
source.replace(/^import .*$/gm, '')
.replace('import.meta.env.VITE_APP_URL', 'undefined')
.replace('export {http};', ''),
['axios', 'lang', 'router', 'createLoginRedirectHandler', 'decodeBlobAPIError']
)
function fixture() {
let success
let failure
let redirects = 0
const http = { interceptors: { response: { use: (left, right) => { success = left; failure = right } } } }
createInterceptors({ create: () => http }, { lang: 'zh' }, {}, () => async () => { redirects++ }, decodeBlobAPIError)
return { success, failure, redirects: () => redirects }
}
function response(body, status = 403) {
return {
data: new Blob([JSON.stringify(body)], { type: 'application/json; charset=utf-8' }),
status,
config: { url: '/api/v1/audits/config', responseType: 'blob' }
}
}
test('HTTP 错误 Blob 保留最近认证错误码及中文提示,不跳转登录', async () => {
const api = fixture()
const res = response({ errorNo: 405, errorMsg: '请确认当前密码', data: { code: 'RECENT_AUTH_REQUIRED', requestId: 'test-request' } })
const error = Object.assign(new Error('HTTP 403'), { response: res, config: res.config })
await assert.rejects(api.failure(error), result => {
assert.equal(result, error)
assert.equal(result.apiErrorCode, 'RECENT_AUTH_REQUIRED')
assert.equal(result.apiRequestId, 'test-request')
assert.equal(result.message, '请确认当前密码')
return true
})
assert.equal(api.redirects(), 0)
})
test('HTTP 200 包装的业务错误 Blob 同样能触发最近认证', async () => {
const api = fixture()
const result = await api.success(response({ errorNo: 405, data: { code: 'RECENT_AUTH_REQUIRED' } }, 200))
assert.equal(result.data.code, 'RECENT_AUTH_REQUIRED')
assert.equal(api.redirects(), 0)
})
test('真实 CSV 和 JSON 导出、损坏内容及过大内容不会被替换', async () => {
const values = [
new Blob(['a,b\n1,2'], { type: 'text/csv' }),
new Blob(['[{"action":"update"}]'], { type: 'application/json' }),
new Blob(['{"errorNo":0,"data":[]}'], { type: 'application/json' }),
new Blob(['broken'], { type: 'application/json' }),
new Blob([' '.repeat(1024 * 1024 + 1)], { type: 'application/json' })
]
for (const value of values) {
const res = { data: value }
await decodeBlobAPIError(res)
assert.equal(res.data, value)
}
})
test('只有真实登录失效的 Blob 错误才跳转登录', async () => {
const api = fixture()
const res = response({ errorNo: 403, errorMsg: '请重新登录' })
await assert.rejects(api.failure({ response: res, config: res.config }))
assert.equal(api.redirects(), 1)
})

View File

@ -59,6 +59,64 @@ function deferred() {
return { promise, resolve, reject }
}
for (const cancelled of [false, true]) {
test(`审计导出身份过期后确认密码再重试,取消不误报失败:取消=${cancelled}`, async context => {
const originalDocument = globalThis.document
let downloads = 0
globalThis.document = { createElement: () => ({ click: () => { downloads++ } }) }
context.after(() => { globalThis.document = originalDocument })
const requests = []
let prompts = 0
const { page, messages, successes } = fixture(() => {}, 'audit', {
prompt: async () => {
prompts++
if (cancelled) throw 'cancel'
return { value: 'test-password' }
},
http: {
post: async (url, body) => {
requests.push(url)
if (url.endsWith('/reauth')) {
assert.equal(body.password, 'test-password')
return { errorNo: 0 }
}
if (requests.length === 1) {
throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
}
return new Blob(['action\nupdate'], { type: 'text/csv' })
}
}
})
await page.exportAudit('csv')
assert.equal(prompts, 1)
assert.equal(downloads, cancelled ? 0 : 1)
assert.deepEqual(messages, [])
assert.equal(successes.length, cancelled ? 0 : 1)
assert.equal(page.working.value, '')
assert.deepEqual(requests, cancelled
? ['/api/v1/audits/config']
: ['/api/v1/audits/config', '/api/v1/security/reauth', '/api/v1/audits/config'])
})
}
test('审计导出确认身份后仍被拒绝时只重试一次,不能下载错误文件', async () => {
let exports = 0
const { page, messages } = fixture(() => {}, 'audit', {
prompt: async () => ({ value: 'test-password' }),
http: {
post: async url => {
if (url.endsWith('/reauth')) return { errorNo: 0 }
exports++
throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
}
}
})
await page.exportAudit('csv')
assert.equal(exports, 2)
assert.deepEqual(messages, ['需要最近认证'])
assert.equal(page.working.value, '')
})
test('证书页从状态接口读取生效反代模式,不额外加载基础配置', async () => {
const requests = []
const { page } = fixture(async path => {