From 9855a2090692ee39e5776b57762f0b2f461b9b47 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 19:05:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A1=E8=AE=A1=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E9=94=99=E8=AF=AF=E8=A7=A3=E6=9E=90=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=AF=86=E7=A0=81=E7=A1=AE=E8=AE=A4=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/utils/apiResponse.mjs | 18 +++++ frontend/src/utils/axios.js | 3 + .../ConfigSettings/ConfigSettings.js | 6 +- test/frontend/api-blob.test.mjs | 73 +++++++++++++++++++ test/frontend/config-settings.test.mjs | 58 +++++++++++++++ 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 frontend/src/utils/apiResponse.mjs create mode 100644 test/frontend/api-blob.test.mjs diff --git a/frontend/src/utils/apiResponse.mjs b/frontend/src/utils/apiResponse.mjs new file mode 100644 index 0000000..9cb9a0c --- /dev/null +++ b/frontend/src/utils/apiResponse.mjs @@ -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 错误。 + } +} diff --git a/frontend/src/utils/axios.js b/frontend/src/utils/axios.js index 872beca..0f3d2b2 100644 --- a/frontend/src/utils/axios.js +++ b/frontend/src/utils/axios.js @@ -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) { diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index 590e7dc..d6a73e8 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -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 = '' } diff --git a/test/frontend/api-blob.test.mjs b/test/frontend/api-blob.test.mjs new file mode 100644 index 0000000..61a77b1 --- /dev/null +++ b/test/frontend/api-blob.test.mjs @@ -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) +}) diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index b41eeac..a7f67e6 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -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 => {