diff --git a/docs/frontend-auth-audit.md b/docs/frontend-auth-audit.md new file mode 100644 index 000000000..99c73c3e5 --- /dev/null +++ b/docs/frontend-auth-audit.md @@ -0,0 +1,81 @@ +# 前端接口鉴权信息排查 + +排查日期:2026-11-11。基于提交 `01f90c95683c581dd75085c514dc96295a8db3c3` 及当前工作区。 + +## 范围与结论 + +静态遍历 `frontend/src` 下 225 个 JS/TS 源文件,追踪修复后 212 处公共请求工具调用、全部 6 处底层网络入口,以及原生链接、页面跳转、图片和文件预览。对照网关 `AuthFilter`、`OmsAuthFilter` 和部署代理路由,检查浏览器请求是否携带 JWT 或 SSO Cookie。 + +普通 REST、分片上传、清洗日志流和日志下载已使用认证拦截器。新增发现 6 类业务下载和 DeerFlow 配置探测缺少 JWT 请求头,已修复。示例算子下载一并复用修正后的公共下载函数。文件和归集日志预览同时补齐显式 `credentials: include` 和登录失效处理。 + +仍发现 DeerFlow 原生页面导航与 JWT 登录不兼容,见下方待处理项。这里的排查结论针对本仓库前端请求;独立 DeerFlow 镜像内的前端源码未包含在仓库中,不能据此断言其内部所有 API 已通过验证。未进行线上账号与部署环境联调,也不以本次检查代替后端权限或资源隔离审计。 + +## 已修复的入口 + +所有下载通过 `Request.get()` 发出请求,携带 `Authorization: Bearer ` 和 `credentials: include`;验证响应成功后才生成 Blob 下载链接,不再把 JWT 放在 URL 查询参数中。 + +| 接口 | 原因与处理 | +| --- | --- | +| `/api/data-management/datasets/{id}/files/download` | 公共下载仅将 token 放入 URL;改用认证请求。 | +| `/api/data-management/datasets/{id}/files/directories/download` | 同上,保留中文目录 prefix 查询参数。 | +| `/api/data-management/datasets/{id}/files/{fileId}/download` | 同上,保留 prefix 和指定文件名。 | +| `/api/v1/evaluation/reports/{reportId}/export` | 同上,保留 format 和文件名。 | +| `/api/annotation/auto/{taskId}/download` | 同上。 | +| `/api/cleaning/tasks/{taskId}/result/download` | API 封装使用原生链接,文件表格另用无认证 fetch;两条路径统一使用认证下载。 | +| `/deer-flow-backend/config` | 首页直接 fetch,缺少 JWT;改用认证请求,同时在失败时清理超时计时器。 | +| `/api/operators/examples/download` | 保留上一次修复的行为,统一复用公共下载,避免重复实现。 | + +文件预览和 `/api/data-collection/executions/{id}/log` 预览此前经过请求拦截器,但绕过响应拦截器,且未显式指定跨域 Cookie 策略。现在复用完整请求链;HTTP 401 和 HTTP 200 中的认证失败业务码均触发统一登录处理,不会保存为文件。 + +## 全量调用覆盖 + +下面的数字是静态调用位置数量,包含多种方法、分支和公开登录接口,不是唯一后端路由数量。 + +| 文件(相对 frontend) | 公共请求工具调用数 | +| --- | ---: | +| `src/components/AuthGuard.tsx` | 2 | +| `src/pages/DataAnnotation/annotation.api.ts` | 24 | +| `src/pages/DataCleansing/cleansing.api.ts` | 17 | +| `src/pages/DataCollection/collection.apis.ts` | 9 | +| `src/pages/DataEvaluation/evaluation.api.ts` | 60 | +| `src/pages/DataManagement/dataset.api.ts` | 39 | +| `src/pages/Home/Home.tsx` | 1 | +| `src/pages/KnowledgeBase/knowledge-base.api.ts` | 11 | +| `src/pages/Layout/Header.tsx` | 3 | +| `src/pages/OperatorMarket/operator.api.ts` | 18 | +| `src/pages/RatioTask/Detail/RatioTaskDetail.tsx` | 2 | +| `src/pages/RatioTask/ratio.api.ts` | 4 | +| `src/pages/SettingsPage/settings.apis.ts` | 8 | +| `src/pages/SynthesisTask/synthesis-api.ts` | 12 | +| `src/utils/systemParam.ts` | 2 | +| 合计 | 212 | + +剩余底层网络入口逐项检查如下: + +| 入口 | 结论 | +| --- | --- | +| `main.tsx` 首页配置 fetch | 手工携带 JWT 和 `credentials: include`,已有启动测试覆盖。 | +| `main.tsx` `/api/user/me` fetch | 同上;网关允许该接口自行判断登录状态。 | +| `request.ts` fetch | 请求配置先经过认证拦截器。 | +| `request.ts` XMLHttpRequest | 使用拦截后的 headers,`withCredentials` 对应 include,已验证分片上传。 | +| `errorConfigStore.ts` fetch | 请求公开静态 `/config/error-code.json`,不需要业务认证头。 | +| `authSlice.ts` `/api/auth/login` fetch | 未发现 `loginUser` 的调用方;这是遗留登录入口,当前 UI 使用 AuthGuard 的 `/api/user/login`。遗留路径与网关白名单不一致,并将 token 存入不同缓存键,若恢复使用需先统一;本次未改动该未使用流程。 | + +文件图片预览使用已认证获取的 Blob URL;PDF、清洗日志、归集日志和批量导出按钮使用本地 Blob。文档中的外部链接、模板示意图和头像不自动附加 DataMate JWT。Label Studio 跳转属于外部应用会话,不能用 DataMate Authorization 头替代其登录;现有标注任务登录 API 本身走公共请求工具。 + +## 待处理:DeerFlow 页面会话 + +条件:`datamate.jwt.enable=true`,未通过 SSO Cookie 认证,DeerFlow 已部署。 + +首页 `Home.tsx` 使用 `window.location.href = "/chat"` 打开独立应用。浏览器原生导航及其 `/_next/**` 静态资源请求不会从 localStorage 自动生成 Authorization 头。`ApiGatewayApplication` 将这些路径转到 DeerFlow,而 `AuthFilter` 对它们同样要求 Bearer JWT。因此配置探测修复后,页面导航仍可能返回 401;直接访问或刷新 `/chat` 也有相同问题。 + +这一项尚未修复。需要 DataMate 网关和 DeerFlow 建立可供页面导航使用的会话(例如受保护的服务端 Cookie 或短期会话交换),并验证 Cookie 范围、过期和退出行为;仅给配置探测加请求头无法完成这项集成。 + +## 验证 + +- `npm test`:覆盖现有基础功能、启动认证、清洗日志及新增请求鉴权测试。 +- `node tests/request-auth.test.cjs`:25 项通过,覆盖 7 个下载封装的 JWT/SSO 配置、二进制字节与文件名、预览、401/业务认证错误、REST 方法、multipart、XHR 分片上传、失败/取消、DeerFlow 探测。 +- `node.exe node_modules/vite/bin/vite.js build`:生产构建通过。当前依赖安装包含 Windows Rollup 原生模块;Linux Node 下直接构建缺少 `@rollup/rollup-linux-x64-gnu`。 +- `git diff --check`:无空白错误。 + +下载现在通过 Blob 完整接收文件后触发浏览器保存,大文件会增加浏览器端资源占用;此次未验证大文件压力场景。需要超大文件直接流式落盘时,应单独设计带认证的流式或短期下载凭据方案。 diff --git a/frontend/package.json b/frontend/package.json index 53c95e0f1..178852e01 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite", "mock": "cd src/mock && nodemon --config nodemon.json --inspect=0.0.0.0:9229 mock.cjs --env=development --port=8002", "build": "vite build", - "test": "node --test tests/basic-functionality.test.cjs tests/bootstrap-auth.test.cjs tests/cleaning-logs.test.cjs", + "test": "node --test tests/*.test.cjs", "lint": "eslint .", "preview": "vite preview" }, diff --git a/frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx b/frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx index de59bad21..f0a97143c 100644 --- a/frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx +++ b/frontend/src/pages/DataCleansing/Detail/components/FileTable.tsx @@ -5,6 +5,7 @@ import {useParams} from "react-router"; import {type CleansingResult, TaskStatus} from "@/pages/DataCleansing/cleansing.model.ts"; import {getTaskStatusMap} from "@/pages/DataCleansing/cleansing.const.tsx"; import { useTranslation } from "react-i18next"; +import { downloadCleaningTaskFiles } from "@/pages/DataCleansing/cleansing.api"; // 渲染 JSON 值的辅助组件 function JsonValue({ value, depth = 0 }: { value: any; depth?: number }) { @@ -109,19 +110,7 @@ export default function FileTable({result = [], fetchTaskResult}: { }; const handleDownload = async () => { try { - const response = await fetch(`/api/cleaning/tasks/${id}/result/download`); - if (!response.ok) { - throw new Error('Download failed'); - } - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `task_${id}_files.zip`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); + await downloadCleaningTaskFiles(id); } catch (error) { console.error('Download failed:', error); message.error(t("dataCleansing.detail.fileTable.downloadFilesFailed")); diff --git a/frontend/src/pages/DataCleansing/cleansing.api.ts b/frontend/src/pages/DataCleansing/cleansing.api.ts index 75b3a93d3..9e74b07c1 100644 --- a/frontend/src/pages/DataCleansing/cleansing.api.ts +++ b/frontend/src/pages/DataCleansing/cleansing.api.ts @@ -1,4 +1,4 @@ -import { get, post, put, del } from "@/utils/request"; +import { get, post, put, del, download } from "@/utils/request"; // 清洗任务相关接口 export function queryCleaningTasksUsingGet(params?: any) { @@ -18,13 +18,7 @@ export function queryCleaningTaskResultByIdUsingGet(taskId: string | number) { } export function downloadCleaningTaskFiles(taskId: string | number) { - const url = `/api/cleaning/tasks/${taskId}/result/download`; - const link = document.createElement('a'); - link.href = url; - link.download = `task_${taskId}_files.zip`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + return download(`/api/cleaning/tasks/${taskId}/result/download`, null, `task_${taskId}_files.zip`); } export function queryCleaningTaskLogByIdUsingGet(taskId: string | number, retryCount: number) { diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 2f26c2ec3..f2b98a135 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -17,6 +17,7 @@ import type { MenuProps } from 'antd'; import { Globe } from "lucide-react"; import { useTranslation } from "react-i18next"; import i18n from "@/i18n"; +import { get } from "@/utils/request"; export default function WelcomePage() { const navigate = useNavigate(); @@ -43,26 +44,19 @@ export default function WelcomePage() { ]; const checkDeerFlowDeploy = async (): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - const response = await fetch('/deer-flow-backend/config', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, + await get('/deer-flow-backend/config', null, { + responseType: 'stream', signal: controller.signal, cache: 'no-store' }); - - clearTimeout(timeoutId); - - if (response.ok) { - return true; - } + return true; } catch (error) { console.error('接口检查失败:', error); + } finally { + clearTimeout(timeoutId); } return false; }; diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts index 559c77ca1..ba9f07979 100644 --- a/frontend/src/utils/request.ts +++ b/frontend/src/utils/request.ts @@ -429,22 +429,6 @@ class Request { return null; } - /** - * 获取当前认证 token - */ - getAuthToken() { - const session = localStorage.getItem("session"); - if (session) { - try { - const sessionData = JSON.parse(session); - return sessionData.token; - } catch { - return null; - } - } - return null; - } - /** * 下载文件 * @param {string} url - 请求URL @@ -454,27 +438,15 @@ class Request { * @param {object} options - 额外的fetch选项,包括showLoading, onDownloadProgress */ async download(url, params = null, filename = "", action = "download", options = {}) { - // 对于预览,使用 fetch + blob 方式 - if (action === "preview") { - const fullURL = this.buildURL(url, params); - const config = { - method: "GET", - ...options, - }; - - const processedConfig = await this.executeRequestInterceptors(config); - const response = await fetch(fullURL, processedConfig); - - // 文件预览不需要执行响应拦截器(因为响应是二进制数据,不是JSON) - // 直接检查响应状态 - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + if (action !== "preview" && action !== "download") return {}; - const blob = await response.blob(); - const blobUrl = window.URL.createObjectURL(blob); - const name = filename || this.parseContentDisposition(response.headers.get("Content-Disposition")) || `download_${Date.now()}`; + // 下载与预览统一携带认证头和 Cookie,并复用登录失效处理。 + const response = await this.get(url, params, { ...options, responseType: "stream" }); + const blob = await response.blob(); + const blobUrl = window.URL.createObjectURL(blob); + const name = filename || this.parseContentDisposition(response.headers.get("Content-Disposition")) || `download_${Date.now()}`; + if (action === "preview") { return { blob, blobUrl, @@ -484,26 +456,18 @@ class Request { }; } - // 对于下载,使用原生 标签 + token 参数 - if (action === "download") { - // 获取 token 并添加到 URL 参数中 - const token = this.getAuthToken(); - const downloadParams = token ? { ...params, token } : params; - const fullURL = this.buildURL(url, downloadParams); - - // 创建隐藏的 标签触发下载 - const link = document.createElement("a"); - link.href = fullURL; - link.download = filename || `download_${Date.now()}`; - link.style.display = "none"; - document.body.appendChild(link); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = name; + link.style.display = "none"; + document.body.appendChild(link); + try { link.click(); + } finally { document.body.removeChild(link); - - return { success: true }; + setTimeout(() => window.URL.revokeObjectURL(blobUrl), 0); } - - return {}; + return { success: true }; } /** diff --git a/frontend/tests/basic-functionality.test.cjs b/frontend/tests/basic-functionality.test.cjs index bdef7dfb0..9081ce6e5 100644 --- a/frontend/tests/basic-functionality.test.cjs +++ b/frontend/tests/basic-functionality.test.cjs @@ -293,6 +293,7 @@ test('cleaning file filters tolerate unfinished results and failed downloads sho 'react-router': { useParams: () => ({ id: 'cleaning-1' }) }, '@/pages/DataCleansing/cleansing.model.ts': {}, '@/pages/DataCleansing/cleansing.const.tsx': {}, + '@/pages/DataCleansing/cleansing.api': { downloadCleaningTaskFiles: async () => { throw new Error('Download failed'); } }, 'react-i18next': { useTranslation: () => ({ t: translate }) }, }, { fetch: async () => ({ ok: false }), console: { error() {} } }).default; const tree = runner.render(() => FileTable({ fetchTaskResult() {} })); diff --git a/frontend/tests/request-auth.test.cjs b/frontend/tests/request-auth.test.cjs new file mode 100644 index 000000000..8d49855f9 --- /dev/null +++ b/frontend/tests/request-auth.test.cjs @@ -0,0 +1,207 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { loadSource, fakeClock, hookRunner } = require('./helpers.cjs'); + +function fixture({ session = JSON.stringify({ token: 'test-jwt' }), response, fetch, globals = {} } = {}) { + const requests = []; + const links = []; + const blobs = []; + const revoked = []; + const events = []; + const clock = fakeClock(); + const source = loadSource('src/utils/request.ts', { + antd: { message: { error() {} } }, + './loading': { __esModule: true, default: { show() {}, hide() {} } }, + '@/utils/errorConfigStore.ts': { errorConfigStore: { getMessage() {} } }, + '@/i18n': { __esModule: true, default: { language: 'zh' } }, + }, { + ...clock, + fetch: async (url, config) => { + requests.push({ url, config }); + return fetch ? fetch(url, config) : response ?? new Response(Uint8Array.of(0, 255, 13, 10), { + headers: { 'content-type': 'application/octet-stream', 'content-disposition': 'attachment; filename="archive.tar"' }, + }); + }, + localStorage: { getItem: key => key === 'session' ? session : null, removeItem() {} }, + CustomEvent: class { constructor(type) { this.type = type; } }, + window: { + dispatchEvent: event => events.push(event.type), + URL: { + createObjectURL(blob) { blobs.push(blob); return 'blob:download'; }, + revokeObjectURL: url => revoked.push(url), + }, + }, + document: { + createElement: () => ({ style: {}, click() { this.clicked = true; } }), + body: { appendChild: link => links.push(link), removeChild: link => { link.removed = true; } }, + }, + ...globals, + }); + source.setRequireLoginMode(true); + return { ...source, requests, links, blobs, revoked, events, clock }; +} + +const downloadCases = [ + ['src/pages/OperatorMarket/operator.api.ts', 'downloadExampleOperatorUsingGet', ['example.tar'], '/api/operators/examples/download', 'example.tar'], + ['src/pages/DataManagement/dataset.api.ts', 'downloadDatasetUsingGet', ['dataset'], '/api/data-management/datasets/dataset/files/download', 'archive.tar'], + ['src/pages/DataManagement/dataset.api.ts', 'downloadDirectoryUsingGet', ['dataset', '中文/folder/'], '/api/data-management/datasets/dataset/files/directories/download?prefix=%E4%B8%AD%E6%96%87%2Ffolder%2F', 'folder.zip'], + ['src/pages/DataManagement/dataset.api.ts', 'downloadFileByIdUsingGet', ['dataset', '中文/', 'file', 'data.txt'], '/api/data-management/datasets/dataset/files/file/download?prefix=%E4%B8%AD%E6%96%87%2F', 'data.txt'], + ['src/pages/DataCleansing/cleansing.api.ts', 'downloadCleaningTaskFiles', ['task'], '/api/cleaning/tasks/task/result/download', 'task_task_files.zip'], + ['src/pages/DataEvaluation/evaluation.api.ts', 'exportEvaluationReportUsingGet', ['report', 'PDF', 'report.pdf'], '/api/v1/evaluation/reports/report/export?format=PDF', 'report.pdf'], + ['src/pages/DataAnnotation/annotation.api.ts', 'downloadAutoAnnotationResultUsingGet', ['task'], '/api/annotation/auto/task/download', 'archive.tar'], +]; + +for (const [file, method, args, url, filename] of downloadCases) { + for (const session of [JSON.stringify({ token: 'test-jwt' }), null]) { + test(`${method} sends ${session ? 'the JWT' : 'SSO cookies'} and saves the response bytes`, async () => { + const f = fixture({ session }); + const api = loadSource(file, { '@/utils/request': f }); + await api[method](...args); + assert.equal(f.requests.length, 1); + assert.equal(f.requests[0].url, url); + assert.equal(f.requests[0].config.method, 'GET'); + assert.equal(f.requests[0].config.credentials, 'include'); + assert.equal(f.requests[0].config.headers.Authorization, session ? 'Bearer test-jwt' : undefined); + assert.equal(f.requests[0].url.includes('token='), false); + assert.deepEqual(new Uint8Array(await f.blobs[0].arrayBuffer()), Uint8Array.of(0, 255, 13, 10)); + assert.equal(f.links.length, 1); + assert.equal(f.links[0].href, 'blob:download'); + assert.equal(f.links[0].download, filename); + assert.equal(f.links[0].clicked, true); + assert.equal(f.links[0].removed, true); + await f.clock.run(0); + assert.deepEqual(f.revoked, ['blob:download']); + }); + } +} + +for (const action of ['download', 'preview']) { + for (const status of [401, 200]) { + test(`${action} rejects ${status === 401 ? 'HTTP 401' : 'business authentication errors'} before saving and requests login`, async () => { + const f = fixture({ response: new Response('{"code":"common.0401","message":"expired"}', { + status, headers: { 'content-type': 'application/json' }, + }) }); + await assert.rejects(f.download('/api/files/download', null, '', action), error => error.status === status); + assert.deepEqual(f.events, ['show-login']); + assert.equal(f.blobs.length, 0); + assert.equal(f.links.length, 0); + }); + } +} + +for (const session of [JSON.stringify({ token: 'test-jwt' }), null]) { + test(`file and collection log previews send ${session ? 'the JWT' : 'cookies'} and expose a revocable blob`, async () => { + for (const [file, method, args, url] of [ + ['src/pages/DataManagement/dataset.api.ts', 'downloadFileByIdUsingGet', ['dataset', 'folder/', 'file', 'file.txt', 'preview'], '/api/data-management/datasets/dataset/files/file/download?prefix=folder%2F'], + ['src/pages/DataCollection/collection.apis.ts', 'queryExecutionLogFileByIdUsingGet', ['execution'], '/api/data-collection/executions/execution/log'], + ]) { + const f = fixture({ session }); + const api = loadSource(file, { '@/utils/request': f }); + const preview = await api[method](...args); + assert.equal(f.requests[0].url, url); + assert.equal(f.requests[0].config.credentials, 'include'); + assert.equal(f.requests[0].config.headers.Authorization, session ? 'Bearer test-jwt' : undefined); + assert.equal(preview.blobUrl, 'blob:download'); + assert.equal(preview.size, 4); + assert.equal(f.links.length, 0); + preview.revoke(); + assert.deepEqual(f.revoked, ['blob:download']); + } + }); +} + +test('REST methods and multipart requests retain authentication with custom headers', async () => { + const f = fixture({ fetch: async () => new Response('{"code":"0"}', { headers: { 'content-type': 'application/json' } }) }); + const options = { headers: { Accept: 'application/json' } }; + await f.get('/api/list', { page: 1 }, options); + await f.post('/api/create', { name: 'name' }, options); + await f.put('/api/update', { name: 'name' }, options); + await f.del('/api/delete', { ids: ['id'] }, options); + await f.patch('/api/patch', { name: 'name' }, options); + await f.post('/api/upload', new FormData(), options); + assert.deepEqual(f.requests.map(({ config }) => config.method), ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'POST']); + for (const { config } of f.requests) { + assert.equal(config.headers.Authorization, 'Bearer test-jwt'); + assert.equal(config.credentials, 'include'); + assert.equal(config.headers.Accept, 'application/json'); + } + assert.equal(f.requests[5].config.headers['Content-Type'], undefined); +}); + +test('failed and aborted downloads never create a browser download', async () => { + for (const error of [new Error('offline'), new DOMException('cancelled', 'AbortError')]) { + const f = fixture({ fetch: async () => { throw error; } }); + await assert.rejects(f.download('/api/files/download'), caught => caught === error); + assert.equal(f.blobs.length, 0); + assert.equal(f.links.length, 0); + } +}); + +test('XHR uploads send the same JWT and cookies as fetch requests', async () => { + const instances = []; + class XHR extends EventTarget { + constructor() { + super(); + this.upload = new EventTarget(); + this.headers = {}; + instances.push(this); + } + open(method, url) { this.method = method; this.url = url; } + setRequestHeader(key, value) { this.headers[key] = value; } + getResponseHeader() { return 'application/json'; } + getAllResponseHeaders() { return ''; } + send(body) { this.body = body; } + } + const f = fixture({ globals: { XMLHttpRequest: XHR } }); + const form = new FormData(); + form.append('file', new Blob(['data']), 'file.txt'); + const promise = f.post('/api/operators/upload/chunk', form, { onUploadProgress() {} }); + await new Promise(setImmediate); + const xhr = instances[0]; + assert.equal(xhr.url, '/api/operators/upload/chunk'); + assert.equal(xhr.method, 'POST'); + assert.equal(xhr.headers.Authorization, 'Bearer test-jwt'); + assert.equal(xhr.withCredentials, true); + assert.equal(xhr.headers['Content-Type'], undefined); + assert.equal(xhr.body, form); + xhr.status = 200; + xhr.statusText = 'OK'; + xhr.responseText = '{"code":"0"}'; + xhr.dispatchEvent(new Event('load')); + xhr.dispatchEvent(new Event('loadend')); + await promise; +}); + +for (const status of [200, 401]) { + test(`DeerFlow availability checks send authentication and clear the timeout on HTTP ${status}`, async () => { + const f = fixture({ response: new Response('{}', { status, headers: { 'content-type': 'application/json' } }) }); + const runner = hookRunner(); + const navigated = []; + const location = { href: '/' }; + const Home = loadSource('src/pages/Home/Home.tsx', { + react: runner.react, + 'react-router': { useNavigate: () => url => navigated.push(url) }, + antd: { Card: 'Card', Dropdown: 'Dropdown', Button: 'Button' }, + 'lucide-react': {}, + '../Layout/Menu.tsx': { features: [], menuItems: [] }, + 'react-i18next': { useTranslation: () => ({ t: key => key }) }, + '@/i18n': { __esModule: true, default: { language: 'zh' } }, + '@/utils/request': f, + }, { ...f.clock, AbortController, window: { location }, console: { error() {} } }).default; + function find(tree) { + if (!tree || typeof tree !== 'object') return; + if (Array.isArray(tree)) return tree.map(find).find(Boolean); + if (tree.props?.onClick && Array.isArray(tree.props.children) && tree.props.children.includes('home.hero.chatAssistantButton')) return tree; + return find(tree.props?.children); + } + const tree = runner.render(() => Home()); + await find(tree).props.onClick(); + assert.equal(f.requests[0].url, '/deer-flow-backend/config'); + assert.equal(f.requests[0].config.headers.Authorization, 'Bearer test-jwt'); + assert.equal(f.requests[0].config.credentials, 'include'); + assert.equal(f.requests[0].config.cache, 'no-store'); + assert.equal(f.clock.size, status === 401 ? 1 : 0); // 401 retains only the login prompt debounce. + assert.equal(location.href, status === 200 ? '/chat' : '/'); + assert.deepEqual(navigated, status === 401 ? ['/chat'] : []); + }); +}