Files
XingHuo/tests/test_risk_api.py
T

396 lines
15 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.
"""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 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"