Files
AgentLab/backend/tests/test_integration_errors.py
T
ThearchyHelios e82d7fa55d 集成接口补齐错误码,开登录时收起 /docs、/redoc、/openapi.json(要搭建者登录)
外部系统只能按中文句子认是哪种错,文案一改就悄悄失灵;开了登录,接口说明仍然不用登录就能读,全部接口的结构对匿名的人
敞开(中间件只拦 /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,测试失败。
2026-10-16 10:19:50 +08:00

160 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""集成接口的错误码与 /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"]