Files
group_fqcd_jr/tests/unit/api/test_cursor_validation.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

209 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""游标校验契约测试(文档 §3.8 游标分页、§16.1 "非法游标返回 400 INVALID_CURSOR")。
覆盖三层:
1. `parse_cursor` 纯函数的合法/非法取值边界(真源是记录 ID 列类型与"边界必须唯一可解释");
2. HTTP 层:非法游标返回 `400 INVALID_CURSOR` 统一错误信封,合法游标照常透传;
3. 判定顺序:认证先于游标校验(未带令牌仍是 401,不因参数非法而变成 400),
管理面的权限闸门先于游标校验(未授权是 403,且游标校验早于任何数据库访问)。
全部进程内调用,不连数据库:非法游标在数据访问之前就被拒绝,合法路径用替身 Service。
"""
from collections.abc import AsyncIterator
from typing import Any
import pytest
from fastapi import Request
from fastapi.testclient import TestClient
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.core.contracts import RequestContext
from app.core.cursor import MAX_CURSOR_VALUE, parse_cursor
from app.core.errors import ForbiddenAgentError, InvalidCursorError
from app.main import create_app
MESSAGES = "/api/v1/conversations/session-1/messages"
ADMIN_RELEASES = "/api/v1/admin/config-releases"
ADMIN_AUDIT = "/api/v1/admin/audit-records"
# 全部必须被拒绝:非整数、负数、零、浮点、带符号、十六进制、超 BIGINT 上界、
# 内嵌空白、Unicode 数字(`str.isdigit()` 对最后两者为真,是容易漏掉的分支)。
INVALID_CURSORS = [
"abc", "-1", "0", "1.5", "+5", "0x10", "1e3", "1 2",
str(MAX_CURSOR_VALUE + 1), "9" * 25, "1", "١٢",
]
def fake_session() -> Any:
"""替身 session:合法路径用替身 Service,根本不会被使用。"""
return object()
async def override_session() -> AsyncIterator[Any]:
yield fake_session()
def authenticated_app(
monkeypatch: pytest.MonkeyPatch, *, permissions: tuple[str, ...] = ("conversation:read",),
roles: tuple[str, ...] = ("customer",),
) -> TestClient:
application = create_app()
async def context(request: Request) -> RequestContext:
built = RequestContext(user_id="9001", trace_id="trace-cursor",
roles=roles, permissions=permissions)
request.state.request_context = built
return built
application.dependency_overrides[build_request_context] = context
application.dependency_overrides[get_session] = override_session
return TestClient(application)
# --- 1. 纯函数边界 ---------------------------------------------------------
@pytest.mark.parametrize("raw,expected", [("1", 1), ("42", 42), (" 42 ", 42),
(str(MAX_CURSOR_VALUE), MAX_CURSOR_VALUE)])
def test_valid_cursor_is_parsed_into_boundary(raw: str, expected: int) -> None:
assert parse_cursor(raw) == expected
@pytest.mark.parametrize("raw", [None, "", " "])
def test_absent_cursor_means_first_page(raw: str | None) -> None:
"""不带游标(`?cursor=` 空值或纯空白)表示第一页。
空值不是"非法值"而是"未翻页"的常见写法:把它判 400 会让正常客户端在最后一页
突然失败,因此这里与"缺参数"同义;真正的非法取值走下面的用例。
"""
assert parse_cursor(raw) is None
@pytest.mark.parametrize("raw", INVALID_CURSORS)
def test_invalid_cursor_reports_document_code(raw: str) -> None:
with pytest.raises(InvalidCursorError) as excinfo:
parse_cursor(raw)
assert excinfo.value.code == "INVALID_CURSOR"
assert excinfo.value.status_code == 400
assert excinfo.value.message # 必须说明原因,不能是空消息
def test_invalid_cursor_message_explains_reason_without_echoing_input() -> None:
with pytest.raises(InvalidCursorError) as excinfo:
parse_cursor("token-<script>")
assert "十进制数字" in excinfo.value.message
assert "token-<script>" not in excinfo.value.message # 不回显客户端可控内容
# --- 2. HTTP 层 ------------------------------------------------------------
@pytest.mark.parametrize("raw", ["abc", "-1", "0", "1.5", "99999999999999999999"])
def test_messages_rejects_invalid_cursor_with_envelope(
monkeypatch: pytest.MonkeyPatch, raw: str
) -> None:
with authenticated_app(monkeypatch) as client:
response = client.get(MESSAGES, params={"cursor": raw})
assert response.status_code == 400
body = response.json()
assert set(body) == {"error", "meta"}
assert body["error"]["code"] == "INVALID_CURSOR"
assert body["error"]["retryable"] is False # 文档 §3.6:该码不可重试
assert body["error"]["field_errors"] == []
assert body["meta"]["trace_id"] == "trace-cursor"
def test_messages_forwards_valid_cursor_to_service(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
class RecordingService:
def __init__(self, session: Any) -> None:
del session
async def messages(
self, session_id: str, context: RequestContext, limit: int, before: int | None = None
) -> dict[str, object]:
captured.update(session_id=session_id, limit=limit, before=before)
return {"data": []}
monkeypatch.setattr("app.api.controllers.conversations.ConversationService", RecordingService)
with authenticated_app(monkeypatch) as client:
response = client.get(MESSAGES, params={"cursor": "42", "limit": "5"})
assert response.status_code == 200
assert captured == {"session_id": "session-1", "limit": 5, "before": 42}
def test_messages_without_cursor_keeps_first_page_behaviour(
monkeypatch: pytest.MonkeyPatch
) -> None:
captured: dict[str, Any] = {}
class RecordingService:
def __init__(self, session: Any) -> None:
del session
async def messages(
self, session_id: str, context: RequestContext, limit: int, before: int | None = None
) -> dict[str, object]:
captured.update(before=before, session_id=session_id)
return {"data": []}
monkeypatch.setattr("app.api.controllers.conversations.ConversationService", RecordingService)
with authenticated_app(monkeypatch) as client:
response = client.get(MESSAGES)
assert response.status_code == 200
assert captured["before"] is None # 合法无游标请求行为不变
def test_authentication_precedes_cursor_validation() -> None:
"""未带令牌 + 非法游标:必须先 401,不能因为参数非法先漏出 400。"""
application = create_app()
with TestClient(application) as client:
response = client.get(MESSAGES, params={"cursor": "abc"})
assert response.status_code == 401
assert response.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
# --- 3. 管理面:权限先于游标,游标先于数据库 ------------------------------
def test_admin_permission_precedes_cursor_validation(monkeypatch: pytest.MonkeyPatch) -> None:
class DenyingAuth:
@staticmethod
async def require(context: RequestContext, permission: str, *, admin: bool = False) -> None:
del context, permission, admin
raise ForbiddenAgentError("缺少操作权限")
monkeypatch.setattr("app.service.admin_service.AuthorizationService", DenyingAuth)
with authenticated_app(monkeypatch, permissions=("config:read",), roles=("admin",)) as client:
response = client.get(ADMIN_RELEASES, params={"cursor": "abc"})
assert response.status_code == 403
assert response.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"
@pytest.mark.parametrize("path", [ADMIN_RELEASES, ADMIN_AUDIT])
def test_admin_invalid_cursor_fails_before_database_access(
monkeypatch: pytest.MonkeyPatch, path: str
) -> None:
class AllowingAuth:
@staticmethod
async def require(context: RequestContext, permission: str, *, admin: bool = False) -> None:
del context, permission, admin
def exploding_factory() -> Any:
raise AssertionError("非法游标不应触发任何数据库访问")
monkeypatch.setattr("app.service.admin_service.AuthorizationService", AllowingAuth)
monkeypatch.setattr("app.service.admin_service.SessionFactory", exploding_factory)
with authenticated_app(monkeypatch, permissions=("config:read", "audit:read"),
roles=("admin",)) as client:
response = client.get(path, params={"cursor": "-7"})
assert response.status_code == 400
assert response.json()["error"]["code"] == "INVALID_CURSOR"