Files
FastapiAdmin/backend/tests/test_error_code_single_source.py
T
zhangtao 644d262cad refactor(backend): 错误码收敛为单一事实来源,统一异常写法
错误码(t16)
- HTTP 语义只由 core/exceptions.py 的 RET.code → fastapi.status 映射决定;
  映射表显式登记 -1→400 与全部「数值即 HTTP 状态」的 RET 成员,_DEFAULT_BUSINESS_STATUS
  降为「新增码忘登记」的安全网
- 移除冗余 status_code:131 处调用点改为纯 code 表达;security.py/dependencies.py
  的 status_code=401/403 去除(映射表已给);get_or_404 → code=RET.NOT_FOUND.code
- 新增静态护栏(AST):app/ 下除 exceptions.py 外不得出现 status_code 传参,
  路由装饰器 201 白名单显式放行并断言白名单非空

异常写法统一(t19)
- 删除 CustomException.internal 及 132 处包装:意外异常直接冒泡,由全局
  @app.exception_handler(Exception) 统一映射 5xx + 通用文案 + 日志;
  需要运维上下文处改为 logger.exception("<业务动作>失败") + raise
- 构造器不再接受 status_code 参数(属性保留,由 code 派生)→ 结构上杜绝第二来源
- 修复 6 处「宽泛 except 把 400 包成 500」的语义反转(补 except CustomException: raise),
  并加 AST 护栏防止回潮
- 全局 SQLAlchemyError:非完整性错误由 400 改为 503(连接类)/500(其它),
  不再把驱动原文/异常类型拼进客户端 msg;顺带修掉不存在的
  status.HTTP_403_SERVICE_UNAVAILABLE 常量(命中即 AttributeError→500)

验证:pytest 97 passed / ruff 全绿;AST 审计残留 0;护栏红绿测试通过;
线上实测验证码过期仍 400 并保留业务文案、无令牌 401。
2026-10-04 05:44:21 +08:00

261 lines
11 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.
"""错误码单一事实来源(t16)回归测试。
设计约定:
- HTTP 语义只由 ``RET.code → HTTP status``(``core/exceptions.py`` 的映射表)决定;
- 业务代码只传 ``code``;意外异常不在业务代码里包装,交给全局处理器映射为 5xx;
- 除 ``core/exceptions.py``(映射表与逃生口)外,``app/`` 下不得再出现 ``status_code=`` 传参。
本文件既锁语义(400/401/403/404/500),也加静态护栏防回潮。
"""
import ast
import json
import pathlib
import re
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.common.enums import RET
from app.core.exceptions import CustomException, handle_exception, resolve_http_status
from app.core.logger import logger
_APP_DIR = pathlib.Path(__file__).resolve().parent.parent / "app"
_EXCEPTIONS_FILE = _APP_DIR / "core" / "exceptions.py"
_STATUS_KWARG = "status_code="
# 唯一允许的例外:FastAPI 路由装饰器的框架参数(如 @Router.post(..., status_code=201)),
# 与「业务错误码 → HTTP 语义」无关;去掉它会改变创建类接口的 HTTP 语义(201→200),故白名单放行。
_ROUTE_DECORATOR_LINE = re.compile(r"^\s*@\w+\.(get|post|put|patch|delete|head|options)\(")
# ── 1) 静态护栏:业务代码不得显式传 status_code ───────────────────────────────
def test_no_explicit_status_code_in_business_code() -> None:
"""app/ 下(除 core/exceptions.py)不得出现 status_code= 传参。"""
violations: list[str] = []
allowed: list[str] = []
for path in sorted(_APP_DIR.rglob("*.py")):
if path == _EXCEPTIONS_FILE:
continue
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if _STATUS_KWARG not in line:
continue
record = f"{path.relative_to(_APP_DIR.parent)}:{lineno}: {line.strip()[:120]}"
if _ROUTE_DECORATOR_LINE.match(line):
allowed.append(record) # FastAPI 路由装饰器参数(框架语义,非业务错误码)
continue
violations.append(record)
assert violations == [], f"业务代码出现显式 status_code 传参(应改为 code=... 或 CustomException.internal):{violations}"
assert allowed, "白名单为空:若路由装饰器写法变化请同步更新本测试"
def test_no_custom_exception_passes_status_code() -> None:
"""更精确的护栏:CustomException 调用点一律不传 status_code(除 exceptions.py 自身)。"""
offenders: list[str] = []
call_re = re.compile(r"CustomException(?:\.\w+)?\(")
for path in sorted(_APP_DIR.rglob("*.py")):
if path == _EXCEPTIONS_FILE:
continue
text = path.read_text(encoding="utf-8")
for match in call_re.finditer(text):
depth, index = 0, match.end() - 1
while index < len(text):
if text[index] == "(":
depth += 1
elif text[index] == ")":
depth -= 1
if depth == 0:
break
index += 1
call = text[match.start() : index + 1]
if _STATUS_KWARG in call:
offenders.append(f"{path.relative_to(_APP_DIR.parent)}:{text[: match.start()].count(chr(10)) + 1}")
assert offenders == [], f"CustomException 仍显式传 status_code:{offenders}"
# ── 2) 映射表是唯一事实来源 ──────────────────────────────────────────────────
def test_mapping_table_is_explicit_for_default_business_code() -> None:
"""默认业务码 RET.EXCEPTION(-1) 显式登记为 400(不再依赖隐式兜底)。"""
assert resolve_http_status(RET.EXCEPTION.code) == 400
assert CustomException(msg="验证码已过期,请刷新").status_code == 400
def test_mapping_covers_http_like_codes() -> None:
"""凡是「数值本身就是 HTTP 状态码」的 RET 成员,映射结果必须与自身一致。"""
for member in RET:
if 400 <= member.code <= 599:
assert resolve_http_status(member.code) == member.code, f"{member.name}({member.code}) 映射错位"
def test_no_internal_entry_and_unexpected_errors_use_global_handler() -> None:
"""t19:internal() 语义入口已删除;意外异常交给全局处理器给 5xx + 通用文案。"""
assert not hasattr(CustomException, "internal")
assert resolve_http_status(RET.SERVERERR.code) == 500 # 码仍在映射表内,供显式声明服务端故障的业务码使用
app = FastAPI()
handle_exception(app)
@app.get("/unexpected")
async def _unexpected() -> None:
raise RuntimeError("统计失败: 连接断开 connection lost")
response = TestClient(app, raise_server_exceptions=False).get("/unexpected")
assert response.status_code == 500
body = response.json()
assert body["msg"] == "服务器内部错误"
assert "connection lost" not in json.dumps(body, ensure_ascii=False)
def test_unmapped_code_falls_back_to_client_error_net() -> None:
"""未登记 code 的安全网仍是 400(仅兜底,不应作为正常路径)。"""
assert resolve_http_status(999999) == 400
assert resolve_http_status(None) == 400
# ── 3) 语义不变(HTTP 层实测) ───────────────────────────────────────────────
def _semantic_probe_app() -> FastAPI:
app = FastAPI()
handle_exception(app)
@app.get("/captcha-expired")
async def _captcha() -> None:
raise CustomException(msg="验证码已过期,请刷新")
@app.get("/forbidden")
async def _forbidden() -> None:
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code)
@app.get("/token-expired")
async def _token() -> None:
raise CustomException(msg="认证已失效", code=RET.TOKEN_EXPIRED.code)
@app.get("/not-found")
async def _not_found() -> None:
raise CustomException(msg="该数据不存在", code=RET.NOT_FOUND.code)
@app.get("/internal")
async def _internal() -> None:
# 意外异常:不包装,直接冒泡给全局处理器(t19)
raise RuntimeError("统计失败: 模拟底层驱动故障")
return app
@pytest.mark.parametrize(
("path", "expected_status", "expected_code"),
[
("/captcha-expired", 400, RET.EXCEPTION.code),
("/forbidden", 403, RET.NO_PERMISSION.code),
("/token-expired", 401, RET.TOKEN_EXPIRED.code),
("/not-found", 404, RET.NOT_FOUND.code),
("/internal", 500, RET.ERROR.code),
],
)
def test_semantics_unchanged_via_mapping(path: str, expected_status: int, expected_code: int) -> None:
"""验证码/参数→400、无权限→403、令牌→401、资源不存在→404、内部故障→500,且响应体结构不变。"""
client = TestClient(_semantic_probe_app(), raise_server_exceptions=False)
response = client.get(path)
assert response.status_code == expected_status, response.text
body = response.json()
assert set(body) >= {"code", "msg", "data", "status_code", "success"}
assert body["code"] == expected_code
assert body["status_code"] == expected_status
assert body["msg"] # 诊断信息不丢
assert body["success"] is False
# ── 4) t19:业务异常透传 + 意外异常冒泡(AST 护栏) ───────────────────────────
def test_internal_faults_are_not_wrapped_locally() -> None:
"""132 处局部包装已清除:app/ 下不再出现 internal() 语义入口或 status_code 传参。"""
offenders: list[str] = []
for path in sorted(_APP_DIR.rglob("*.py")):
text = path.read_text(encoding="utf-8")
if "CustomException.internal" in text or "def internal(" in text:
offenders.append(str(path.relative_to(_APP_DIR.parent)))
assert offenders == [], f"仍存在局部包装入口:{offenders}"
def test_business_error_passes_through_try_and_stays_4xx() -> None:
"""try 内抛出的业务 400 穿过「日志 + raise」的宽泛 except 后仍是 400。"""
app = FastAPI()
handle_exception(app)
async def _service() -> None:
raise CustomException(msg="请求参数错误")
@app.get("/passthrough")
async def _passthrough() -> None:
try:
await _service()
except CustomException:
raise
except Exception as exc:
logger.exception("处理请求失败")
raise exc
response = TestClient(app, raise_server_exceptions=False).get("/passthrough")
assert response.status_code == 400, response.text
body = response.json()
assert body["msg"] == "请求参数错误"
assert body["code"] == RET.EXCEPTION.code
# 宽泛 except 若「构造新的 CustomException」抛出,必须先有 `except CustomException: raise`
_BROAD = {"Exception", "BaseException"}
def _is_custom_exception_name(node: ast.AST) -> bool:
if isinstance(node, ast.Name):
return node.id == "CustomException"
if isinstance(node, ast.Attribute):
return node.attr == "CustomException"
if isinstance(node, ast.Tuple):
return any(_is_custom_exception_name(e) for e in node.elts)
return False
def _is_broad_handler(handler: ast.ExceptHandler) -> bool:
t = handler.type
if t is None:
return True
if isinstance(t, ast.Name):
return t.id in _BROAD
if isinstance(t, ast.Tuple):
return any(isinstance(e, ast.Name) and e.id in _BROAD for e in t.elts)
return False
def test_ast_guard_broad_except_requires_custom_exception_passthrough() -> None:
"""AST 护栏:宽泛 except 若转换异常,必须前置 `except CustomException: raise`(防语义反转回潮)。"""
offenders: list[str] = []
for path in sorted(_APP_DIR.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, ast.Try):
continue
guarded = any(
h.type is not None
and _is_custom_exception_name(h.type)
and any(isinstance(stmt, ast.Raise) and stmt.exc is None for stmt in h.body)
for h in node.handlers
)
for handler in node.handlers:
if not _is_broad_handler(handler) or guarded:
continue
for inner in ast.walk(handler):
if (
isinstance(inner, ast.Raise)
and isinstance(inner.exc, ast.Call)
and getattr(inner.exc.func, "id", None) == "CustomException"
):
offenders.append(f"{path.relative_to(_APP_DIR.parent)}:{inner.lineno}")
assert offenders == [], f"宽泛 except 转换业务异常却缺守卫(应加 except CustomException: raise):{offenders}"