1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
209 lines
8.4 KiB
Python
209 lines
8.4 KiB
Python
"""游标校验契约测试(文档 §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"
|