Files
group_fqcd_jr/tests/unit/service/test_risk_query_service.py
T
lzf_0626 a572c09a5c 风控列表游标绑定用户与查询条件(docs/25 遗留 P3)
docs/05 §3.8 要求游标绑定用户、查询条件、排序字段和方向,此前实现只把 offset
用 base64 包了一层:任何登录用户拿到别人的游标都能继续翻,换个筛选条件也能继续翻
(偏移量对不上就静默返回错页)。现在游标里携带 SHA-256 指纹:

- 指纹口径 = user_id + data_scope/customer_ids + 查询条件(排除 limit/cursor)
- 刻意排除 limit:它是分页参数、不是查询条件,算进去只会让翻页时改页大小失效
- /evidence/{source} 的 source 是路径参数,单独并入指纹,否则 customers 的
  游标能直接拿去翻 products
- 指纹不符一律 InvalidCursorError -> 400 INVALID_CURSOR(docs/05 §3.6)

新增 2 个单测:换用户/换筛选/换 data_scope 失效、改 limit 仍有效、
不同证据类型游标不互通。
2026-09-11 14:05:37 +08:00

166 lines
6.0 KiB
Python

from datetime import datetime
from decimal import Decimal
from types import MappingProxyType
import pytest
from app.api.schemas.risk import RiskAlertPageQuery, RiskEvidencePageQuery
from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError, InvalidCursorError
from app.core.risk_cursor import cursor_binding, decode_offset_cursor, encode_offset_cursor
from app.repository.fund_query_repository import CustomerScope, FundPage, FundRecord
from app.service.risk_query_service import RiskQueryService, scope_from_context
class FakeRepository:
def __init__(self) -> None:
self.page = FundPage(
entity="risk_alert",
items=(
FundRecord(
entity="risk_alert",
values=MappingProxyType(
{
"alert_no": "ALERT-001",
"customer_id": 9,
"risk_level": "高",
"amount": Decimal("100.00"),
"created_at": datetime(2026, 9, 10, 8, 0),
"rule_codes": ("RW-007",),
}
),
),
),
limit=5,
offset=0,
next_offset=5,
)
async def overview(self) -> dict:
return {
"total": 2,
"levels": {"低": 1, "中": 0, "高": 1},
"pending": 1,
"overdue": 0,
"high_priority": list(self.page.items),
}
async def list_alerts(self, **_kwargs) -> FundPage:
return self.page
async def get_alert_detail(self, _alert_no: str) -> FundRecord | None:
return None
def context(**updates) -> RequestContext:
values = {
"user_id": "990000002",
"trace_id": "trace",
"permissions": ("risk:alert:read",),
"data_scope": "all",
}
values.update(updates)
return RequestContext(**values)
def test_cursor_round_trip_and_invalid_values() -> None:
binding = cursor_binding(user_id="990000002", filters={"keyword": None})
assert (
decode_offset_cursor(encode_offset_cursor(20, binding=binding), binding=binding) == 20
)
assert decode_offset_cursor(None, binding=binding) == 0
with pytest.raises(InvalidCursorError):
decode_offset_cursor("not-a-cursor", binding=binding)
with pytest.raises(InvalidCursorError):
decode_offset_cursor(encode_offset_cursor(1, binding=binding) + "x", binding=binding)
def test_query_schema_enforces_business_page_sizes() -> None:
assert RiskAlertPageQuery(limit=5).limit == 5
assert RiskEvidencePageQuery(limit=10).limit == 10
with pytest.raises(ValueError):
RiskAlertPageQuery(limit=6)
with pytest.raises(ValueError):
RiskEvidencePageQuery(limit=11)
def test_scope_from_context_is_fail_closed() -> None:
assert scope_from_context(context()).is_unrestricted
assert scope_from_context(context(data_scope="self")).is_denied
scope = scope_from_context(
context(data_scope="own_customers", customer_ids=("9", "10"))
)
assert scope == CustomerScope.for_customers({9, 10})
@pytest.mark.asyncio
async def test_overview_maps_levels_and_serializes_high_priority() -> None:
service = RiskQueryService(None, repository=FakeRepository())
result = await service.overview(context())
assert result["levels"] == {"高风险": 1, "中风险": 0, "低风险": 1}
assert result["high_priority"][0]["customer_id"] == "9"
assert result["high_priority"][0]["amount"] == "100.00"
assert result["high_priority"][0]["created_at"] == "2026-09-10T08:00:00Z"
@pytest.mark.asyncio
async def test_alert_page_returns_opaque_cursor() -> None:
service = RiskQueryService(None, repository=FakeRepository())
query = RiskAlertPageQuery(limit=5)
result = await service.list_alerts(context(), query)
assert result["has_more"] is True
binding = RiskQueryService._binding(context(), query)
assert decode_offset_cursor(result["next_cursor"], binding=binding) == 5
assert result["items"][0]["rule_codes"] == ["RW-007"]
def test_cursor_is_bound_to_user_and_filters() -> None:
"""§3.8:游标绑定用户与查询条件,换个人或换个筛选条件都必须失效。"""
query = RiskAlertPageQuery(limit=5)
raw = encode_offset_cursor(5, binding=RiskQueryService._binding(context(), query))
assert decode_offset_cursor(raw, binding=RiskQueryService._binding(context(), query)) == 5
with pytest.raises(InvalidCursorError):
decode_offset_cursor(
raw,
binding=RiskQueryService._binding(context(user_id="990000003"), query),
)
with pytest.raises(InvalidCursorError):
other_filter = RiskAlertPageQuery(limit=5, keyword="张三")
decode_offset_cursor(
raw,
binding=RiskQueryService._binding(context(), other_filter),
)
# data_scope 收紧后旧游标同样失效
with pytest.raises(InvalidCursorError):
decode_offset_cursor(
raw,
binding=RiskQueryService._binding(
context(data_scope="own_customers", customer_ids=("9",)), query
),
)
def test_cursor_ignores_page_size_and_other_filters_do_not_collide() -> None:
"""翻页时改 limit 不该让游标失效;不同证据类型之间不能互相串用游标。"""
base = RiskEvidencePageQuery(limit=10)
binding = RiskQueryService._binding(context(), base, "customers")
again = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "customers")
other = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "products")
assert binding == again
assert binding != other
@pytest.mark.asyncio
async def test_missing_alert_detail_is_hidden() -> None:
service = RiskQueryService(None, repository=FakeRepository())
with pytest.raises(GenericResourceNotFoundError):
await service.get_alert_detail(context(), "ALERT-NOT-FOUND")