Files
XingHuo/tests/test_risk_api.py
T
GaoYiYuan_0626 3e40a2bb49 fix: B6 评审修复——鉴权审计统一 deny/401 留痕、start_date/end_date 对齐 PRD、simulate 回挂鉴权、异常与审计注入收敛
- P1-1: 403/401 全部经 deps.deny/_authz_audit 留痕(event_type='authz')
- P2-1: GET /alerts 参数名 start_date/end_date 对齐 PRD;page/page_size 用 fastapi.Query
- P2-2: /api/simulate/trade 回挂 get_auth_context(risk_demo 或本人),越权 403+审计
- P2-3: suitability/aml 直调补审计+request_ref 透传
- P3-1: 多角色 fail-closed 口径固化进测试;P3-3/P3-4 core_ro/risk_repo 必传;
  P3-5 LookupError→NotFoundError 统一;P3-9 alert_service import 上提
- 测试: test_risk_api 权限矩阵/审计断言/多角色组合/非 dev 拒绝;
  test_trade_gateway 客户本人 vs 越权 403(P3-4 模拟 _repo 注入 sqlite)
- 挂账: P3-2/P3-7(响应外壳+disclaimer)→B7;P3-6(scan 幂等)→B9b 前
2026-09-06 18:58:48 +08:00

360 lines
14 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 create_engine, text
from sqlalchemy.pool import StaticPool
from app.api import risk as risk_api
from app.api.deps import assert_customer_access, permission_denied_handler
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 alert_service
from app.utils.exceptions import PermissionDenied
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_engine(
"sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False}
)
with engine.begin() as conn:
for ddl in [
"""CREATE TABLE core_customer (
customer_id VARCHAR(64) PRIMARY KEY, display_name VARCHAR(128), age INTEGER,
occupation VARCHAR(64), open_date DATE, is_active TINYINT DEFAULT 1)""",
"""CREATE TABLE core_customer_risk (
customer_id VARCHAR(64), risk_code VARCHAR(8), evaluated_at TIMESTAMP)""",
"""CREATE TABLE core_customer_advisor (
advisor_id VARCHAR(64), customer_id VARCHAR(64), rel_status VARCHAR(16))""",
"""CREATE TABLE core_product (
product_id VARCHAR(64) PRIMARY KEY, product_name VARCHAR(128),
min_risk_code VARCHAR(8), product_type VARCHAR(32))""",
"""CREATE TABLE risk_aml_list (
id INTEGER PRIMARY KEY AUTOINCREMENT, list_id VARCHAR(64), list_type VARCHAR(16),
full_name VARCHAR(128), match_threshold REAL, source VARCHAR(64),
list_version VARCHAR(16), effective_date DATE, is_active TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE risk_alert (
alert_id VARCHAR(64) PRIMARY KEY, trace_id VARCHAR(64), customer_id VARCHAR(64),
trade_id VARCHAR(64), alert_type VARCHAR(16), triggered_rules TEXT,
risk_score INTEGER, status VARCHAR(24) DEFAULT 'pending_review', payload TEXT,
handler_id VARCHAR(64), handler_result VARCHAR(64), handler_comment VARCHAR(512),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, handled_at TIMESTAMP)""",
"""CREATE TABLE audit_log (
id INTEGER PRIMARY KEY, trace_id VARCHAR(64), event_type VARCHAR(64),
agent_type VARCHAR(16), actor_id VARCHAR(64), customer_id VARCHAR(64),
rule_id VARCHAR(64), input_summary TEXT, decision VARCHAR(64), risk_score INTEGER,
handler_id VARCHAR(64), handler_result VARCHAR(64), handler_comment VARCHAR(512),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE risk_suitability_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id VARCHAR(64), customer_id VARCHAR(64),
product_id VARCHAR(64), customer_risk_level VARCHAR(8), product_risk_level VARCHAR(8),
is_matched TINYINT, is_blocked TINYINT, block_reason VARCHAR(512),
request_ref VARCHAR(64), profile_l1_version VARCHAR(32),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
"""CREATE TABLE customer_profile_l3 (
customer_id VARCHAR(64) PRIMARY KEY, monitor_tier VARCHAR(16) NOT NULL,
risk_score INTEGER, score_dimensions TEXT, monitor_tags TEXT,
last_alert_id VARCHAR(64), computed_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
]:
conn.execute(text(ddl))
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)
app = FastAPI()
app.include_router(risk_router)
app.add_exception_handler(PermissionDenied, permission_denied_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_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):
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
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 "AUTH_403_NOT_OWNER" in r.json()["detail"]
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 "AUTH_403_NOT_ASSIGNED" in r.json()["detail"]
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 "AUTH_403_SCOPE" in r.json()["detail"]
# ---------- 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, "hit_customers": 1, "alerts": body["alerts"]} # CUST-3001 命中
assert len(body["alerts"]) == 1
l3 = repo.get_l3("CUST-3001")
assert l3["monitor_tier"] == "high" # scan 命中标记 L3
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
# ---------- 非 dev 环境拒绝 debug 鉴权 ----------
def test_non_dev_rejects_debug_auth(client, monkeypatch):
from app.config.settings import settings
monkeypatch.setattr(settings, "app_env", "production")
with pytest.raises(RuntimeError, match="debug auth disabled"):
client.get("/api/risk/alerts", headers=OFFICER)
# ---------- 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 "AUTH_403_NOT_OWNER" in r.json()["detail"]