396 lines
15 KiB
Python
396 lines
15 KiB
Python
"""risk API 权限矩阵测试(B6 · A-7 处置/仅 aml、A-9 越权 403+audit、debug 头边界)。
|
||
|
||
TestClient 独立挂 router(main 挂载归 B7);仓储经 monkeypatch 注入 sqlite。
|
||
"""
|
||
|
||
from datetime import datetime, timedelta
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine
|
||
from app.api import risk as risk_api
|
||
from app.api.deps import assert_customer_access
|
||
from app.api.risk import router as risk_router
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service.risk import redis_gateway
|
||
from app.utils.exceptions import PermissionDenied
|
||
from app.utils.response import register_error_handlers
|
||
|
||
|
||
class FakePublisher:
|
||
"""aml/scan 推送隔离(复审 P2:测试不得依赖本机 Redis 状态)。"""
|
||
|
||
def __init__(self):
|
||
self.messages = []
|
||
self.deletes = []
|
||
|
||
def publish(self, channel, payload):
|
||
self.messages.append((channel, payload))
|
||
|
||
def delete(self, *keys):
|
||
self.deletes.append(keys)
|
||
|
||
|
||
def _alert(alert_id, customer, atype, status="pending_review", score=70):
|
||
return {
|
||
"alert_id": alert_id,
|
||
"trace_id": f"trc-{alert_id.lower()}",
|
||
"customer_id": customer,
|
||
"trade_id": None,
|
||
"alert_type": atype,
|
||
"triggered_rules": ["RISK-001"],
|
||
"risk_score": score,
|
||
"status": status,
|
||
"payload": {"events": [], "customer_context": {}},
|
||
}
|
||
|
||
|
||
@pytest.fixture()
|
||
def env():
|
||
engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12)
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer (customer_id, display_name, age, is_active) VALUES"
|
||
" ('CUST-1001', '客户·王**', 28, 1), ('CUST-3001', '客户·孙**', 45, 1)"
|
||
)
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at) VALUES"
|
||
" ('CUST-1001', 'C1', :t), ('CUST-3001', 'C3', :t)"
|
||
),
|
||
{"t": datetime.now() - timedelta(days=30)},
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer_advisor VALUES ('ADV-01', 'CUST-3001', 'active')"
|
||
)
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type) VALUES"
|
||
" ('PROD-161725', '科技成长主题', 'R4', 'mixed'),"
|
||
" ('PROD-510300', '沪深300指数', 'R3', 'index')"
|
||
)
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO risk_aml_list (list_id, list_type, full_name, match_threshold,"
|
||
" source, list_version, is_active) VALUES"
|
||
" ('SAN-1', 'sanction', '客户·孙**', 0.85, 'mock', 'v1', 1)"
|
||
)
|
||
)
|
||
repo = RiskRepository(engine=engine)
|
||
repo.insert_alert(_alert("ALT-E1", "CUST-3001", "large_amount"))
|
||
repo.insert_alert(_alert("ALT-A1", "CUST-1001", "aml", score=95))
|
||
repo.insert_alert(_alert("ALT-D1", "CUST-3001", "freq_trade", status="confirmed_normal", score=50))
|
||
yield repo, engine
|
||
engine.dispose()
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(env, monkeypatch):
|
||
repo, engine = env
|
||
monkeypatch.setattr(risk_api, "_repo", lambda: repo)
|
||
monkeypatch.setattr(risk_api, "CoreReadOnlyRepository", lambda: CoreReadOnlyRepository(engine=engine))
|
||
# 401/越权审计经 deps 内 RiskRepository 兜底构造,统一注入 sqlite(B6 评审 P3-4)
|
||
from app.api import deps as deps_mod
|
||
|
||
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
|
||
# aml 命中广播 Pub/Sub + L3 缓存 DEL:注入 fake,不触真 Redis(复审 P2)
|
||
monkeypatch.setattr(redis_gateway, "_gateway", FakePublisher())
|
||
app = FastAPI()
|
||
app.include_router(risk_router)
|
||
register_error_handlers(app) # 统一错误体(手册 §10,与 main 同一 handler 集)
|
||
with TestClient(app) as c:
|
||
yield c
|
||
|
||
|
||
def _h(role="", actor=""):
|
||
return {"X-Debug-Role": role, "X-Debug-Actor": actor} if role else {}
|
||
|
||
|
||
OFFICER = _h("risk_officer", "STAFF-90001")
|
||
COMPLIANCE = _h("compliance", "STAFF-40001")
|
||
CUST_1001 = _h("customer", "CUST-1001")
|
||
ADV_01 = _h("advisor", "ADV-01")
|
||
|
||
|
||
def _counts(engine, table, where="1=1"):
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(f"SELECT COUNT(*) FROM {table} WHERE {where}")).scalar_one()
|
||
|
||
|
||
# ---------- GET /alerts ----------
|
||
|
||
|
||
def test_officer_sees_all_compliance_forced_aml(client):
|
||
r = client.get("/api/risk/alerts", headers=OFFICER)
|
||
assert r.status_code == 200
|
||
assert r.json()["total"] == 3 # 全量(含已处置)
|
||
r = client.get("/api/risk/alerts", headers=COMPLIANCE)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["total"] == 1 and body["items"][0]["alert_type"] == "aml" # A-7 强制过滤
|
||
r = client.get("/api/risk/alerts", params={"alert_type": "large_amount"}, headers=COMPLIANCE)
|
||
assert r.json()["items"][0]["alert_type"] == "aml" # 传入类型被覆盖
|
||
|
||
|
||
def test_other_roles_cannot_list_alerts(client):
|
||
for headers in (CUST_1001, ADV_01):
|
||
r = client.get("/api/risk/alerts", headers=headers)
|
||
assert r.status_code == 403
|
||
|
||
|
||
def test_alerts_date_filter_and_pagination(client):
|
||
"""复审 P3:start_date/end_date 过滤与分页边界回归保护。
|
||
|
||
DDL 收敛后 created_at 为 localtime(_ddl.py,B5 评审 P3-4),窗口用本地时间构造。
|
||
"""
|
||
now = datetime.now()
|
||
r = client.get("/api/risk/alerts", params={"start_date": (now - timedelta(hours=1)).isoformat()}, headers=OFFICER)
|
||
assert r.json()["total"] == 3 # 窗口内全命中
|
||
r = client.get("/api/risk/alerts", params={"start_date": (now + timedelta(hours=1)).isoformat()}, headers=OFFICER)
|
||
assert r.json()["total"] == 0 # 未来起点 → 空
|
||
r = client.get("/api/risk/alerts", params={"end_date": (now - timedelta(hours=1)).isoformat()}, headers=OFFICER)
|
||
assert r.json()["total"] == 0 # 过去终点 → 空
|
||
r = client.get("/api/risk/alerts", params={"page": 2, "page_size": 2}, headers=OFFICER)
|
||
body = r.json()
|
||
assert body["total"] == 3 and len(body["items"]) == 1 and body["page"] == 2
|
||
assert client.get("/api/risk/alerts", params={"page": 0}, headers=OFFICER).status_code == 422
|
||
assert client.get("/api/risk/alerts", params={"page_size": 101}, headers=OFFICER).status_code == 422
|
||
|
||
|
||
def test_missing_debug_headers_401(client):
|
||
assert client.get("/api/risk/alerts").status_code == 401
|
||
|
||
|
||
# ---------- POST /alerts/{id}/handle ----------
|
||
|
||
|
||
def test_officer_handle_success_state_machine_and_audit(client, env):
|
||
repo, engine = env
|
||
r = client.post(
|
||
"/api/risk/alerts/ALT-E1/handle",
|
||
json={"handler_result": "confirmed_suspicious", "handler_comment": "确认可疑"},
|
||
headers=OFFICER,
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["status"] == "confirmed_suspicious" and body["handler_id"] == "STAFF-90001"
|
||
assert _counts(engine, "audit_log", "event_type='alert_handle' AND decision='alert_handled'") == 1
|
||
# 状态机:已处置单禁止跳改
|
||
r2 = client.post(
|
||
"/api/risk/alerts/ALT-E1/handle",
|
||
json={"handler_result": "confirmed_normal"},
|
||
headers=OFFICER,
|
||
)
|
||
assert r2.status_code == 409
|
||
|
||
|
||
def test_compliance_handle_403(client, env):
|
||
repo, _ = env
|
||
r = client.post(
|
||
"/api/risk/alerts/ALT-E1/handle",
|
||
json={"handler_result": "confirmed_suspicious"},
|
||
headers=COMPLIANCE,
|
||
)
|
||
assert r.status_code == 403 # A-7
|
||
assert repo.get_alert("ALT-E1")["status"] == "pending_review"
|
||
|
||
|
||
def test_handle_invalid_result_422(client):
|
||
r = client.post(
|
||
"/api/risk/alerts/ALT-E1/handle",
|
||
json={"handler_result": "auto_frozen"}, # 非法枚举(依赖层校验)
|
||
headers=OFFICER,
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_handle_missing_alert_404(client):
|
||
r = client.post(
|
||
"/api/risk/alerts/ALT-XXXX/handle",
|
||
json={"handler_result": "confirmed_normal"},
|
||
headers=OFFICER,
|
||
)
|
||
assert r.status_code == 404
|
||
|
||
|
||
# ---------- POST /suitability/check(G-01 + A-9) ----------
|
||
|
||
|
||
def test_suitability_check_by_owner_officer_and_assigned_advisor(client, env):
|
||
repo, engine = env
|
||
r = client.post(
|
||
"/api/risk/suitability/check",
|
||
json={"customer_id": "CUST-1001", "product_id": "PROD-161725"},
|
||
headers=CUST_1001,
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is True and body["rule_id"] == "SUIT-001" # C1+R4
|
||
r = client.post(
|
||
"/api/risk/suitability/check",
|
||
json={"customer_id": "CUST-3001", "product_id": "PROD-510300"},
|
||
headers=OFFICER,
|
||
)
|
||
assert r.status_code == 200 and r.json()["is_matched"] is True
|
||
r = client.post(
|
||
"/api/risk/suitability/check",
|
||
json={"customer_id": "CUST-3001", "product_id": "PROD-510300"},
|
||
headers=ADV_01, # ADV-01 名下 CUST-3001
|
||
)
|
||
assert r.status_code == 200
|
||
# 直调路径每次校验补 api 层审计(评审 P2-3;复审 P3 回归保护)
|
||
assert _counts(engine, "audit_log", "event_type='suitability_check'") == 3
|
||
assert (
|
||
_counts(
|
||
engine,
|
||
"audit_log",
|
||
"event_type='suitability_check' AND decision='suitability_blocked'",
|
||
)
|
||
== 1
|
||
)
|
||
|
||
|
||
def test_a9_customer_other_403_with_audit(client, env):
|
||
repo, engine = env
|
||
r = client.post(
|
||
"/api/risk/suitability/check",
|
||
json={"customer_id": "CUST-3001", "product_id": "PROD-510300"},
|
||
headers=CUST_1001, # 查他人
|
||
)
|
||
assert r.status_code == 403
|
||
assert r.json()["error_code"] == "AUTH_403_NOT_OWNER"
|
||
assert _counts(engine, "audit_log", "event_type='authz' AND decision='forbidden'") == 1
|
||
|
||
|
||
def test_a9_advisor_not_assigned_403_with_audit(client, env):
|
||
repo, engine = env
|
||
r = client.post(
|
||
"/api/risk/suitability/check",
|
||
json={"customer_id": "CUST-1001", "product_id": "PROD-161725"},
|
||
headers=ADV_01, # 非名下
|
||
)
|
||
assert r.status_code == 403
|
||
assert r.json()["error_code"] == "AUTH_403_NOT_ASSIGNED"
|
||
assert _counts(engine, "audit_log", "event_type='authz' AND decision='forbidden'") == 1
|
||
|
||
|
||
def test_compliance_suitability_check_403(client):
|
||
r = client.post(
|
||
"/api/risk/suitability/check",
|
||
json={"customer_id": "CUST-3001", "product_id": "PROD-510300"},
|
||
headers=COMPLIANCE, # 审计角色不在客户业务数据白名单
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SCOPE"
|
||
|
||
|
||
# ---------- POST /aml/scan ----------
|
||
|
||
|
||
def test_aml_scan_officer_only_with_audit(client, env):
|
||
repo, engine = env
|
||
r = client.post("/api/risk/aml/scan", headers=OFFICER)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["scanned"] == 2 and body["hit_customers"] == 1 # CUST-3001 命中
|
||
assert len(body["alerts"]) == 1
|
||
assert body["skipped_existing"] == [] # 首扫无既有单
|
||
assert body["disclaimer"] == risk_api.ALERT_DISCLAIMER # B9b 核查单①
|
||
l3 = repo.get_l3("CUST-3001")
|
||
assert l3["monitor_tier"] == "high" # scan 命中标记 L3
|
||
# L3 写侧缓存 DEL 钩子(B7 挂账②):命中客户 upsert 后失效读缓存
|
||
fake = redis_gateway.get_gateway()
|
||
assert ("profile:l3:CUST-3001",) in fake.deletes
|
||
assert _counts(engine, "audit_log", "event_type='aml_scan' AND decision='scan_completed'") == 1
|
||
for headers in (COMPLIANCE, CUST_1001):
|
||
assert client.post("/api/risk/aml/scan", headers=headers).status_code == 403
|
||
|
||
|
||
def test_aml_scan_idempotent_same_day(client, env):
|
||
"""B9b 核查单②(B6 评审 P3-6):同日重扫不重复出单,重复点击防护。"""
|
||
repo, engine = env
|
||
first = client.post("/api/risk/aml/scan", headers=OFFICER).json()
|
||
second = client.post("/api/risk/aml/scan", headers=OFFICER).json()
|
||
assert second["alerts"] == []
|
||
assert second["skipped_existing"] == first["alerts"]
|
||
# aml 单总数不变(env 预置 ALT-A1 一张 + scan 新出一张;重扫零新增)
|
||
assert _counts(engine, "risk_alert", "alert_type='aml'") == 2
|
||
|
||
|
||
def test_alert_disclaimer_on_alert_apis(client):
|
||
"""B9b 核查单①(B6 复审 P3-7):预警类 API 响应体固定 disclaimer(规则表 §5)。"""
|
||
r = client.get("/api/risk/alerts", headers=OFFICER)
|
||
body = r.json()
|
||
assert r.status_code == 200 and body["disclaimer"] == risk_api.ALERT_DISCLAIMER
|
||
pending = next(i for i in body["items"] if i["status"] == "pending_review")
|
||
r = client.post(
|
||
f"/api/risk/alerts/{pending['alert_id']}/handle",
|
||
json={"handler_result": "confirmed_normal"},
|
||
headers=OFFICER,
|
||
)
|
||
assert r.status_code == 200 and r.json()["disclaimer"] == risk_api.ALERT_DISCLAIMER
|
||
|
||
|
||
# ---------- 非 dev 环境拒绝 debug 鉴权 ----------
|
||
|
||
|
||
def test_non_dev_rejects_debug_auth(client, monkeypatch):
|
||
"""T-01 后:非 dev 无 Bearer 一律 401 留痕(debug 头通道不存在于生产)。"""
|
||
from app.config.settings import settings
|
||
|
||
monkeypatch.setattr(settings, "app_env", "production")
|
||
r = client.get("/api/risk/alerts", headers=OFFICER)
|
||
assert r.status_code == 401
|
||
assert r.json()["error_code"] == "AUTH_401_MISSING_BEARER"
|
||
|
||
|
||
# ---------- deps 单元:compliance 不放行客户业务数据 ----------
|
||
|
||
|
||
def test_assert_customer_access_scope_denial(env):
|
||
repo, _ = env
|
||
from app.api.deps import AuthContext
|
||
|
||
auth = AuthContext(actor_id="STAFF-40001", roles=["compliance"])
|
||
with pytest.raises(PermissionDenied) as ei:
|
||
assert_customer_access(
|
||
auth, "CUST-1001", core_ro=CoreReadOnlyRepository(engine=env[1]), risk_repo=repo
|
||
)
|
||
assert ei.value.code == "AUTH_403_SCOPE"
|
||
|
||
|
||
def test_authz_denials_are_audited(client, env):
|
||
"""B6 评审 P1-1:403/401 全部留痕(AUTH_403_ROLE / AUTH_401_MISSING_DEBUG_HEADERS)。"""
|
||
repo, engine = env
|
||
assert client.get("/api/risk/alerts").status_code == 401 # 无 debug 头
|
||
assert client.post("/api/risk/aml/scan", headers=ADV_01).status_code == 403
|
||
assert client.post(
|
||
"/api/risk/alerts/ALT-E1/handle",
|
||
json={"handler_result": "confirmed_normal"},
|
||
headers=COMPLIANCE,
|
||
).status_code == 403
|
||
assert _counts(engine, "audit_log", "decision='unauthenticated' AND input_summary LIKE '%AUTH_401%'") == 1
|
||
assert _counts(engine, "audit_log", "decision='forbidden' AND input_summary LIKE '%AUTH_403_ROLE%'") == 2
|
||
|
||
|
||
def test_multi_role_combinations_fixed_behavior(client):
|
||
"""B6 评审 P3-1:多角色口径固化——并集权限、归属 fail-closed。"""
|
||
# customer+compliance:借 compliance 角色看 aml 台账(审计类跨客户,固化允许)
|
||
r = client.get("/api/risk/alerts", headers=_h("customer,compliance", "CUST-1001"))
|
||
assert r.status_code == 200 and r.json()["items"][0]["alert_type"] == "aml"
|
||
# customer+advisor 查非本人客户:customer 分支 deny 即终止(最窄范围 fail-closed)
|
||
r = client.post(
|
||
"/api/risk/suitability/check",
|
||
json={"customer_id": "CUST-1001", "product_id": "PROD-161725"},
|
||
headers=_h("customer,advisor", "CUST-3001"),
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_OWNER"
|