"""评测台的每一个端点都必须挡住未认证请求,以及挡住普通员工。 这道闸是本仓的标准动作(见 `tests/test_partner_directory/test_router_auth.py` 里那次真实泄漏:六个端点的 `Depends()` 全漏了调用括号,FastAPI 于是把工厂 本身当依赖,**鉴权一次都没跑**)。这里还多一层理由:评测结果行里存着完整的 助手回答,是以某个真实员工的身份跑出来的 —— 和聊天记录同一类内容。所以读 权限刻意比 `require_admin_read()` 更窄:**不含 management**,与前端的 `canReadAssistantTranscripts` 和 MCP 侧的 `_require_assistant_eval_read` 三处对齐。 """ from __future__ import annotations import pytest pytestmark = pytest.mark.asyncio _UUID = "00000000-0000-0000-0000-000000000000" # 每个端点一行。新增端点必须同时加到这里 —— 下面第二条用例会拿 openapi # 对账,所以漏加会红,而不是静默地少覆盖一个。 _ENDPOINTS: list[tuple[str, str]] = [ ("GET", "/api/v1/assistant/eval/overview"), ("GET", "/api/v1/assistant/eval/trend"), ("GET", "/api/v1/assistant/eval/suites"), ("POST", "/api/v1/assistant/eval/suites"), ("POST", "/api/v1/assistant/eval/suites/seed"), ("PATCH", f"/api/v1/assistant/eval/suites/{_UUID}"), ("GET", "/api/v1/assistant/eval/cases"), ("POST", "/api/v1/assistant/eval/cases"), ("POST", "/api/v1/assistant/eval/cases/mine"), ("POST", "/api/v1/assistant/eval/cases/bulk-status"), ("PATCH", f"/api/v1/assistant/eval/cases/{_UUID}"), ("DELETE", f"/api/v1/assistant/eval/cases/{_UUID}"), ("POST", f"/api/v1/assistant/eval/cases/{_UUID}/variants"), ("GET", "/api/v1/assistant/eval/runs"), ("POST", "/api/v1/assistant/eval/runs"), ("GET", f"/api/v1/assistant/eval/runs/{_UUID}"), ("GET", f"/api/v1/assistant/eval/runs/{_UUID}/results"), ("GET", f"/api/v1/assistant/eval/runs/{_UUID}/results/{_UUID}"), ("GET", f"/api/v1/assistant/eval/runs/{_UUID}/compare"), ("POST", f"/api/v1/assistant/eval/runs/{_UUID}/resume"), ("POST", f"/api/v1/assistant/eval/runs/{_UUID}/cancel"), ("POST", f"/api/v1/assistant/eval/runs/{_UUID}/promote-failures"), ] #: 路径参数还原成模板,供 openapi 对账。 _PARAM_NAMES = { "/suites/": "suite_id", "/cases/": "case_id", "/runs/": "run_id", } async def test_every_endpoint_rejects_an_anonymous_caller(client): """🔴 未认证一律不能是 2xx。""" leaked: list[str] = [] for method, path in _ENDPOINTS: resp = await client.request(method, path, json={}) if resp.status_code < 400: leaked.append(f"{method} {path} -> {resp.status_code}") assert not leaked, ( "这些端点未认证就能调通,鉴权没生效:\n" + "\n".join(leaked) ) async def test_the_endpoint_list_matches_the_router(client): """防空转:端点表必须和路由表一致。 没有这条,有人新增一个端点却忘了加进 `_ENDPOINTS`,上面那条会继续全绿 —— 一道只覆盖旧端点的闸比没有闸更糟,因为它让人以为覆盖了。 用 `app.openapi()` 而不是 `app.routes`:本仓 `app.routes` 遍历不出这些 路径(永远是空集),照它写这条断言会永远真空通过。 """ from app.main import app spec = app.openapi() actual = { (method.upper(), path) for path, ops in spec["paths"].items() if path.startswith("/api/v1/assistant/eval") for method in ops if method.upper() not in {"HEAD", "OPTIONS"} } listed = set() for method, path in _ENDPOINTS: template = path for segment, name in _PARAM_NAMES.items(): if segment in template: template = template.replace(f"{segment}{_UUID}", f"{segment}{{{name}}}") # ``/runs/{run_id}/results/{result_id}`` — the second uuid is its own # parameter and the loop above only rewrote the first. template = template.replace(_UUID, "{result_id}") listed.add((method, template)) assert actual, "路由一个都没扫到 —— 这条断言本身失效了,先修它" assert actual == listed, ( f"端点表和路由表对不上。\n只在路由里:{sorted(actual - listed)}\n" f"只在表里:{sorted(listed - actual)}" ) async def test_an_employee_token_is_not_enough(client, employee_headers): """普通员工看不到评测结果 —— 里面是完整的助手回答。""" resp = await client.get( "/api/v1/assistant/eval/overview", headers=employee_headers, ) assert resp.status_code == 403, resp.text async def test_an_admin_can_read_it(client, admin_headers): """闸别关过头:管理员必须读得到,否则「修好了」和「关掉了」分不出来。""" resp = await client.get( "/api/v1/assistant/eval/overview", headers=admin_headers, ) assert resp.status_code == 200, resp.text assert resp.json()["success"] is True async def test_an_auditor_can_read_but_cannot_write(client, auditor_headers): """审计能看评测(那是治理证据),但不能改题库、也不能花钱发起一次运行。""" read = await client.get( "/api/v1/assistant/eval/suites", headers=auditor_headers, ) assert read.status_code == 200, read.text write = await client.post( "/api/v1/assistant/eval/suites", json={"slug": "auditor-should-not", "name": "nope"}, headers=auditor_headers, ) assert write.status_code == 403, write.text run = await client.post( "/api/v1/assistant/eval/runs", json={"suite_id": _UUID}, headers=auditor_headers, ) assert run.status_code == 403, run.text