Files
group_fqcd_jr/tests/unit/api/test_frontend_boundaries.py
T
张胜宇 da1d14cb31 chore(sync): zsy_developcc 全量同步至 qyqy_develop(W27 口径)
- 分支内容对齐 qyqy_develop 3e24033,树完全一致(同步后 git diff 为空)
- 覆盖本轮 W27 交付:L0 表层判定层 + 出口 E6 行情 + 收益过滤槽位白名单
  + 免责声明分档 + 金标扩容至 55 条(全绿)+ 配置版本 244 已发布
- 新增 app/core/exit_codes.py、app/service/fund_trend_service.py 及 3 个测试文件
- 新增 开发文档\D3.9-客服Agent智能路由与行情出口设计-2026-09-21.md
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
2026-09-21 22:33:09 +08:00

308 lines
14 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.
"""前端 ↔ 后端边界守卫(`W11` 批次补测,答辩前的前端边界盘点产物)。
## 为什么要单开这一组
答辩前的边界盘点发现一类**同族缺口**:前端 `widget.js` 的输入框已经用
`maxlength="8000"` 截断,但服务端请求模型当初**不设上限**(或上限与落库列宽不符)。
前端截断只是体验,绕过前端(curl / 脚本 / 改前端)直发就能把任意长度文本或超长
ID 送进入库、入模型链路;MySQL 严格模式下这时表现为 `500` 而不是 `422`——
一次本该是"参数不合格"的失败,被冒泡成了服务端故障。
本文件把三类不变量钉死:
1. **口径一致**:前端输入框的 `maxlength` 必须等于服务端 `max_length`,
否则前端"允许输入"与服务端"允许接收"会静默分叉;
2. **入参上限 = 落库列宽**:请求模型的 `max_length` 必须 ≤ 对应列的 `String(n)`
(见 `app/model/session.py` / `app/model/platform.py` / `app/model/conversation.py`),
超限必须在入口变 `422`,而不是落库时才炸 `500`;
3. **路径参数同宽**:`{session_id}` / `{run_id}` / `{handover_id}` 等路径参数与列宽
对齐——这一层用 OpenAPI 契约断言,避免单元环境连库。
另含一条端点表守卫:`api-client.js` 里 `ENDPOINTS` 的每一项都必须在 `app.openapi()`
里真实存在。历史上 `HEALTH` 写的是 `/health`,而后端只挂了 `/internal/health/live`
与 `/internal/health/ready`——前端因此恒判"后端未连接",正是缺了这条守卫。
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.api.dependencies.auth import build_request_context
from app.api.schemas.agent_runs import AgentRunCreateRequest
from app.api.schemas.conversations import FeedbackRequest
from app.core.contracts import RequestContext
from app.main import create_app
from app.model.conversation import ConversationFeedback
from app.model.platform import RequestIdempotency
from app.model.session import ConversationSession
ROOT = Path(__file__).resolve().parents[3]
PORTAL = ROOT / "app" / "static" / "portal"
WIDGET = PORTAL / "common" / "customer-service-widget" / "widget.js"
API_CLIENT = PORTAL / "common" / "api-client.js"
AGENT_RUNS = "/api/v1/agent-runs"
#: 一份**合法**的建运行请求,边界用例只在它上面改一个字段。
VALID_RUN: dict[str, Any] = {
"agent_type": "customer_service",
"message": "查询我的基金持仓",
"session_id": "session-1",
"idempotency_key": "1234567890123456",
}
def _column_width(model: type, column: str) -> int:
length = model.__table__.c[column].type.length # type: ignore[attr-defined]
assert isinstance(length, int), f"{model.__name__}.{column} 不是定长列,无法比对"
return length
def _rejected(**overrides: Any) -> bool:
payload = {**VALID_RUN, **overrides}
try:
AgentRunCreateRequest(**payload)
except ValidationError:
return True
return False
def _client(permissions: tuple[str, ...] = ("agent:run",)) -> TestClient:
application = create_app()
async def context() -> RequestContext:
return RequestContext(
user_id="1", trace_id="fe-boundary", portal="public", permissions=permissions
)
application.dependency_overrides[build_request_context] = context
return TestClient(application)
def _endpoints() -> dict[str, tuple[str, str]]:
source = API_CLIENT.read_text(encoding="utf-8")
pairs = re.findall(r"(\w+):\s*\{\s*method:\s*'(\w+)',\s*path:\s*'([^']+)'", source)
assert len(pairs) >= 50, "端点表解析异常,正则可能已与 api-client.js 脱节"
return {name: (method, path) for name, method, path in pairs}
def _normalized(path: str) -> str:
"""把 `{sessionId}` / `{session_id}` 之类占位符统一成 `{}` 再比对。"""
return re.sub(r"\{[^}]+\}", "{}", path)
# --------------------------------------------------------------------------- #
# 一、口径一致:前端输入框 ↔ 服务端校验
# --------------------------------------------------------------------------- #
def test_widget_message_maxlength_matches_backend_limit() -> None:
"""`widget.js` 的 `maxlength` 必须与 `AgentRunCreateRequest.message` 的上限逐字一致。
这条断言是"两端同宽"的唯一防线:只改一边(比如前端产品改到 4000、或后端放宽
到 20000)都会立刻变红,而不会拖到线上才以"用户输入被静默截断/提交 500"暴露。
"""
source = WIDGET.read_text(encoding="utf-8")
matched = re.search(r'name="message"[^>]*maxlength="(\d+)"', source)
assert matched, "widget.js 里找不到 message 输入框的 maxlength"
frontend_limit = int(matched.group(1))
assert not _rejected(message="问" * frontend_limit), "前端允许的长度,后端不该拒绝"
assert _rejected(message="问" * (frontend_limit + 1)), "前端截断之外的长度,后端必须拒绝"
def test_message_and_session_bounds_match_storage_widths() -> None:
"""入参上限必须 ≤ 落库列宽,否则超限会以 500 而不是 422 出现。"""
session_width = _column_width(ConversationSession, "session_id")
idempotency_width = _column_width(RequestIdempotency, "idempotency_key")
assert not _rejected(session_id="s" * session_width)
assert _rejected(session_id="s" * (session_width + 1))
assert not _rejected(idempotency_key="k" * idempotency_width)
assert _rejected(idempotency_key="k" * (idempotency_width + 1))
def test_feedback_type_bound_matches_storage_width() -> None:
width = _column_width(ConversationFeedback, "feedback_type")
assert FeedbackRequest(rating=1, feedback_type="t" * width).feedback_type is not None
with pytest.raises(ValidationError):
FeedbackRequest(rating=1, feedback_type="t" * (width + 1))
def test_degenerate_inputs_are_rejected_at_the_model_layer() -> None:
assert _rejected(message="")
assert _rejected(session_id="")
assert _rejected(idempotency_key="123456789012345")
# --------------------------------------------------------------------------- #
# 二、HTTP 层:超限必须是 422 信封,不是 500
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
("overrides", "field"),
[
({"message": "问" * 8001}, "body.message"),
({"message": ""}, "body.message"),
({"session_id": "s" * 65}, "body.session_id"),
({"session_id": ""}, "body.session_id"),
({"idempotency_key": "k" * 65}, "body.idempotency_key"),
({"idempotency_key": "123456789012345"}, "body.idempotency_key"),
],
)
def test_oversized_body_fields_return_422_envelope(
overrides: dict[str, Any], field: str
) -> None:
with _client() as client:
response = client.post(
AGENT_RUNS, json={**VALID_RUN, **overrides},
headers={"X-Trace-ID": "fe-boundary"},
)
assert response.status_code == 422, response.text
body = response.json()
assert set(body) == {"error", "meta"}
error = body["error"]
assert error["code"] == "AGENT_INPUT_INVALID"
assert error["retryable"] is False
assert field in {item["field"] for item in error["field_errors"]}
assert body["meta"]["trace_id"] == "fe-boundary"
@pytest.mark.parametrize(
("method", "path"),
[
("GET", "/api/v1/conversations/" + "s" * 65),
("POST", "/api/v1/conversations/" + "s" * 65 + "/closures"),
("POST", "/api/v1/conversations/" + "s" * 65 + "/handover-requests"),
("GET", "/api/v1/conversations/" + "s" * 65 + "/messages"),
("GET", "/api/v1/agent-runs/" + "r" * 65),
("GET", "/api/v1/agent-runs/" + "r" * 65 + "/events"),
("POST", "/api/v1/agent-runs/" + "r" * 65 + "/cancellations"),
("GET", "/api/v1/handover-requests/" + "t" * 65),
],
)
def test_oversized_path_params_return_422(method: str, path: str) -> None:
"""路径参数超宽必须在路由层被拦下——不能带着超长 ID 去查库/写库。"""
with _client(permissions=("agent:run", "conversation:read", "handover:write")) as client:
response = client.request(method, path, json={} if method == "POST" else None)
assert response.status_code == 422, response.text
assert response.json()["error"]["code"] == "AGENT_INPUT_INVALID"
@pytest.mark.parametrize(
("method", "route", "param"),
[
("GET", "/api/v1/conversations/{session_id}", "session_id"),
("GET", "/api/v1/conversations/{session_id}/messages", "session_id"),
("POST", "/api/v1/conversations/{session_id}/closures", "session_id"),
("POST", "/api/v1/conversations/{session_id}/handover-requests", "session_id"),
("GET", "/api/v1/agent-runs/{run_id}", "run_id"),
("GET", "/api/v1/agent-runs/{run_id}/events", "run_id"),
("POST", "/api/v1/agent-runs/{run_id}/cancellations", "run_id"),
("GET", "/api/v1/handover-requests/{handover_id}", "handover_id"),
],
)
def test_path_params_declare_width_constraints_in_openapi(
method: str, route: str, param: str
) -> None:
"""契约层守卫:OpenAPI 必须暴露路径参数的长度上限(前端据此生成校验/文档)。"""
spec = create_app().openapi()["paths"][route][method.lower()]
parameters = {item["name"]: item for item in spec.get("parameters", [])}
assert param in parameters, f"{route} 未声明路径参数 {param}"
schema = parameters[param]["schema"]
assert schema.get("maxLength") == 64
assert schema.get("pattern") == r"^[A-Za-z0-9_-]+$"
@pytest.mark.parametrize("path", ["/api/v1/conversations/有中文", "/api/v1/agent-runs/run id"])
def test_path_params_reject_illegal_characters(path: str) -> None:
with _client(permissions=("agent:run", "conversation:read")) as client:
response = client.get(path)
assert response.status_code == 422, response.text
# --------------------------------------------------------------------------- #
# 三、鉴权/权限:边界收紧了,入口守卫不能跟着松
# --------------------------------------------------------------------------- #
def test_agent_run_requires_authentication() -> None:
with TestClient(create_app()) as client:
response = client.post(AGENT_RUNS, json=VALID_RUN)
assert response.status_code == 401
assert response.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
def test_missing_agent_permission_is_forbidden() -> None:
"""权限集里没有 `agent:run` 就必须 403——长度约束不得掩盖权限判定。"""
with _client(permissions=()) as client:
response = client.post(AGENT_RUNS, json=VALID_RUN)
assert response.status_code == 403, response.text
assert response.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"
def test_visitor_role_can_run_but_only_with_public_scope() -> None:
"""访客**可以**提交运行(`agent:run` 是设计内),但权限集里不得有任何个人数据权限。
⚠️ 这一条曾经被写反。盘点时的第一直觉是「访客调 R001 必须 403」,实测打到
真机才发现访客令牌**本来就带** `agent:run`——客服浮窗的匿名提问正是走这条路;
真正的不变量不是「访客不能提问」,而是「访客只能拿公共档位、不得触个人数据」。
因此这里的判据改成**权限集内容**:一旦有人往访客权限里追加
`customer:` / `profile:` / `memory:` / `handover:` / `conversation:` 前缀的权限码,
这条会立刻变红(`app/core/actor.py` 的注释也明确写了"不允许在这里追加权限码")。
"""
from app.core.actor import VISITOR_DATA_SCOPE, VISITOR_PERMISSIONS
# 逐项列出**允许**的权限码,而不是"只要不含个人数据前缀就放行":
# 白名单式才能拦住"新增了一个我们没想到的公开权限面"。
# `fund:quote:read` 是 `W27` / `DEC-W27-11` 批准的**唯一新增项** ——
# 净值走势是公开事实(`fin_product` 的场内产品 + `fin_nav_history`),
# 不读任何个人数据、也不读画像;对应的出口是 `E6`。
assert set(VISITOR_PERMISSIONS) == {"agent:run", "knowledge:query", "fund:quote:read"}
# ⚠️ 逆向守卫:`fund:` 前缀里**只允许** `fund:quote:read` 这一个码。
# 将来若有人顺手加上 `fund:order:read`(成交明细=个人数据),上面那条
# 个人数据前缀判据抓不到(前缀是 `fund:` 不是 `customer:`),但这一条会立刻变红。
assert {name for name in VISITOR_PERMISSIONS if name.startswith("fund:")} == {
"fund:quote:read"
}
assert VISITOR_DATA_SCOPE == "public"
personal_prefixes = ("customer:", "profile:", "memory:", "handover:", "conversation:")
assert [
name for name in VISITOR_PERMISSIONS if name.startswith(personal_prefixes)
] == []
# --------------------------------------------------------------------------- #
# 四、端点表守卫:前端调的每一条都必须是后端真实存在的路由
# --------------------------------------------------------------------------- #
def test_frontend_endpoint_table_matches_real_routes() -> None:
spec = create_app().openapi()["paths"]
available = {
(method.upper(), _normalized(path))
for path, operations in spec.items()
for method in operations
}
missing = [
f"{name} {method} {path}"
for name, (method, path) in _endpoints().items()
if (method.upper(), _normalized(path)) not in available
]
assert not missing, "api-client.js 引用了后端不存在的端点:\n" + "\n".join(missing)
def test_health_probe_uses_a_registered_route() -> None:
"""`HEALTH` 历史上写的是 `/health`(后端没有),前端因此恒判"后端离线"。"""
method, path = _endpoints()["HEALTH"]
assert method == "GET"
assert path in create_app().openapi()["paths"]