背景:远端 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)。
96 lines
3.0 KiB
Python
96 lines
3.0 KiB
Python
"""S2 接缝适配器单测:宿主 AuthContext → 模块 AuthContext。
|
|
|
|
适配器当前为预制件(未接线),本测试保证它随时可用、且语义是 fail-closed。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.api.auth_adapter import (
|
|
HostAuthAdapterError,
|
|
from_host_auth,
|
|
perm_matches,
|
|
resolve_agent_type,
|
|
)
|
|
from app.utils.trace import current_trace, reset_trace, set_trace
|
|
|
|
|
|
def _host(**kw):
|
|
"""构造一个宿主风格的 AuthContext(字段与 main 的 schemas.AuthContext 对齐)。"""
|
|
base = dict(
|
|
sub="STAFF-90001",
|
|
token_type="staff",
|
|
roles=["risk_officer"],
|
|
permissions=["risk:alert:write"],
|
|
tenant_id="default",
|
|
trace_id="trace-host-001",
|
|
agent_type="risk",
|
|
jti="jti-001",
|
|
customer_id=None,
|
|
)
|
|
base.update(kw)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
def test_sub_maps_to_actor_id():
|
|
ctx = from_host_auth(_host(), bind_trace=False)
|
|
assert ctx.actor_id == "STAFF-90001"
|
|
|
|
|
|
def test_roles_and_permissions_passthrough():
|
|
ctx = from_host_auth(_host(roles=["risk_officer", "risk_demo"]), bind_trace=False)
|
|
assert ctx.has_role("risk_officer", "compliance") is True
|
|
assert ctx.has_permission("risk:alert:write") is True
|
|
|
|
|
|
def test_customer_token_fallbacks_customer_id_to_sub():
|
|
ctx = from_host_auth(_host(sub="CUST-1001", token_type="customer"), bind_trace=False)
|
|
assert ctx.customer_id == "CUST-1001"
|
|
assert ctx.is_customer() is False # 角色集合仍为空,归属判定不因 token_type 放宽
|
|
|
|
|
|
def test_missing_sub_is_fail_closed():
|
|
bad = SimpleNamespace(sub=None, actor_id=None, roles=[], token_type="staff")
|
|
with pytest.raises(HostAuthAdapterError):
|
|
from_host_auth(bad, bind_trace=False)
|
|
|
|
|
|
def test_empty_sub_string_is_fail_closed():
|
|
bad = SimpleNamespace(sub="", roles=[], token_type="staff")
|
|
with pytest.raises(HostAuthAdapterError):
|
|
from_host_auth(bad, bind_trace=False)
|
|
|
|
|
|
def test_trace_binding_writes_contextvar():
|
|
token = set_trace("before")
|
|
try:
|
|
from_host_auth(_host(trace_id="trace-host-999"), bind_trace=True)
|
|
assert current_trace() == "trace-host-999"
|
|
finally:
|
|
reset_trace(token)
|
|
|
|
|
|
def test_trace_not_bound_when_disabled():
|
|
token = set_trace("keep-me")
|
|
try:
|
|
from_host_auth(_host(trace_id="trace-host-999"), bind_trace=False)
|
|
assert current_trace() == "keep-me"
|
|
finally:
|
|
reset_trace(token)
|
|
|
|
|
|
def test_perm_matches_exact_and_wildcard():
|
|
perms = ["risk:alert:read", "audit:*"]
|
|
assert perm_matches("risk:alert:read", perms) is True
|
|
assert perm_matches("audit:read:all", perms) is True # 命中 audit:* 通配
|
|
assert perm_matches("trade:execute", perms) is False
|
|
assert perm_matches("", perms) is False
|
|
|
|
|
|
def test_resolve_agent_type():
|
|
assert resolve_agent_type(_host(agent_type="risk")) == "risk"
|
|
assert resolve_agent_type(SimpleNamespace(agent_type=None)) == "risk" # 默认回退
|