mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-14 13:06:51 +00:00
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。
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
"""错误码单一事实来源(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}"
|
||||
@@ -1,12 +1,13 @@
|
||||
"""安全默认值与异常状态码回归测试。
|
||||
|
||||
覆盖 backend/audit-security.md 的 S9/S10 与 backend/audit-backend.md 的 H1/H5 同类根因:
|
||||
- 业务异常按自身状态码返回:客户端可修正类(参数/验证码/权限/不存在)为 4xx;
|
||||
- 「包装底层异常」的内部故障(DB/驱动/Redis/SDK)显式为 500,不得降级成 4xx(否则监控误判);
|
||||
- 业务异常一律只传 code(HTTP 状态由映射表推导):参数/验证码 400、权限 403、不存在 404;
|
||||
- 意外异常不在业务代码里包装(t19):原始异常冒泡,由全局处理器给 5xx + 通用文案 + 日志;
|
||||
- 定时任务代码块执行默认关闭,关闭时给出明确失败原因(而非静默执行/静默跳过);
|
||||
- 生产环境 CORS 不再回落为通配 ``["*"]``(与 ``ALLOW_CREDENTIALS=True`` 组合会放行任意站点带凭据跨域)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -21,6 +22,7 @@ from app.common.enums import RET, EnvironmentEnum
|
||||
from app.config.setting import Settings, settings
|
||||
from app.core.ap_scheduler import SchedulerUtil
|
||||
from app.core.exceptions import CustomException, handle_exception, resolve_http_status
|
||||
from app.core.logger import logger
|
||||
from app.core.middlewares import CustomCORSMiddleware
|
||||
from app.modules.task.cronjob.node.service import _add_job_with_trigger
|
||||
|
||||
@@ -36,9 +38,9 @@ def test_custom_exception_defaults_to_4xx() -> None:
|
||||
|
||||
|
||||
def test_custom_exception_status_resolution() -> None:
|
||||
"""显式状态码优先;否则按业务码映射;未知码回落 400。"""
|
||||
assert CustomException(msg="x", status_code=403).status_code == 403
|
||||
assert CustomException(msg="x", status_code=500).status_code == 500
|
||||
"""HTTP 状态只能由 code 经映射表推导(t19:构造器已不接受 status_code)。"""
|
||||
with pytest.raises(TypeError):
|
||||
CustomException(msg="x", status_code=403)
|
||||
assert CustomException(msg="x", code=RET.UNAUTHORIZED.code).status_code == 401
|
||||
assert CustomException(msg="x", code=RET.NOT_FOUND.code).status_code == 404
|
||||
assert CustomException(msg="x", code=RET.NO_PERMISSION.code).status_code == 403
|
||||
@@ -59,7 +61,7 @@ def _exception_probe_app() -> FastAPI:
|
||||
|
||||
@app.get("/forbidden")
|
||||
async def _forbidden() -> None:
|
||||
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code, status_code=403)
|
||||
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code)
|
||||
|
||||
@app.get("/boom")
|
||||
async def _boom() -> None:
|
||||
@@ -80,8 +82,8 @@ def test_captcha_expired_returns_4xx_with_stable_body() -> None:
|
||||
assert body["status_code"] == 400
|
||||
|
||||
|
||||
def test_explicit_status_code_is_honored_by_handler() -> None:
|
||||
"""显式状态码仍按原样返回(403 不被 400 覆盖)。"""
|
||||
def test_forbidden_code_maps_to_403_via_table() -> None:
|
||||
"""无权限业务码经映射表得到 403(不依赖显式 status_code)。"""
|
||||
client = TestClient(_exception_probe_app(), raise_server_exceptions=False)
|
||||
response = client.get("/forbidden")
|
||||
assert response.status_code == 403, response.text
|
||||
@@ -293,13 +295,12 @@ def _crud(db: object, model: type | None = None):
|
||||
("set", lambda c: c.set(ids=[1], status=0)),
|
||||
],
|
||||
)
|
||||
async def test_base_crud_wrapped_internal_failure_returns_500(case: str, call) -> None:
|
||||
"""CRUD 公共出口包装底层异常时必须 500(此前被 t10 降级成 400)。"""
|
||||
with pytest.raises(CustomException) as excinfo:
|
||||
async def test_base_crud_internal_failure_bubbles_original_exception(case: str, call) -> None:
|
||||
"""CRUD 公共出口不再包装意外异常:原始异常(含类型与信息)原样冒泡,由全局处理器给 5xx。"""
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await call(_crud(_FailingSession()))
|
||||
assert excinfo.value.status_code == 500, f"{case} 未按 500 返回(实际 {excinfo.value.status_code})"
|
||||
assert excinfo.value.success is False
|
||||
assert "失败" in excinfo.value.msg
|
||||
assert "connection lost" in str(excinfo.value), f"{case} 丢掉了原始错误信息"
|
||||
assert not isinstance(excinfo.value, CustomException), f"{case} 仍被包装成业务异常"
|
||||
|
||||
|
||||
async def test_get_or_404_returns_404_for_missing_row() -> None:
|
||||
@@ -315,32 +316,33 @@ def test_client_side_errors_keep_4xx() -> None:
|
||||
assert CustomException(msg="验证码已过期,请刷新").status_code == 400
|
||||
assert CustomException(msg="请求参数错误").status_code == 400
|
||||
assert CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code).status_code == 403
|
||||
assert CustomException(msg="该数据不存在", status_code=404).status_code == 404
|
||||
assert CustomException(msg="该数据不存在", code=RET.NOT_FOUND.code).status_code == 404
|
||||
assert resolve_http_status(RET.NOT_FOUND.code) == 404
|
||||
assert resolve_http_status(RET.NO_PERMISSION.code) == 403
|
||||
assert resolve_http_status(RET.BAD_REQUEST.code) == 400
|
||||
assert resolve_http_status(RET.SERVERERR.code) == 500
|
||||
|
||||
|
||||
def test_internal_failure_body_keeps_business_code_and_message() -> None:
|
||||
"""内部故障 500 时响应体仍带原始 msg 与业务 code(前端依赖 code=-1 展示真实原因)。"""
|
||||
def test_internal_failure_body_is_generic_and_hides_details() -> None:
|
||||
"""意外异常 → 全局处理器:500 + 通用文案,内部细节只进日志(t19)。"""
|
||||
app = FastAPI()
|
||||
handle_exception(app)
|
||||
|
||||
@app.get("/internal")
|
||||
async def _internal() -> None:
|
||||
raise CustomException(msg="统计失败: 模拟底层驱动故障", status_code=500)
|
||||
raise RuntimeError("统计失败: 模拟底层驱动故障 connection lost")
|
||||
|
||||
response = TestClient(app, raise_server_exceptions=False).get("/internal")
|
||||
assert response.status_code == 500
|
||||
body = response.json()
|
||||
assert body["msg"] == "统计失败: 模拟底层驱动故障"
|
||||
assert body["code"] == RET.EXCEPTION.code
|
||||
assert body["msg"] == "服务器内部错误"
|
||||
assert "connection lost" not in json.dumps(body, ensure_ascii=False)
|
||||
assert body["code"] == RET.ERROR.code
|
||||
|
||||
|
||||
# 与实现同源的分类规则(见 app/core/exceptions.py 的说明与 t11 的清理范围)
|
||||
_EXC_INTERP = re.compile(r"\{(?:e|exc|err|error|ex|exception)(?:!s|!r|:[a-z]+)?\}|\{errors\[|_error_desc\(")
|
||||
_EXC_CALL = re.compile(r"CustomException\(")
|
||||
_EXC_CALL = re.compile(r"CustomException(?:\.\w+)?\(")
|
||||
_CLIENT_MARKERS = (
|
||||
"targets 参数格式错误",
|
||||
"创建成功但任务注册失败",
|
||||
@@ -384,45 +386,46 @@ def _iter_custom_exception_calls(path: pathlib.Path):
|
||||
yield text[: match.start()].count("\n") + 1, text[match.start() : index + 1]
|
||||
|
||||
|
||||
def test_all_wrapped_internal_failures_are_explicitly_500() -> None:
|
||||
"""守卫:inScope 内凡是「包装底层异常」的 CustomException 都必须显式 500。
|
||||
def test_no_internal_wrapping_helper_anywhere() -> None:
|
||||
"""守卫(t19):``CustomException.internal`` 已彻底移除——意外异常交给全局处理器。
|
||||
|
||||
否则底层故障会被默认值(400)静默降级成客户端错误,监控失去信号。
|
||||
同时确认原来的「包装点」已改为「logger.exception(...) + raise」或直接冒泡。
|
||||
"""
|
||||
offenders: list[str] = []
|
||||
checked = 0
|
||||
targets = [_REPO_ROOT / "app" / "core" / "base_crud.py"] + sorted((_REPO_ROOT / "app" / "modules").rglob("*.py"))
|
||||
for path in targets:
|
||||
for line, call in _iter_custom_exception_calls(path):
|
||||
if not _EXC_INTERP.search(call):
|
||||
continue
|
||||
if any(marker in call for marker in _CLIENT_MARKERS):
|
||||
continue
|
||||
checked += 1
|
||||
if "status_code=500" not in call:
|
||||
offenders.append(f"{path.relative_to(_REPO_ROOT)}:{line}")
|
||||
assert checked > 90, f"分类用例数异常(只匹配到 {checked} 处),分类规则可能失效"
|
||||
assert offenders == [], f"以下代码生成路径仍可能把内部故障降级为 4xx:{offenders}"
|
||||
for path in sorted((_REPO_ROOT / "app").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(_REPO_ROOT)))
|
||||
assert offenders == [], f"仍存在 internal 语义入口:{offenders}"
|
||||
assert not hasattr(CustomException, "internal")
|
||||
|
||||
logged = (_REPO_ROOT / "app" / "core" / "base_crud.py").read_text(encoding="utf-8")
|
||||
assert logged.count("logger.exception(") >= 9, "base_crud 的公共出口应改为 logger.exception + raise"
|
||||
assert 'raise CustomException.internal' not in logged
|
||||
|
||||
|
||||
def test_base_crud_internal_wrappers_are_500() -> None:
|
||||
"""core 层专门固化:base_crud.py 全部 9 处包装路径显式 500。"""
|
||||
calls = [
|
||||
(line, call)
|
||||
for line, call in _iter_custom_exception_calls(_REPO_ROOT / "app" / "core" / "base_crud.py")
|
||||
if _EXC_INTERP.search(call)
|
||||
]
|
||||
assert len(calls) == 9, f"base_crud.py 包装路径数量变化({len(calls)}):{[(line, call[:30]) for line, call in calls]}"
|
||||
missing = [line for line, call in calls if "status_code=500" not in call]
|
||||
assert missing == [], f"base_crud.py 以下行未显式 500:{missing}"
|
||||
def test_business_errors_can_pass_through_broad_except_guards() -> None:
|
||||
"""示范正确写法:宽泛 except 前先 `except CustomException: raise`,业务 400 不被改写。"""
|
||||
app = FastAPI()
|
||||
handle_exception(app)
|
||||
|
||||
async def _service() -> None:
|
||||
raise CustomException(msg="该数据不存在", code=RET.NOT_FOUND.code)
|
||||
|
||||
# ── 5. 升级可见性:CORS 严格启动、主机放行开关、健康检查字段 ──────────────────
|
||||
#
|
||||
# 背景:t13 把三处「静默行为变更」做成可配置 + 可观测:
|
||||
# - CORS_STRICT_STARTUP:生产未配置 CORS 白名单时从「只告警」变为「启动失败」;
|
||||
# - ALLOW_LOCALHOST_HOSTS:localhost 放行从无条件写死变为可开关;
|
||||
# - 健康检查暴露 code_exec_enabled / cors_origins_configured,避免升级后「任务不动了」无迹可循。
|
||||
@app.get("/guarded")
|
||||
async def _guarded() -> None:
|
||||
try:
|
||||
await _service()
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("查询业务数据失败")
|
||||
raise
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.get("/guarded")
|
||||
assert response.status_code == 404, response.text
|
||||
assert response.json()["msg"] == "该数据不存在"
|
||||
|
||||
|
||||
def test_cors_strict_startup_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user