"""游标校验契约测试(文档 §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-