diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..0e865a6 --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=http://safeapi.mcp123.qzz.io:8881/api/v1 +VITE_LOG_LEVEL=warn diff --git a/dist.tar.gz b/dist.tar.gz new file mode 100644 index 0000000..df09442 Binary files /dev/null and b/dist.tar.gz differ diff --git a/docs/api-contracts.md b/docs/api-contracts.md index a5fb84b..fe730fc 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -24,9 +24,9 @@ - `POST /runs/{id}/results/{execution_id}/retry` 无请求体,沿用原 Run 的测试范围和自动仲裁配置,并以相同 `execution_id` 替换旧结果;仅 `completed / completed_with_errors` 状态允许提交。 - `POST /runs/{id}/resume` 无请求体,返回 `skipped_count`;`POST /runs/{id}/retry-errors` 无请求体,返回 `retry_count`。两者返回 202 后必须重新获取运行详情,不能将 POST 响应视为执行完成;409 时也重新获取详情。 - 单条重试返回 202 后按运行详情的 `poll_after_seconds`(缺省 2 秒)轮询,终态后重新获取结果。重跑期间旧结果可能暂时不存在,UI 保留“重新执行中”状态;404 刷新结果,409 刷新 Run,网络错误或 5xx 先刷新 Run,避免不确定是否受理时重复提交。 -- `POST /runs` 总是携带新的 UUID `Idempotency-Key`。 +- `POST /runs` 总是携带新的 UUID v4 `Idempotency-Key`;HTTPS 优先使用原生 `crypto.randomUUID()`,HTTP 环境使用 `crypto.getRandomValues()` 兼容生成,后端契约不变。 - 登录和续约都会原子替换 access/refresh token 及两个过期时间。 -- access token 剩余不超过 30 分钟且用户最近 30 分钟有真实操作时,`POST /auth/refresh` 携带 UUID `Idempotency-Key`。 +- access token 剩余不超过 30 分钟且用户最近 30 分钟有真实操作时,`POST /auth/refresh` 携带同一共享生成器产生的 UUID v4 `Idempotency-Key`。 - 网络错误和 5xx 最多尝试 3 次,且一直复用原 refresh token 和原 key;409 保留凭据并等待 `refresh_after`。 - 普通业务请求首次 401 共享 single-flight 续约,成功后仅重放一次;只有 refresh 本身返回 401 才清除会话。 - HTTP 204 按无响应体处理;403/404/409/422/502 映射为可操作的用户消息。 diff --git a/docs/architecture.md b/docs/architecture.md index b57e0c9..c3f9622 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,8 @@ UI 页面只调用 `services/`;service 先用 `schemas/` 中的 Zod schema 校验请求,再由 `api/client.ts` 发起请求并用 Zod 校验响应。跨字段业务不变量也在 schema 层完成。错误统一进入 `errors/`,日志进入 `logging/` 并在输出前递归脱敏。 +创建运行和 Token 续约统一通过 `idempotencyKey.ts` 生成 UUID v4 幂等键:安全上下文优先使用浏览器原生 `crypto.randomUUID()`,HTTP 或旧浏览器回退到 `crypto.getRandomValues()`;不使用可预测的 `Math.random()`。 + ```text pages/components → services → schemas + api client → FastAPI ↓ diff --git a/docs/operations.md b/docs/operations.md index 8b422c9..c80a478 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2,7 +2,7 @@ ## 测试 -`tests/unit` 覆盖脱敏、错误归一化、主题持久化、API 响应校验、认证原子存储、single-flight 续约、幂等重试、401 单次重放、运行数据不变量、结果语义标识和运行进入终态时的最终结果同步。`tests/integration` 验证主题切换、逐条结果筛选与搜索、单条重试结果跨筛选置顶跟踪等页面交互,以及所有 service 到后端路由的映射。 +`tests/unit` 覆盖脱敏、错误归一化、主题持久化、API 响应校验、认证原子存储、single-flight 续约、HTTP/HTTPS UUID v4 幂等键生成、幂等重试、401 单次重放、运行数据不变量、结果语义标识和运行进入终态时的最终结果同步。`tests/integration` 验证主题切换、逐条结果筛选与搜索、单条重试结果跨筛选置顶跟踪等页面交互,以及所有 service 到后端路由的映射。 ```bash pnpm test @@ -15,6 +15,7 @@ pnpm build - 前端不包含 `TARGET_API_KEY` 或 `JUDGE_API_KEY`,也不应在 Vite 变量中暴露它们。 - 完整认证对象保存在 `localStorage` 以支持多标签页原子轮换,不分开写入 access/refresh token;生产环境必须使用 HTTPS 并保持严格 CSP,降低 XSS 窃取风险。 - 续约的待处理 refresh token 和 `Idempotency-Key` 作为一组保存;网络/5xx 失败后不换 key,refresh 401 或用户主动退出才清理凭据。 +- HTTP 部署缺少 `crypto.randomUUID()` 时使用 Web Crypto `getRandomValues()` 生成标准 UUID v4;若浏览器连安全随机源也不支持则明确失败,绝不降级为 `Math.random()`。 - logger 对 password、token、API key、Authorization 等键及嵌套数据递归脱敏。 - UI 权限控制只用于交互,真实授权必须由后端执行。 - 生产环境应使用 HTTPS,并将后端 CORS 限制为实际前端域名。 diff --git a/src/auth/refresh.ts b/src/auth/refresh.ts index 8b9198a..efa6b36 100644 --- a/src/auth/refresh.ts +++ b/src/auth/refresh.ts @@ -1,4 +1,5 @@ import { normalizeError } from '../errors' +import { createIdempotencyKey } from '../idempotencyKey' import { logger } from '../logging' import { tokenSchema, type AuthSession } from '../schemas/auth' import { activity } from './activity' @@ -15,7 +16,7 @@ type PendingRefresh = { refreshToken: string; idempotencyKey: string } const pendingFor = (refreshToken: string): PendingRefresh => { const saved = JSON.parse(localStorage.getItem(pendingKey) || 'null') as PendingRefresh | null if (saved?.refreshToken === refreshToken) return saved - const pending = { refreshToken, idempotencyKey: crypto.randomUUID() } + const pending = { refreshToken, idempotencyKey: createIdempotencyKey() } localStorage.setItem(pendingKey, JSON.stringify(pending)) return pending } diff --git a/src/idempotencyKey.ts b/src/idempotencyKey.ts new file mode 100644 index 0000000..1cea5c8 --- /dev/null +++ b/src/idempotencyKey.ts @@ -0,0 +1,11 @@ +export function createIdempotencyKey(): string { + const source = globalThis.crypto + if (typeof source?.randomUUID === 'function') return source.randomUUID() + if (typeof source?.getRandomValues !== 'function') throw new Error('当前浏览器不支持安全随机数') + + const bytes = source.getRandomValues(new Uint8Array(16)) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/src/pages/RunDetailPage.tsx b/src/pages/RunDetailPage.tsx index 9bb97ff..fe12227 100644 --- a/src/pages/RunDetailPage.tsx +++ b/src/pages/RunDetailPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Alert, Button, Card, Empty, Input, Modal, Progress, Select, Steps, Tabs, Tag, message } from 'antd' @@ -26,9 +26,7 @@ export function RunDetailPage() { useFinalResultsSync(Boolean(run.data?.terminal), results.refetch) const report = useQuery({ queryKey: ['report', id], queryFn: () => services.reports.get(id), enabled: Boolean(run.data?.terminal) }) const refresh = () => void queryClient.invalidateQueries({ queryKey: ['run', id] }) - useEffect(() => { - if (retryingExecutionId && run.data?.terminal) setRetryingExecutionId(null) - }, [retryingExecutionId, run.data?.terminal]) + const activeRetryingExecutionId = run.data?.terminal ? null : retryingExecutionId const retryResult = useMutation({ mutationFn: (executionId: string) => services.runs.retryResult(id, executionId), onSuccess: (response) => { @@ -85,7 +83,7 @@ export function RunDetailPage() {
{data.processed_count}已处理{data.completed_count}已完成{data.error_count}执行错误{data.selected_count}总样例
setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> }, + { key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> }, { key: 'report', label: '汇总报告', children: report.data ? : }, ]} /> diff --git a/src/services/runService.ts b/src/services/runService.ts index 007ad7d..c4e26ac 100644 --- a/src/services/runService.ts +++ b/src/services/runService.ts @@ -1,12 +1,11 @@ import type { ApiClient } from '../api/client' +import { createIdempotencyKey } from '../idempotencyKey' import { resultListSchema, retryErrorsSchema, retryResultSchema, runDetailSchema, runListSchema, runResponseSchema, startRunSchema, resumeRunSchema, type StartRunInput } from '../schemas/runs' -const idempotencyKey = () => crypto.randomUUID() - export const runService = (api: ApiClient) => ({ list: (limit = 50) => api.request(`/runs?limit=${limit}`, { schema: runListSchema }), get: (id: number) => api.request(`/runs/${id}`, { schema: runDetailSchema }), - start: (input: StartRunInput, key = idempotencyKey()) => + start: (input: StartRunInput, key = createIdempotencyKey()) => api.request('/runs', { method: 'POST', headers: { 'Idempotency-Key': key }, body: startRunSchema.parse(input), schema: runResponseSchema }), cancel: (id: number) => api.request(`/runs/${id}`, { method: 'PATCH', body: { status: 'cancelled' }, schema: runDetailSchema }), remove: (id: number) => api.request(`/runs/${id}`, { method: 'DELETE' }), diff --git a/tests/integration/pages.test.tsx b/tests/integration/pages.test.tsx index 4adaefb..6426a62 100644 --- a/tests/integration/pages.test.tsx +++ b/tests/integration/pages.test.tsx @@ -147,7 +147,7 @@ describe('page mounting and route security', () => { expect(mocks.resume).toHaveBeenCalledWith(1) expect(await screen.findByText('运行已恢复,将跳过 60 条已有结果')).toBeInTheDocument() - }) + }, 20_000) it('only retries runs completed with errors and reports the queued count', async () => { mocks.run.mockResolvedValueOnce({ ...run, status: 'completed_with_errors', error_count: 5, processed_count: 5 }) @@ -159,13 +159,15 @@ describe('page mounting and route security', () => { expect(mocks.retryErrors).toHaveBeenCalledWith(1) expect(await screen.findByText('已重新提交 5 条错误样例')).toBeInTheDocument() - }) + }, 20_000) it('confirms and retries one result, then locks every result retry button', async () => { + mocks.run.mockResolvedValueOnce(run).mockResolvedValue({ ...run, status: 'pending', terminal: false, phase: 'pending', progress_percent: 0, poll_after_seconds: 2 }) + const otherResult = { execution_id: 'R0050', case_kind: 'control', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' } mocks.results.mockResolvedValueOnce([ { execution_id: 'R0049', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' }, - { execution_id: 'R0050', case_kind: 'control', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' }, - ]) + otherResult, + ]).mockResolvedValue([otherResult]) mount('/runs/1') await userEvent.click(await screen.findByRole('button', { name: '重新执行 R0049' })) @@ -173,9 +175,9 @@ describe('page mounting and route security', () => { await userEvent.click(screen.getByRole('button', { name: 'OK' })) expect(mocks.retryResult).toHaveBeenCalledWith(1, 'R0049') - expect(await screen.findByRole('button', { name: '重新执行中…' })).toBeDisabled() + expect(await screen.findByText('R0049 重新执行中…')).toBeInTheDocument() expect(screen.getByRole('button', { name: '重新执行 R0050' })).toBeDisabled() - }) + }, 20_000) it('keeps a tracked retry visible when its verdict no longer matches the filter', async () => { mocks.results.mockResolvedValueOnce([ @@ -194,7 +196,7 @@ describe('page mounting and route security', () => { expect(await screen.findByText('结论:通过')).toBeInTheDocument() expect(screen.getAllByText('仲裁结论:人工复核')).not.toHaveLength(0) expect(screen.getByText('正在跟踪 R0079;即使不符合当前筛选也会置顶显示。')).toBeInTheDocument() - }) + }, 20_000) it('refreshes run details after an action conflict', async () => { mocks.run.mockResolvedValueOnce({ ...run, status: 'failed' }) @@ -206,5 +208,5 @@ describe('page mounting and route security', () => { expect(await screen.findByText('当前状态不允许恢复')).toBeInTheDocument() expect(mocks.run).toHaveBeenCalledTimes(2) - }) + }, 20_000) }) diff --git a/tests/unit/idempotencyKey.test.ts b/tests/unit/idempotencyKey.test.ts new file mode 100644 index 0000000..0af9170 --- /dev/null +++ b/tests/unit/idempotencyKey.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createIdempotencyKey } from '../../src/idempotencyKey' + +afterEach(() => vi.unstubAllGlobals()) + +describe('createIdempotencyKey', () => { + it('prefers the native UUID implementation', () => { + const randomUUID = vi.fn(() => 'native-uuid') + vi.stubGlobal('crypto', { randomUUID, getRandomValues: vi.fn() }) + + expect(createIdempotencyKey()).toBe('native-uuid') + expect(randomUUID).toHaveBeenCalledOnce() + }) + + it('generates an RFC 4122 UUID v4 when randomUUID is unavailable', () => { + vi.stubGlobal('crypto', { getRandomValues: (bytes: Uint8Array) => bytes.map((_, index) => index) }) + + expect(createIdempotencyKey()).toBe('00010203-0405-4607-8809-0a0b0c0d0e0f') + }) + + it('fails explicitly when no secure random source exists', () => { + vi.stubGlobal('crypto', {}) + + expect(() => createIdempotencyKey()).toThrow('当前浏览器不支持安全随机数') + }) +})