相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。
一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
approve→reviews(需 body decision)、activate→activations、
rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
{data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
不再返回 FastAPI 默认的 {"detail": ...}。
二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。
三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
.env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。
四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。
五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。
验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
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"
|