风控列表游标绑定用户与查询条件(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 仍有效、
不同证据类型游标不互通。
This commit is contained in:
@@ -7,7 +7,7 @@ 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 decode_offset_cursor, encode_offset_cursor
|
||||
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
|
||||
|
||||
@@ -64,12 +64,15 @@ def context(**updates) -> RequestContext:
|
||||
|
||||
|
||||
def test_cursor_round_trip_and_invalid_values() -> None:
|
||||
assert decode_offset_cursor(encode_offset_cursor(20)) == 20
|
||||
assert decode_offset_cursor(None) == 0
|
||||
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")
|
||||
decode_offset_cursor("not-a-cursor", binding=binding)
|
||||
with pytest.raises(InvalidCursorError):
|
||||
decode_offset_cursor(encode_offset_cursor(1) + "x")
|
||||
decode_offset_cursor(encode_offset_cursor(1, binding=binding) + "x", binding=binding)
|
||||
|
||||
|
||||
def test_query_schema_enforces_business_page_sizes() -> None:
|
||||
@@ -105,14 +108,55 @@ async def test_overview_maps_levels_and_serializes_high_priority() -> None:
|
||||
@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(), RiskAlertPageQuery(limit=5))
|
||||
result = await service.list_alerts(context(), query)
|
||||
|
||||
assert result["has_more"] is True
|
||||
assert decode_offset_cursor(result["next_cursor"]) == 5
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user