Files
group_xinghuo_jinrong/tests/test_module_boundary.py
T
GaoYiYuan_0626 8c226f0c6d chore: 风控Agent模块自治边界标注 + AL-09 合并预处置
背景:远端 main 新提交 3995cb4(09-07 17:20,交接文档漏记)为 Wave 0 鉴权/
chat/防护平行实现,与已完工 T-01/T-02/T-03/T-06 同名不同路径;试合并实测
20 文件冲突(原记 9 个),另有 13 个 main 新增文件不报冲突会静默并入。
拍板:风控 Agent 按独立封装模块自治,与宿主耦合收敛到 4 个接缝。

1. 《风控Agent模块边界与合并接缝标注》入库存档:A~D 四类文件归属表;
   4 接缝(S1 挂载点 main.py / S2 AuthContext / S3 settings / S4 引擎工厂);
   20 冲突文件逐个裁决(core_ro、model/suitability、conftest、02-seed-base
   以模块版为准;chat/main/settings/agent_service 等公共层以 main 为主);
   三处硬伤处置:issuer 不一致改为适配器映射不统一、STAFF-90001 必保、
   main infer_roles 未知 actor 默认 analyst(fail-open)记宿主侧 P1。
2. app/api/auth_adapter.py:S2 接缝适配器预制件(当前未接线,AL-09 接入)。
   鸭子类型读宿主 ctx 故不依赖宿主文件;sub→actor_id、trace_id→contextvar、
   perm_matches 兼容宿主 `前缀:*` 通配;缺主体即 HostAuthAdapterError,
   fail-closed 不静默降级。
3. tests/test_module_boundary.py:边界防呆 4 类断言——模块私有文件存在、
   禁止跨层 import 宿主私有实现(gateway.*/config.database/middleware.*/
   utils.input_guard/model.schemas)、AuthContext 契约完整(actor_id 与
   has_role 多参)、settings 私有字段与 AGENT_TYPES 四值不漂移。
4. tests/test_auth_adapter.py:适配器 11 例(映射/回退/fail-closed/trace 绑定/通配)。

基线:406 → 436 全绿(演示库已按演练 SOP §2 重灌:AML 8 条 / 演示 7 行 / sync 33 rows)。
2026-09-07 18:28:18 +08:00

151 lines
5.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.
"""模块边界防呆测试(配合《风控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")