This commit is contained in:
parent
d941074095
commit
9855a20906
18
frontend/src/utils/apiResponse.mjs
Normal file
18
frontend/src/utils/apiResponse.mjs
Normal 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 错误。
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 = ''
|
||||
}
|
||||
|
||||
73
test/frontend/api-blob.test.mjs
Normal file
73
test/frontend/api-blob.test.mjs
Normal 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)
|
||||
})
|
||||
@ -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 => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user