- 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 前
360 lines
14 KiB
Python
360 lines
14 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 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"]
|