"""模块边界防呆测试(配合《风控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", ) # 宿主 Wave 0 独占文件(D 类):允许 import gateway/middleware 等,不参与「模块禁 import」扫描 HOST_ONLY_SKIP_PREFIXES = ( "app/main.py", "app/api/auth.py", "app/gateway/", "app/middleware/", "app/repository/advisor_rel_repository.py", "app/repository/agent_repository.py", "app/repository/audit_repository.py", "app/utils/input_guard.py", "app/utils/response.py", "app/utils/exceptions.py", ) # 数据分析 Agent S3 接缝 — 走 deps + analyst_auth_adapter ANALYST_AGENT_SEAM_SKIP = ( "app/api/analyst.py", "app/api/analyst_auth_adapter.py", "app/service/analyst_agent.py", "app/service/analytics_repo.py", "app/service/cache_service.py", "app/service/dict_service.py", "app/service/guardrail.py", "app/service/llm.py", "app/service/schema_meta.py", "app/service/sql_guard.py", ) # B7 Redis 网关惰性连接需 database._redis_kwargs(RESP2 口径) INFRA_SKIP = ( "app/service/risk/redis_gateway.py", ) # 客服 Agent S2 接缝(docs/项目框架设计/客服Agent-合并说明.md)— 允许 import 宿主 schemas CUSTOMER_AGENT_SEAM_SKIP = ( "app/api/auth_adapter.py", "app/api/visitor.py", "app/repository/note_repository.py", "app/repository/profile_repository.py", "app/repository/threshold_repository.py", "app/service/customer_service.py", "app/service/customer_prompts.py", "app/service/note_service.py", "app/service/profile_service.py", "app/service/threshold_service.py", "app/service/visitor_prompts.py", "app/service/visitor_service.py", "app/tool/core_ro_tool.py", "app/utils/compliance_guard.py", "app/utils/data_masker.py", "app/utils/sanitize_postprocess.py", "app/config/profile_slots.py", ) def _is_host_only_file(path: Path) -> bool: rel = path.relative_to(APP_DIR.parent).as_posix() if rel in CUSTOMER_AGENT_SEAM_SKIP or rel in ANALYST_AGENT_SEAM_SKIP or rel in INFRA_SKIP: return True return rel.startswith(HOST_ONLY_SKIP_PREFIXES) def _iter_module_py_files(): for path in APP_DIR.rglob("*.py"): if "__pycache__" in path.parts: continue if _is_host_only_file(path): continue yield path _IMPORT_LINE = re.compile(r"^\s*(from|import)\s+") @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")