"""知识循环的每一个端点都要挡住未认证请求,以及挡住普通员工。 多一层理由:接受一条提案改变的是**每个员工被告知的内容**。所以读是 global_admin + auditor(审计读得到,那是治理),写只有 global_admin —— 与知识库管理面本身的闸一致。 """ from __future__ import annotations import pytest pytestmark = pytest.mark.asyncio _UUID = "00000000-0000-0000-0000-000000000000" _ENDPOINTS: list[tuple[str, str]] = [ ("GET", "/api/v1/knowledge/proposals"), ("GET", "/api/v1/knowledge/proposals/summary"), ("GET", f"/api/v1/knowledge/proposals/{_UUID}"), ("POST", f"/api/v1/knowledge/proposals/{_UUID}/decide"), ("POST", "/api/v1/knowledge/proposals/bulk-decide"), ("POST", "/api/v1/knowledge/lint"), ("POST", "/api/v1/knowledge/backfill/answers"), ("POST", f"/api/v1/knowledge/backfill/eval-run/{_UUID}"), ("GET", "/api/v1/knowledge/kb/index"), ("GET", f"/api/v1/knowledge/kb/articles/{_UUID}/links"), ("DELETE", f"/api/v1/knowledge/kb/links/{_UUID}"), ] _PARAM_NAMES = { "/proposals/": "proposal_id", "/eval-run/": "run_id", "/articles/": "article_id", "/links/": "link_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/knowledge") 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 f"{segment}{_UUID}" in template: template = template.replace( f"{segment}{_UUID}", f"{segment}{{{name}}}", ) listed.add((method, template)) assert actual, "路由一个都没扫到 —— 这条断言本身失效了,先修它" assert actual == listed, ( f"端点表和路由表对不上。\n只在路由里:{sorted(actual - listed)}\n" f"只在表里:{sorted(listed - actual)}" ) async def test_an_employee_cannot_read_the_queue(client, employee_headers): resp = await client.get( "/api/v1/knowledge/proposals", 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/knowledge/proposals/summary", headers=admin_headers, ) assert resp.status_code == 200, resp.text registered = resp.json()["data"]["registered"] # 断言「包含」而不是「等于」:每接进一套新语料就要来改一次的相等断言, # 只会训练出「把它改成新值」这个动作,而不是去想为什么变了。 # 每一个语料都要单独点名 —— 没注册处理器的语料会收下提案然后一条都落不了地, # 而那个失败只在评审人第一次点击时才会出现。 assert "support_kb" in registered, "IT 知识库的处理器没有注册" assert "security_faq" in registered, "安全答案库的处理器没有注册" async def test_an_auditor_reads_but_does_not_decide(client, auditor_headers): """审计读得到(治理),但不能改变每个员工被告知的内容。""" read = await client.get( "/api/v1/knowledge/proposals", headers=auditor_headers, ) assert read.status_code == 200, read.text decide = await client.post( f"/api/v1/knowledge/proposals/{_UUID}/decide", json={"accept": True}, headers=auditor_headers, ) assert decide.status_code == 403, decide.text lint = await client.post("/api/v1/knowledge/lint", headers=auditor_headers) assert lint.status_code == 403, lint.text