Files
group_xinghuo_jinrong/tests/test_risk_repository.py
T
zhanghongyu_0626 793c0307f8 feat(risk): Enhance risk management functionality and access control
- 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.
2026-09-11 17:07:22 +08:00

239 lines
9.3 KiB
Python

"""risk_repository 单测(A3 · sqlite 内存库;JSON/ENUM 列用 TEXT 兼容)。
L3 为 insert_l3/update_l3 两方法(无 MySQL 方言残留),更新路径已测。
"""
import json
from datetime import datetime
from decimal import Decimal
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine
from app.repository.risk_repository import RiskRepository
from app.repository.repo_access import RiskListAccess
@pytest.fixture()
def repo():
engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12)
def make_alert(alert_id, alert_type="large_amount", cid="C1", rules=None, score=70):
return {
"alert_id": alert_id,
"trace_id": "trc-test",
"customer_id": cid,
"trade_id": "TRD-1",
"alert_type": alert_type,
"triggered_rules": rules or ["RISK-001"],
"risk_score": score,
"status": "pending_review",
"payload": {"product_id": "P1", "events": []},
}
yield RiskRepository(engine=engine), engine, make_alert
engine.dispose()
def test_insert_and_get_alert(repo):
r, _, make_alert = repo
r.insert_alert(make_alert("ALT-1"))
got = r.get_alert("ALT-1")
assert got["alert_type"] == "large_amount"
assert got["triggered_rules"] == ["RISK-001"]
assert got["payload"]["product_id"] == "P1"
assert got["status"] == "pending_review"
def test_find_pending_event_alert(repo):
r, _, make_alert = repo
r.insert_alert(make_alert("ALT-E", alert_type="large_amount"))
found = r.find_pending_event_alert("C1", datetime(2026, 1, 1))
assert found is not None and found["alert_id"] == "ALT-E"
# suitability 类型不在事件类查询范围
r.insert_alert(make_alert("ALT-S", alert_type="suitability"))
assert r.find_pending_event_alert("C1", datetime(2026, 1, 1))["alert_id"] == "ALT-E"
def test_find_pending_suitability_alert_matches_product(repo):
r, _, make_alert = repo
r.insert_alert(make_alert("ALT-S", alert_type="suitability"))
assert r.find_pending_suitability_alert("C1", "P1", datetime(2026, 1, 1)) is not None
assert r.find_pending_suitability_alert("C1", "P2", datetime(2026, 1, 1)) is None
def test_append_alert_event_merges(repo):
r, _, make_alert = repo
r.insert_alert(make_alert("ALT-1", rules=["RISK-001"], score=70))
r.append_alert_event(
"ALT-1",
event={"trade_id": "TRD-2", "amount": "600000"},
triggered_rules=["RISK-002", "RISK-001"],
risk_score=50,
alert_type="large_amount",
)
got = r.get_alert("ALT-1")
assert got["triggered_rules"] == ["RISK-001", "RISK-002"] # 合并未重复
assert got["risk_score"] == 70 # 取 max,不被 50 降
assert len(got["payload"]["events"]) == 1
assert got["payload"]["events"][0]["trade_id"] == "TRD-2"
# 再追加一笔,score 升到 90
r.append_alert_event("ALT-1", {"trade_id": "TRD-3"}, ["RISK-005"], 80, "pattern")
got = r.get_alert("ALT-1")
assert got["risk_score"] == 80
assert got["alert_type"] == "pattern" # 类型随最高分规则更新
assert len(got["payload"]["events"]) == 2
def test_append_missing_alert_raises(repo):
r, _, _ = repo
with pytest.raises(ValueError):
r.append_alert_event("NOPE", {}, ["RISK-001"], 70, "large_amount")
def test_update_alert_status_state_machine(repo):
r, _, make_alert = repo
r.insert_alert(make_alert("ALT-1"))
assert r.update_alert_status("ALT-1", "confirmed_suspicious", "STAFF-30001", "确认可疑") is True
got = r.get_alert("ALT-1")
assert got["status"] == "confirmed_suspicious"
assert got["handler_id"] == "STAFF-30001"
assert got["handled_at"] is not None
# 已处置不可再改(状态机只允许 pending_review → 三态)
assert r.update_alert_status("ALT-1", "confirmed_normal", "STAFF-30002", None) is False
def test_list_alerts_filters_and_pages(repo):
r, _, make_alert = repo
for i in range(1, 4):
r.insert_alert(make_alert(f"ALT-{i}", alert_type="large_amount" if i < 3 else "aml"))
items, total = r.list_alerts(access=RiskListAccess.unit_test(), alert_type="large_amount", page=1, page_size=20)
assert total == 2 and len(items) == 2
items, total = r.list_alerts(access=RiskListAccess.unit_test(), alert_type="aml")
assert total == 1 and items[0]["alert_id"] == "ALT-3"
items, total = r.list_alerts(access=RiskListAccess.unit_test(), customer_id="C1")
assert total == 3
def test_list_alerts_pagination_offset(repo):
"""分页 offset 路径(评审 P2-4①)。"""
r, _, make_alert = repo
for i in range(1, 4):
r.insert_alert(make_alert(f"ALT-{i}"))
page1, total = r.list_alerts(access=RiskListAccess.unit_test(), page=1, page_size=2)
page2, _ = r.list_alerts(access=RiskListAccess.unit_test(), page=2, page_size=2)
assert total == 3 and len(page1) == 2 and len(page2) == 1
ids = [a["alert_id"] for a in page1] + [a["alert_id"] for a in page2]
assert set(ids) == {"ALT-1", "ALT-2", "ALT-3"}
assert not set(a["alert_id"] for a in page1) & set(a["alert_id"] for a in page2)
def test_list_alerts_handled_status(repo):
r, _, make_alert = repo
r.insert_alert(make_alert("ALT-P"))
r.insert_alert(make_alert("ALT-D"))
r.update_alert_status("ALT-D", "confirmed_normal", "STAFF-30001", None)
pending, _ = r.list_alerts(access=RiskListAccess.unit_test(), status="pending_review")
handled, ht = r.list_alerts(access=RiskListAccess.unit_test(), status="handled")
assert len(pending) == 1 and pending[0]["alert_id"] == "ALT-P"
assert ht == 1 and handled[0]["alert_id"] == "ALT-D"
def test_find_pending_excludes_previous_day(repo):
"""跨日反向:昨日 pending 单不命中当日查询(评审 P2-4③)。"""
r, engine, make_alert = repo
r.insert_alert(make_alert("ALT-OLD"))
with engine.begin() as conn:
conn.execute(
text("UPDATE risk_alert SET created_at = :ts WHERE alert_id = 'ALT-OLD'"),
{"ts": datetime(2026, 9, 5, 23, 0)},
)
assert r.find_pending_event_alert("C1", datetime(2026, 9, 6, 0, 0)) is None
def test_append_alert_event_empty_rules(repo):
"""空 triggered_rules 合并(评审 P2-4④):不破坏已有规则且 score 仍可升。"""
r, _, make_alert = repo
r.insert_alert(make_alert("ALT-1", rules=["RISK-001"], score=70))
r.append_alert_event("ALT-1", {"trade_id": "TRD-2"}, [], 90, "pattern")
got = r.get_alert("ALT-1")
assert got["triggered_rules"] == ["RISK-001"]
assert got["risk_score"] == 90 and got["alert_type"] == "pattern"
def test_insert_suitability_log(repo):
"""AL-05 对齐 main 契约:21 列全列 INSERT + rule_refs JSON 序列化落库。"""
r, engine, _ = repo
log = {
"trace_id": "trc-x",
"customer_id": "C1",
"product_id": "P1",
"product_name": "测试产品",
"customer_risk_level": "C1",
"product_risk_level": "R4",
"investor_category": "ordinary",
"match_result": "forbidden",
"mismatch_type": "risk_level",
"is_matched": 0,
"is_blocked": 1,
"requires_disclosure": 0,
"needs_branch_confirm": 0,
"risk_was_expired": 0,
"block_reason": "客户风险等级与产品最低等级不匹配",
"block_response_code": "SUIT_RISK_MISMATCH",
"check_source": "r02_trade",
"actor_id": "svc-trade-suitability",
"request_ref": None,
"profile_l1_version": None,
"rule_refs": ["JR-AST-012"],
}
r.insert_suitability_log(log)
row = engine.connect().execute(
text(
"SELECT customer_risk_level, is_blocked, match_result, block_response_code,"
" check_source, actor_id, rule_refs FROM risk_suitability_log"
)
).first()
assert row[0] == "C1" and row[1] == 1
assert row[2] == "forbidden" and row[3] == "SUIT_RISK_MISMATCH"
assert row[4] == "r02_trade" and row[5] == "svc-trade-suitability"
assert row[6] == '["JR-AST-012"]'
def test_l3_insert_and_get(repo):
r, _, _ = repo
r.insert_l3(
"C1", "high", 95, {"aml": True}, ["aml_hit_pending_review"], "ALT-AML-1", datetime(2026, 9, 6)
)
got = r.get_l3("C1")
assert got["monitor_tier"] == "high"
assert got["monitor_tags"] == ["aml_hit_pending_review"]
assert got["score_dimensions"] == {"aml": True}
def test_l3_update_after_insert(repo):
r, _, _ = repo
r.insert_l3("C1", "watch", 80, {}, ["pattern"], "ALT-1", datetime(2026, 9, 6))
r.update_l3("C1", "high", 95, {"aml": True}, ["pattern", "aml_hit_pending_review"], "ALT-2",
datetime(2026, 9, 6, 12))
got = r.get_l3("C1")
assert got["monitor_tier"] == "high"
assert got["monitor_tags"] == ["pattern", "aml_hit_pending_review"]
assert got["last_alert_id"] == "ALT-2"
def test_list_active_aml_entries(repo):
r, engine, _ = repo
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO risk_aml_list (list_id, list_type, full_name, is_active) VALUES"
"('AML-1', 'sanction', '客户·赵**', 1),"
"('AML-2', 'pep', '客户·已停**', 0)"
)
)
entries = r.list_active_aml_entries()
assert len(entries) == 1
assert entries[0]["full_name"] == "客户·赵**"