151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
"""模块边界防呆测试(配合《风控Agent模块边界与合并接缝标注.md》§3)。
|
||||
|
|
|
|||
|
|
风控 Agent 按"独立封装模块"自治:模块私有实现自己维护,与宿主(main)的耦合
|
|||
|
|
只走 4 个接缝。本文件把这层约定写成断言——**AL-09 合并 main 后必须全绿**,
|
|||
|
|
任何一条红了都说明模块被宿主侵蚀或接缝被破坏,应先修边界再继续。
|
|||
|
|
|
|||
|
|
四类断言:
|
|||
|
|
1. 模块私有文件未被误删;
|
|||
|
|
2. 模块代码不得 import 宿主私有实现(防双套串味);
|
|||
|
|
3. 模块 AuthContext 契约未被宿主类替换;
|
|||
|
|
4. 模块私有 settings 字段未被合并丢掉。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import re
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from app.api.deps import AuthContext
|
|||
|
|
from app.config.settings import settings
|
|||
|
|
from app.utils.authz import AGENT_TYPES
|
|||
|
|
|
|||
|
|
APP_DIR = Path(__file__).resolve().parents[1] / "app"
|
|||
|
|
|
|||
|
|
# 模块私有关键文件(A 类):缺失即说明被误删或合并时被宿主覆盖
|
|||
|
|
MODULE_PRIVATE_FILES = [
|
|||
|
|
"app/api/deps.py",
|
|||
|
|
"app/api/risk.py",
|
|||
|
|
"app/api/simulate.py",
|
|||
|
|
"app/service/auth_service.py",
|
|||
|
|
"app/service/input_guard.py",
|
|||
|
|
"app/service/suitability.py",
|
|||
|
|
"app/service/tool_service.py",
|
|||
|
|
"app/repository/core_ro.py",
|
|||
|
|
"app/repository/risk_repository.py",
|
|||
|
|
"app/repository/session_repository.py",
|
|||
|
|
"app/gateway/trade_gateway.py",
|
|||
|
|
"app/model/suitability.py",
|
|||
|
|
"app/utils/db.py",
|
|||
|
|
"app/utils/trace.py",
|
|||
|
|
"app/api/audit_middleware.py",
|
|||
|
|
"app/service/risk/engine.py",
|
|||
|
|
"app/service/risk/alert_service.py",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# 禁止模块代码导入的宿主私有实现(D 类 / B 类宿主侧)
|
|||
|
|
FORBIDDEN_IMPORT_PREFIXES = (
|
|||
|
|
"from app.gateway.auth_deps",
|
|||
|
|
"from app.gateway.jwt_service",
|
|||
|
|
"from app.gateway.rbac",
|
|||
|
|
"from app.gateway.ownership",
|
|||
|
|
"from app.config.database",
|
|||
|
|
"from app.middleware",
|
|||
|
|
"from app.utils.input_guard", # 模块用 app.service.input_guard(含限流与留痕)
|
|||
|
|
"from app.model.schemas", # 宿主 AuthContext,须经 app.api.auth_adapter 转换
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 模块私有 settings 字段(合并时一个都不能丢)
|
|||
|
|
MODULE_SETTINGS_FIELDS = (
|
|||
|
|
"mysql_core_database",
|
|||
|
|
"redis_url",
|
|||
|
|
"milvus_uri",
|
|||
|
|
"ollama_base_url",
|
|||
|
|
"embed_model",
|
|||
|
|
"embed_dim",
|
|||
|
|
"embed_timeout_seconds",
|
|||
|
|
"deepseek_api_key",
|
|||
|
|
"deepseek_base_url",
|
|||
|
|
"risk_large_amount",
|
|||
|
|
"risk_daily_total",
|
|||
|
|
"risk_freq_count",
|
|||
|
|
"risk_probe_window_minutes",
|
|||
|
|
"risk_probe_count",
|
|||
|
|
"risk_probe_amount",
|
|||
|
|
"risk_small_amount",
|
|||
|
|
"risk_small_count",
|
|||
|
|
"risk_aml_default_threshold",
|
|||
|
|
"guard_rate_limit_max",
|
|||
|
|
"guard_rate_limit_window_seconds",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_IMPORT_LINE = re.compile(r"^\s*(from|import)\s+")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _iter_module_py_files():
|
|||
|
|
for path in APP_DIR.rglob("*.py"):
|
|||
|
|
if "__pycache__" in path.parts:
|
|||
|
|
continue
|
|||
|
|
yield path
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("rel_path", MODULE_PRIVATE_FILES)
|
|||
|
|
def test_module_private_file_exists(rel_path):
|
|||
|
|
"""模块私有文件必须存在(防合并时被删除/覆盖)。"""
|
|||
|
|
assert (APP_DIR.parent / rel_path).is_file(), f"模块私有文件缺失:{rel_path}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_no_host_private_imports():
|
|||
|
|
"""模块代码不得直接 import 宿主私有实现——跨层一律走接缝。"""
|
|||
|
|
offenders: list[str] = []
|
|||
|
|
for path in _iter_module_py_files():
|
|||
|
|
for lineno, line in enumerate(
|
|||
|
|
path.read_text(encoding="utf-8", errors="ignore").splitlines(), start=1
|
|||
|
|
):
|
|||
|
|
stripped = line.strip()
|
|||
|
|
if not _IMPORT_LINE.match(stripped):
|
|||
|
|
continue # 只看真正的 import 行,避免注释/文档字符串误报
|
|||
|
|
if stripped.startswith(FORBIDDEN_IMPORT_PREFIXES):
|
|||
|
|
rel = path.relative_to(APP_DIR.parent).as_posix()
|
|||
|
|
offenders.append(f"{rel}:{lineno} -> {stripped}")
|
|||
|
|
|
|||
|
|
assert not offenders, "发现跨层导入宿主私有实现(应改走接缝):\n" + "\n".join(offenders)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_auth_context_contract_intact():
|
|||
|
|
"""模块 AuthContext 契约必须完整——防被宿主的 schemas.AuthContext 替换。
|
|||
|
|
|
|||
|
|
宿主用 sub/trace_id/agent_type、has_role 单参、has_perm;
|
|||
|
|
模块用 actor_id、has_role 多参、has_permission。字段名或方法名一变,
|
|||
|
|
模块内全量引用会静默失效,故在此锁死。
|
|||
|
|
"""
|
|||
|
|
for field in (
|
|||
|
|
"actor_id",
|
|||
|
|
"roles",
|
|||
|
|
"customer_id",
|
|||
|
|
"token_type",
|
|||
|
|
"permissions",
|
|||
|
|
"tenant_id",
|
|||
|
|
"jti",
|
|||
|
|
):
|
|||
|
|
assert field in AuthContext.model_fields, f"AuthContext 缺失字段:{field}"
|
|||
|
|
|
|||
|
|
ctx = AuthContext(actor_id="STAFF-90001", roles=["risk_officer", "risk_demo"])
|
|||
|
|
assert ctx.has_role("risk_officer", "compliance") is True # 多参语义
|
|||
|
|
assert ctx.has_role("advisor") is False
|
|||
|
|
assert ctx.has_permission("risk:alert:write") is False
|
|||
|
|
assert ctx.is_customer() is False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_module_settings_fields_present():
|
|||
|
|
"""模块私有配置字段必须齐全(防合并 settings.py 时被丢)。"""
|
|||
|
|
missing = [f for f in MODULE_SETTINGS_FIELDS if not hasattr(settings, f)]
|
|||
|
|
assert not missing, f"settings 丢失模块私有字段:{missing}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_agent_types_contract():
|
|||
|
|
"""Agent 类型四值须与宿主一致(这是少数双方天然对齐的契约,不得漂移)。"""
|
|||
|
|
assert AGENT_TYPES == ("customer", "advisor", "analyst", "risk")
|