mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:11:58 +00:00
e82d7fa55d
外部系统只能按中文句子认是哪种错,文案一改就悄悄失灵;开了登录,接口说明仍然不用登录就能读,全部接口的结构对匿名的人 敞开(中间件只拦 /api/ 开头的路径)。 错误码(detail 文字一字不改,老前端照常显示;码都登记在 api/coded.py): - api/apps.py 13 处:应用不存在(app_not_found,同入口发起时的码;没有授权的人也回它)、版本、实例不存在,钩子没声明、 没生成密钥,岗位绑定、个人应用草稿、授权、页面参数不合规;派生时工作流、版本不存在(apps/derive.py 的 DeriveRefused 带上码);视图、入口、补算的拒绝原样带码,只有引擎不发起运行(LaunchRefused)没带码的几种补上 run_launch_refused - api/tasks.py 3 处:待办不存在、查看全部要搭建者(role_required);服务层没带码的拒绝按状态补上同一类的码 (404 task_not_found、403 task_forbidden、409 task_closed、422 task_invalid),没改 process/tasks.py - api/artifacts.py 5 处:没有权限、这类原文只给搭建者、不存在、内容和哈希对不上 - api/deps.py 的 _forbidden(run_forbidden)、运行和证据接口的角色拒绝(role_required)、个人运行只给拥有者 (run_private)、问数据(chat_forbidden);ensure_role / 路由级依赖走 auth.require,抽出 auth.refusal 一处判断, 没登录 401 auth_required、角色不够 403 role_required /docs:开登录时中间件把 /docs(含子路径)、/redoc、/openapi.json 当成 /api 一样拦,和路由级依赖用同一个 auth.refusal: 匿名 401,不到搭建者 403。关登录照旧(test_provenance_contract.py 的断言不受影响)。 测试 tests/test_integration_errors.py:这五个模块里不许再直接抛 HTTPException(静态检查);应用、待办、工件、角色、运行 拒绝逐类核对 code 和原 detail;/docs 关登录照旧 200,开登录匿名 401、使用者 403、搭建者 200。去掉中间件的改动回跑, 开登录时匿名打开 /docs 是 200,测试失败。
160 lines
7.3 KiB
Python
160 lines
7.3 KiB
Python
"""集成接口的错误码与 /docs(04 细案 A7)。
|
||
|
||
外部系统按 code 认是哪种错,不按中文句子:应用(api/apps.py)、待办(api/tasks.py)、工件(api/artifacts.py)的错误,
|
||
和角色、运行读权限的拒绝(api/deps.py、auth.require)全部带 code;detail 还是原来那一句,老前端照常显示。
|
||
|
||
开了登录,/docs、/redoc、/openapi.json 和 /api 一样要登录、要搭建者:以前中间件只拦 /api/ 开头的路径,接口全貌对
|
||
匿名的人敞开。关登录照旧(tests/test_provenance_contract.py 的断言不受影响)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import ast
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.api import coded
|
||
from app.db.base import SessionLocal
|
||
from app.db.models_apps import App
|
||
from app.main import app as main_app
|
||
from tests.fakes import auth_env
|
||
|
||
APP_DIR = Path(__file__).resolve().parents[1] / "app"
|
||
DOCS = ("/docs", "/redoc", "/openapi.json")
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def clean():
|
||
await auth_env.reset()
|
||
try:
|
||
yield
|
||
finally:
|
||
await auth_env.reset()
|
||
|
||
|
||
def client() -> AsyncClient:
|
||
return AsyncClient(transport=ASGITransport(app=main_app), base_url="http://test")
|
||
|
||
|
||
async def new_app() -> str:
|
||
async with SessionLocal() as session:
|
||
row = App(name=f"报销-{uuid.uuid4().hex[:6]}")
|
||
session.add(row)
|
||
await session.commit()
|
||
return row.id
|
||
|
||
|
||
def test_no_bare_http_exception_left_in_the_integration_modules():
|
||
"""这几个模块里不许再直接抛 HTTPException(没有 code):一律 CodedHTTPException。"""
|
||
for rel in ("api/apps.py", "api/tasks.py", "api/artifacts.py", "api/deps.py", "auth/__init__.py"):
|
||
tree = ast.parse((APP_DIR / rel).read_text())
|
||
bare = [node.lineno for node in ast.walk(tree) if isinstance(node, ast.Call)
|
||
and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) == "HTTPException"]
|
||
assert not bare, f"{rel} 第 {bare} 行抛的 HTTPException 没有 code"
|
||
|
||
|
||
def test_codes_agree_with_the_ones_already_in_use():
|
||
from app.apps import entries
|
||
|
||
assert coded.APP_NOT_FOUND == entries.APP_GONE, "入口发起时的「应用不存在」和这里同一个码"
|
||
|
||
|
||
async def test_app_errors_carry_codes_and_keep_their_detail():
|
||
app_id = await new_app()
|
||
async with client() as c:
|
||
r = await c.get("/api/apps/nope-nope")
|
||
assert (r.status_code, r.json()) == (404, {"detail": "应用不存在,可能已被删除", "code": "app_not_found"})
|
||
r = await c.get(f"/api/apps/{app_id}/versions/9")
|
||
assert r.status_code == 404 and r.json()["code"] == "app_version_not_found" and "没有 v9" in r.json()["detail"]
|
||
r = await c.get(f"/api/apps/{app_id}/pages/home", params={"params": "[1, 2]", "draft": 1})
|
||
assert (r.status_code, r.json()) == (422, {"detail": "页面参数需要是 JSON 对象", "code": "app_params_invalid"})
|
||
r = await c.patch(f"/api/apps/{app_id}", json={"draft_bindings": {"slots": {"财务": "不是列表"}}})
|
||
assert r.status_code == 422 and r.json()["code"] == "app_bindings_invalid", r.text
|
||
r = await c.post(f"/api/apps/{app_id}/hooks/nope/rotate")
|
||
assert r.status_code == 404 and r.json()["code"] == "app_hook_not_declared"
|
||
r = await c.patch(f"/api/apps/{app_id}/hooks/nope", json={"enabled": False})
|
||
assert r.status_code == 404 and r.json()["code"] == "app_hook_not_found", r.text
|
||
r = await c.post("/api/apps/derive", json={"workflow_id": "nope-nope", "version": 1})
|
||
assert (r.status_code, r.json()["code"]) == (404, "workflow_not_found")
|
||
|
||
|
||
async def test_task_errors_carry_codes():
|
||
async with client() as c:
|
||
r = await c.get("/api/tasks/nope-nope")
|
||
assert (r.status_code, r.json()) == (404, {"detail": "待办不存在,所属的运行可能已被删除", "code": "task_not_found"})
|
||
# 服务层没带码的拒绝(这里是待办不存在)按状态补上同一类的码
|
||
r = await c.post("/api/tasks/nope-nope/respond", json={"decision": "approve"})
|
||
assert (r.status_code, r.json()["code"]) == (404, "task_not_found")
|
||
r = await c.post("/api/tasks/nope-nope/remind")
|
||
assert (r.status_code, r.json()["code"]) == (404, "task_not_found")
|
||
|
||
|
||
async def test_artifact_errors_carry_codes():
|
||
async with client() as c:
|
||
r = await c.get(f"/api/artifacts/{'0' * 64}")
|
||
assert (r.status_code, r.json()) == (404, {"detail": "工件不存在,可能已被删除", "code": "artifact_not_found"})
|
||
|
||
|
||
async def test_role_and_run_refusals_carry_codes():
|
||
await auth_env.turn_on_local()
|
||
await auth_env.make_user("uma", "user")
|
||
uma = await auth_env.login("uma")
|
||
r = await uma.get("/api/workflows")
|
||
assert r.status_code == 403 and r.json() == {"detail": "没有权限:这项操作需要搭建者及以上的角色",
|
||
"code": "role_required"}
|
||
r = await uma.get("/api/tasks", params={"assigned": "all"})
|
||
assert r.status_code == 403 and r.json()["code"] == "role_required"
|
||
r = await uma.get("/api/runs/nope-nope/evidence")
|
||
assert r.status_code == 403 and r.json() == {"detail": "没有权限查看这次运行", "code": "run_forbidden"}
|
||
r = await uma.get(f"/api/artifacts/{'0' * 64}")
|
||
assert r.status_code == 403 and r.json() == {"detail": "没有权限查看这件工件", "code": "artifact_forbidden"}
|
||
r = await uma.get("/api/runs")
|
||
assert r.status_code == 403 and r.json()["code"] == "role_required"
|
||
|
||
|
||
async def test_ensure_role_inside_an_endpoint_carries_codes():
|
||
from app.api.deps import Principal, ensure_role, reset_principal, set_principal
|
||
|
||
for principal, status, code in ((Principal(username=None, role="", mode="local"), 401, "auth_required"),
|
||
(Principal(username="bob", role="builder", verified=True, mode="local"), 403,
|
||
"role_required")):
|
||
token = set_principal(principal)
|
||
try:
|
||
with pytest.raises(coded.CodedHTTPException) as e:
|
||
ensure_role("admin")
|
||
finally:
|
||
reset_principal(token)
|
||
assert (e.value.status_code, e.value.code) == (status, code)
|
||
assert "管理员" in e.value.detail
|
||
|
||
|
||
# ---------------------------------------------------------------- /docs
|
||
|
||
|
||
async def test_docs_stay_open_when_login_is_off():
|
||
async with client() as c:
|
||
for path in DOCS:
|
||
assert (await c.get(path)).status_code == 200, path
|
||
|
||
|
||
async def test_docs_need_a_builder_once_login_is_on():
|
||
await auth_env.turn_on_local()
|
||
await auth_env.make_user("uma", "user")
|
||
await auth_env.make_user("bob", "builder")
|
||
async with client() as anonymous:
|
||
for path in DOCS:
|
||
r = await anonymous.get(path)
|
||
assert r.status_code == 401 and r.json()["code"] == "auth_required", path
|
||
# 子路径(Swagger 的 OAuth 回跳页)一样
|
||
assert (await anonymous.get("/docs/oauth2-redirect")).status_code == 401
|
||
uma = await auth_env.login("uma")
|
||
for path in DOCS:
|
||
r = await uma.get(path)
|
||
assert r.status_code == 403 and r.json()["code"] == "role_required", path
|
||
bob = await auth_env.login("bob")
|
||
for path in DOCS:
|
||
assert (await bob.get(path)).status_code == 200, path
|
||
assert "/api/apps/{app_id}" in (await bob.get("/openapi.json")).json()["paths"]
|