- Updated `RiskListAccess` and `ThresholdWriteAccess` to enforce access control in the risk repository and threshold repository, ensuring only authorized roles can perform sensitive operations. - Introduced new methods in `RiskRepository` for counting pending alerts and listing alerts with access checks, improving data security and compliance. - Enhanced the `chat.py` and `deps.py` files to integrate compliance roles into the risk management matrix, allowing for more granular access control. - Updated documentation to reflect the new testing baseline of 825 passed tests, indicating improved stability and functionality across the application. This update significantly strengthens the risk management capabilities, ensuring robust access control and compliance with organizational policies.
452 lines
18 KiB
Python
452 lines
18 KiB
Python
"""风控集成测试(B8 · PRD §8 验收 A-1~A-5/A-7/A-9 + trace 一致性 + 审计 JSON)。
|
||
|
||
真链路:TestClient(main app)(路由 + trace 中间件 + lifespan)→ 真本机 MySQL
|
||
(jinrong_core/jinrong_agent,演示数据就位校验见 conftest.ensure_risk_demo_ready)。
|
||
仅注入两点:trade_gateway._new_trade_id 统一 TRD-TEST- 前缀(teardown 按前缀
|
||
清理,B5 评审 P3-2);redis_gateway fake(断言推送,不依赖本机 Redis)。
|
||
用例间按演示时间线顺序耦合(A-3 产生的预警单供 A-7 处置),模块内保序。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
from conftest import ensure_risk_demo_ready
|
||
|
||
from app.config.settings import settings # noqa: E402
|
||
from app.gateway import trade_gateway # noqa: E402
|
||
from app.main import app # noqa: E402
|
||
from app.service.risk import redis_gateway # noqa: E402
|
||
|
||
ensure_risk_demo_ready()
|
||
|
||
OFFICER = {"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-90001"}
|
||
COMPLIANCE = {"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001"}
|
||
CUSTOMER_1001 = {"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001"}
|
||
CUSTOMER_1002 = {"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1002"}
|
||
# simulate 交易白名单:risk_demo 演示账号或客户本人(FR-1 鉴权)
|
||
DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-DEMO"}
|
||
# STAFF-10087 真实存在但名下无 CUST-3001(归属表 28 行分布于 5 个 advisor)
|
||
ADVISOR_10087 = {"X-Debug-Role": "advisor", "X-Debug-Actor": "STAFF-10087"}
|
||
|
||
# 演示时间线状态(A-1/A-3 产物供 A-7 审计与处置用例续用)
|
||
_state: dict[str, str] = {}
|
||
|
||
|
||
class FakePub:
|
||
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 _test_trade_id(now):
|
||
"""集成交易统一 TRD-TEST- 前缀(conftest teardown 定位清理)。"""
|
||
return f"TRD-TEST-{uuid4().hex[:8].upper()}"
|
||
|
||
|
||
@pytest.fixture()
|
||
def iclient(risk_demo_env, monkeypatch):
|
||
monkeypatch.setattr(trade_gateway, "_new_trade_id", _test_trade_id)
|
||
fake = FakePub()
|
||
with TestClient(app) as c: # 真 lifespan:dev 放行 + Redis 网关注册
|
||
monkeypatch.setattr(redis_gateway, "_gateway", fake) # 覆盖 lifespan 注册的真网关
|
||
yield c, fake
|
||
|
||
|
||
def _trade(customer_id, product_id, trade_type, amount):
|
||
return {
|
||
"customer_id": customer_id,
|
||
"product_id": product_id,
|
||
"trade_type": trade_type,
|
||
"amount": amount,
|
||
}
|
||
|
||
|
||
def _one(env, sql, **params):
|
||
with env["agent"].connect() as conn:
|
||
return conn.execute(text(sql), params).mappings().first()
|
||
|
||
|
||
def _core_one(env, sql, **params):
|
||
with env["core"].connect() as conn:
|
||
return conn.execute(text(sql), params).mappings().first()
|
||
|
||
|
||
def _audit_by_trade(env, trade_id, event_type=None):
|
||
sql = (
|
||
"SELECT * FROM audit_log WHERE event_type = :et"
|
||
" AND JSON_UNQUOTE(JSON_EXTRACT(input_summary, '$.trade_id')) = :tid"
|
||
)
|
||
return _one(env, sql, et=event_type or "trade_request", tid=trade_id)
|
||
|
||
|
||
# ---------- 核查单⑥前置断言(B8 复审 P2-1:演示与 pytest 同日交叉防护) ----------
|
||
# 各用例对演示库当日状态有隐式假设;交叉执行时不静默产出错误结论,而是 fail 并提示处理。
|
||
|
||
|
||
def _fail_if_dirty(cond_row, message):
|
||
assert cond_row is None, f"演示/测试同日交叉防护(核查单⑥):{message}"
|
||
|
||
|
||
def _assert_a1_clean(env):
|
||
"""A-1 假定当日无 CUST-1001×PROD-161725 pending suitability 单:
|
||
同日交叉时本笔会并入旧单(新 trade_id 不在 trade_id 列),断言必炸。"""
|
||
_fail_if_dirty(
|
||
_one(
|
||
env,
|
||
"SELECT alert_id FROM risk_alert WHERE customer_id = 'CUST-1001'"
|
||
" AND alert_type = 'suitability' AND status = 'pending_review'"
|
||
" AND payload LIKE '%PROD-161725%' AND created_at >= CURDATE()",
|
||
),
|
||
"CUST-1001 今日已有 PROD-161725 pending suitability 单;"
|
||
"先按演示 SOP 重灌或清理当日演示单后再跑集成测试",
|
||
)
|
||
|
||
|
||
def _assert_a3_clean(env):
|
||
"""A-3 假定 CUST-3001 当日无 confirmed 交易(RISK-003 频次口径)且无当日
|
||
pending 事件单(聚合锚点:有 pending 单则本笔 append 进旧单,规则断言漂移)。"""
|
||
_fail_if_dirty(
|
||
_core_one(
|
||
env,
|
||
"SELECT trade_id FROM core_trade WHERE customer_id = 'CUST-3001'"
|
||
" AND trade_status = 'confirmed' AND traded_at >= CURDATE() LIMIT 1",
|
||
),
|
||
"CUST-3001 今日已有 confirmed 交易(影响 RISK-002 累计/RISK-003 频次口径)",
|
||
)
|
||
_fail_if_dirty(
|
||
_one(
|
||
env,
|
||
"SELECT alert_id FROM risk_alert WHERE customer_id = 'CUST-3001'"
|
||
" AND alert_type IN ('large_amount','freq_trade','pattern')"
|
||
" AND status = 'pending_review' AND created_at >= CURDATE()",
|
||
),
|
||
"CUST-3001 今日已有 pending 事件类预警单(本笔会并入旧单而非新建)",
|
||
)
|
||
|
||
|
||
def _assert_a4_clean(env):
|
||
"""A-4 假定 CUST-9527 当日无 confirmed 交易:同日交叉时首笔即可能触发规则,
|
||
「第 3 笔才触发 / events==2」断言失真。"""
|
||
_fail_if_dirty(
|
||
_core_one(
|
||
env,
|
||
"SELECT trade_id FROM core_trade WHERE customer_id = 'CUST-9527'"
|
||
" AND trade_status = 'confirmed' AND traded_at >= CURDATE() LIMIT 1",
|
||
),
|
||
"CUST-9527 今日已有 confirmed 交易(A-4 频次计数失真)",
|
||
)
|
||
|
||
|
||
# ---------- A-1:C1 申购 R4 → 矩阵 forbidden 阻断(含 trace 一致性) ----------
|
||
|
||
|
||
def test_a1_suit001_blocked_with_trace_consistency(iclient, risk_demo_env):
|
||
c, fake = iclient
|
||
env = risk_demo_env
|
||
_assert_a1_clean(env)
|
||
trace = f"trc-integ-{uuid4().hex[:12]}"
|
||
r = c.post(
|
||
"/api/simulate/trade",
|
||
json=_trade("CUST-1001", "PROD-161725", "subscribe", 10000),
|
||
headers={**CUSTOMER_1001, "X-Trace-Id": trace},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is True
|
||
# AL-05 main 契约:机器码 SUIT_RISK_MISMATCH(SUIT-001 退役)
|
||
assert body["block_response_code"] == "SUIT_RISK_MISMATCH"
|
||
assert r.headers["X-Trace-Id"] == trace # 中间件透传(PRD §7.5 全链路环①)
|
||
trade_id = body["trade_id"]
|
||
assert trade_id.startswith("TRD-TEST-")
|
||
|
||
# 阻断不落交易(FR-1)
|
||
assert _core_one(env, "SELECT * FROM core_trade WHERE trade_id = :t", t=trade_id) is None
|
||
# 校验日志 blocked,trace 一致(环②)
|
||
slog = _one(
|
||
env,
|
||
"SELECT * FROM risk_suitability_log WHERE request_ref = :t",
|
||
t=trade_id,
|
||
)
|
||
assert slog is not None and slog["is_blocked"] == 1 and slog["is_matched"] == 0
|
||
assert slog["trace_id"] == trace
|
||
# suitability 预警单生成(R-02 阻断单),trace 与请求一致
|
||
alert = _one(env, "SELECT * FROM risk_alert WHERE trade_id = :t", t=trade_id)
|
||
assert alert["alert_type"] == "suitability" and alert["status"] == "pending_review"
|
||
assert alert["trace_id"] == trace
|
||
# 审计可查(platform 阻断留痕),trace 一致
|
||
audit = _audit_by_trade(env, trade_id, "trade_request")
|
||
assert audit["decision"] == "suitability_blocked" and audit["trace_id"] == trace
|
||
assert audit["agent_type"] == "platform"
|
||
# Redis 推送 trace 一致
|
||
pushed = [p for ch, p in fake.messages if ch == "risk:pub:alert"]
|
||
assert any(p["alert_id"] == alert["alert_id"] and p["trace_id"] == trace for p in pushed)
|
||
_state["a1_trade_id"] = trade_id
|
||
|
||
|
||
def test_a1_audit_input_summary_json(iclient, risk_demo_env):
|
||
"""B5 复审 L1:platform 审计 input_summary 可 JSON 解析(含阻断 reasons)。"""
|
||
c, _ = iclient
|
||
env = risk_demo_env
|
||
audit = _audit_by_trade(env, _state["a1_trade_id"], "trade_request")
|
||
summary = json.loads(audit["input_summary"])
|
||
assert summary["trade_id"] == _state["a1_trade_id"]
|
||
assert summary["product_id"] == "PROD-161725"
|
||
assert summary["trade_type"] == "subscribe"
|
||
assert summary["amount"] == "10000"
|
||
assert summary["block_response_code"] == "SUIT_RISK_MISMATCH" # AL-05 main 契约
|
||
assert isinstance(summary["reasons"], list) and summary["reasons"]
|
||
|
||
|
||
# ---------- A-2:70 岁 C5 买 R4 → FM-01 网点确认阻断(替换 SUIT-006 封顶) ----------
|
||
|
||
|
||
def test_a2_age70_cap_suit006_no_suit008(iclient, risk_demo_env):
|
||
c, _ = iclient
|
||
env = risk_demo_env
|
||
r = c.post(
|
||
"/api/simulate/trade",
|
||
json=_trade("CUST-4001", "PROD-161725", "subscribe", 20000),
|
||
headers={**DEMO, "X-Trace-Id": f"trc-integ-{uuid4().hex[:12]}"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is True
|
||
# AL-05 main 契约:≥70 买 R3+ 一律阻断待网点当面确认(FM-01 替换 SUIT-006 封顶)
|
||
assert body["block_response_code"] == "SUIT_AGE_CONFIRM"
|
||
assert body["needs_branch_confirm"] is True
|
||
assert any("网点当面确认" in x for x in body["reasons"]), body["reasons"]
|
||
trade_id = body["trade_id"]
|
||
assert _core_one(env, "SELECT * FROM core_trade WHERE trade_id = :t", t=trade_id) is None
|
||
_state["a2_trade_id"] = trade_id
|
||
|
||
|
||
# ---------- A-3:C3 单笔 50 万 R3 → 放行 + RISK-001/002 预警 + 推送 ----------
|
||
|
||
|
||
def test_a3_large_amount_alert_and_publish(iclient, risk_demo_env):
|
||
c, fake = iclient
|
||
env = risk_demo_env
|
||
_assert_a3_clean(env)
|
||
r = c.post(
|
||
"/api/simulate/trade",
|
||
json=_trade("CUST-3001", "PROD-510300", "subscribe", 500000),
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is False
|
||
assert body["triggered_rules"] == ["RISK-001", "RISK-002"]
|
||
assert body["aml_hit"] is False
|
||
trade_id = body["trade_id"]
|
||
alert_id = body["alert_ids"][0]
|
||
|
||
assert _core_one(env, "SELECT * FROM core_trade WHERE trade_id = :t", t=trade_id) is not None
|
||
alert = _one(env, "SELECT * FROM risk_alert WHERE alert_id = :a", a=alert_id)
|
||
assert alert["status"] == "pending_review" and alert["risk_score"] == 70
|
||
assert json.loads(alert["triggered_rules"]) == ["RISK-001", "RISK-002"]
|
||
pushed = [p for ch, p in fake.messages if ch == "risk:pub:alert"]
|
||
assert any(p["alert_id"] == alert_id for p in pushed)
|
||
# 放行审计全量引擎输出(B5 复审 L1)
|
||
audit = _audit_by_trade(env, trade_id, "trade_request")
|
||
assert audit["decision"] == "trade_accepted"
|
||
summary = json.loads(audit["input_summary"])
|
||
assert summary["triggered_rules"] == ["RISK-001", "RISK-002"]
|
||
assert summary["alert_ids"] == [alert_id]
|
||
_state["a3_alert_id"] = alert_id
|
||
_state["a3_trade_id"] = trade_id
|
||
|
||
|
||
# ---------- A-4:同产品当日第 3 笔 → freq 并入当日预警单 ----------
|
||
|
||
|
||
def test_a4_freq_trade_merged_into_same_alert(iclient, risk_demo_env):
|
||
c, _ = iclient
|
||
env = risk_demo_env
|
||
_assert_a4_clean(env)
|
||
alert_id = None
|
||
for _ in range(3):
|
||
r = c.post(
|
||
"/api/simulate/trade",
|
||
json=_trade("CUST-9527", "PROD-510300", "subscribe", 1000),
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is False
|
||
if body["triggered_rules"]:
|
||
assert body["triggered_rules"] == ["RISK-003"]
|
||
alert_id = body["alert_ids"][0]
|
||
assert alert_id, "第 3 笔应触发 RISK-003"
|
||
|
||
# 第 4 笔:并入既有单,不另开新单(PRD FR-4)
|
||
r = c.post(
|
||
"/api/simulate/trade",
|
||
json=_trade("CUST-9527", "PROD-510300", "subscribe", 1000),
|
||
headers=DEMO,
|
||
)
|
||
body = r.json()
|
||
assert body["blocked"] is False and body["triggered_rules"] == ["RISK-003"]
|
||
assert body["alert_ids"] == [alert_id]
|
||
alerts = _one(
|
||
env,
|
||
"SELECT COUNT(*) AS n FROM risk_alert WHERE customer_id = 'CUST-9527'"
|
||
" AND alert_type = 'freq_trade' AND created_at >= CURDATE()",
|
||
)
|
||
assert alerts["n"] == 1
|
||
row = _one(env, "SELECT payload FROM risk_alert WHERE alert_id = :a", a=alert_id)
|
||
assert len(json.loads(row["payload"])["events"]) == 2
|
||
|
||
|
||
# ---------- A-5:AML 命中 → 独立单 + L3 high + compliance 可见 + 不冻户 ----------
|
||
|
||
|
||
def test_a5_aml_hit_independent_alert(iclient, risk_demo_env):
|
||
c, fake = iclient
|
||
env = risk_demo_env
|
||
r = c.post(
|
||
"/api/simulate/trade",
|
||
json=_trade("CUST-1002", "PROD-005828", "subscribe", 100), # C2+R2 匹配,不阻断
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is False and body["aml_hit"] is True
|
||
trade_id = body["trade_id"]
|
||
|
||
# aml 单按本测试 trade_id 定位(核查单⑥:同日旧 aml 单不得干扰断言)
|
||
alert = _one(
|
||
env,
|
||
"SELECT * FROM risk_alert WHERE customer_id = 'CUST-1002' AND alert_type = 'aml'"
|
||
" AND trade_id = :t",
|
||
t=trade_id,
|
||
)
|
||
assert alert is not None
|
||
assert alert["risk_score"] == 95 and alert["status"] == "pending_review"
|
||
assert json.loads(alert["triggered_rules"]) == ["AML-001"]
|
||
# L3 置 high(FR-7)
|
||
l3 = _one(env, "SELECT * FROM customer_profile_l3 WHERE customer_id = 'CUST-1002'")
|
||
assert l3["monitor_tier"] == "high"
|
||
# 紧急推送含 compliance
|
||
pushed = [p for ch, p in fake.messages if ch == "risk:pub:alert"]
|
||
aml_push = [p for p in pushed if p["alert_id"] == alert["alert_id"]]
|
||
assert aml_push and "compliance" in aml_push[-1]["notify_role"]
|
||
# compliance 账号台账可见该单(A-7 强制 aml 过滤的另一面)
|
||
r = c.get("/api/risk/alerts", headers=COMPLIANCE)
|
||
assert r.status_code == 200
|
||
items = r.json()["items"]
|
||
assert items and all(i["alert_type"] == "aml" for i in items)
|
||
assert any(i["alert_id"] == alert["alert_id"] for i in items)
|
||
# 账户未被冻结(R-03 禁止自动冻户)
|
||
row = _core_one(env, "SELECT is_active FROM core_customer WHERE customer_id = 'CUST-1002'")
|
||
assert row["is_active"] == 1
|
||
_state["a5_alert_id"] = alert["alert_id"]
|
||
|
||
|
||
# ---------- A-7:人工处置状态机 + compliance 403 + GET 仅 aml ----------
|
||
|
||
|
||
def test_a7_handle_state_machine_compliance_forbidden(iclient, risk_demo_env):
|
||
c, _ = iclient
|
||
env = risk_demo_env
|
||
alert_id = _state["a3_alert_id"]
|
||
r = c.post(
|
||
f"/api/risk/alerts/{alert_id}/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"
|
||
# 状态机:已处置单禁止跳改
|
||
r2 = c.post(
|
||
f"/api/risk/alerts/{alert_id}/handle",
|
||
json={"handler_result": "confirmed_normal"},
|
||
headers=OFFICER,
|
||
)
|
||
assert r2.status_code == 409
|
||
# compliance 无处置权
|
||
r3 = c.post(
|
||
f"/api/risk/alerts/{alert_id}/handle",
|
||
json={"handler_result": "confirmed_normal"},
|
||
headers=COMPLIANCE,
|
||
)
|
||
assert r3.status_code == 403
|
||
# 处置审计留痕(同事务,agent_type='risk')
|
||
audit = _one(
|
||
env,
|
||
"SELECT * FROM audit_log WHERE event_type = 'alert_handle' AND actor_id = 'STAFF-90001'"
|
||
" AND decision = 'alert_handled' AND handler_result = 'confirmed_suspicious'",
|
||
)
|
||
assert audit is not None
|
||
|
||
|
||
# ---------- A-9:越权 403 + 审计 ----------
|
||
|
||
|
||
def test_a9_cross_customer_and_unassigned_advisor_403(iclient, risk_demo_env):
|
||
c, _ = iclient
|
||
env = risk_demo_env
|
||
# customer 查他人
|
||
r = c.post(
|
||
"/api/risk/suitability/check",
|
||
json={"customer_id": "CUST-3001", "product_id": "PROD-510300"},
|
||
headers=CUSTOMER_1002,
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_OWNER"
|
||
# advisor 查非名下(STAFF-10086 名下无 CUST-3001)
|
||
r = c.post(
|
||
"/api/risk/suitability/check",
|
||
json={"customer_id": "CUST-3001", "product_id": "PROD-510300"},
|
||
headers=ADVISOR_10087, # 名下无 CUST-3001
|
||
)
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_ASSIGNED"
|
||
denials = _one(
|
||
env,
|
||
"SELECT COUNT(*) AS n FROM audit_log WHERE event_type = 'authz' AND decision = 'forbidden'"
|
||
" AND actor_id IN ('CUST-1002', 'STAFF-10087') AND created_at >= CURDATE()",
|
||
)
|
||
assert denials["n"] >= 2
|
||
|
||
|
||
# ---------- 参数校验与无 trace 头兜底 ----------
|
||
|
||
|
||
def test_convert_400_and_no_new_trade_audit(iclient, risk_demo_env):
|
||
c, _ = iclient
|
||
env = risk_demo_env
|
||
before = _one(env, "SELECT COUNT(*) AS n FROM audit_log WHERE event_type = 'trade_request'")
|
||
r = c.post(
|
||
"/api/simulate/trade",
|
||
json=_trade("CUST-3001", "PROD-510300", "convert", 1000),
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 400 and r.json()["error_code"] == "BAD_REQUEST"
|
||
after = _one(env, "SELECT COUNT(*) AS n FROM audit_log WHERE event_type = 'trade_request'")
|
||
assert after["n"] == before["n"] # convert 属参数校验失败,不落审计
|
||
|
||
|
||
def test_missing_trace_header_generates_one(iclient):
|
||
c, _ = iclient
|
||
r = c.get("/api/risk/alerts", headers=OFFICER)
|
||
assert r.status_code == 200
|
||
assert r.headers["X-Trace-Id"].startswith("trc-")
|
||
|
||
|
||
def test_settings_thresholds_loaded():
|
||
"""冒烟:阈值配置与冻结规则一致(.env 未覆盖时)。"""
|
||
assert settings.risk_large_amount == 500000
|
||
# risk_assessment_valid_days 已随 AL-05 退役(有效期由 core_customer_risk.expires_at 驱动)
|
||
assert not hasattr(settings, "risk_assessment_valid_days")
|