Files
group_fqcd_jr/tests/unit/api/test_frontend_boundaries.py
T
张胜宇 3e24033f72 feat(W27): L0 表层判定层 + 出口 E6 行情 + 收益过滤槽位白名单 + 免责声明分档(设计与代码同轮完成)
设计与依据:新增 D3.9-客服Agent智能路由与行情出口设计-2026-09-21.md(CS-ARCH-2026-024)
DEC-W27-1~12 全部批准并落地。

## 新增(3 个源文件 + 3 个测试文件)
- app/core/exit_codes.py:出口码注册表(E0/E1/E2a~E2e/E3/E4/E5a~E5c/E6/E8/CHAT/LOGIN/CONTACT)
  + NON_BUSINESS_EXIT_CODES 白名单(免责分档的判据面)
- app/service/fund_trend_service.py(E6):summarize_trend() 纯函数 + query_fund_trend 工具
  读 fin_nav_history;按净值日个数(5/20/60/120)给区间涨跌 / 区间高低 / 来源与区间
- tests/unit/{service,tools} 新增 45 条守卫单测(E6 出口 / L0 表层判定 / 工具配置)

## 改动(重点)
- customer_service.py:
  · _route_surface()(L0-a 闲聊 / L0-b 行情 / L0-e 无信息量),位置在安全路由之后
  · drop_yield_claims() 重写为「入口闸 × 槽位白名单」+ fail-closed
    (旧黑名单会误删权重:A-06 业绩基准公式整行消失)
  · allowed_tools 补 query_fund_trend —— 修掉发布被拒 422「配置超出 Agent 工具上限」
    (该 422 是**子集**校验而非数量上限,根因即此项漏配)
  · 25 处 CoreResult 全部补 exit_code(AST 守卫保证零遗漏)
- customer_service_rules.py:闲聊判定重写(业务实体边界 + 词表 + 特征串 + 语气词)
  + is_low_information_message() + is_contact_inquiry()
- governance.py:免责声明按 exit_code 分档(业务档完整 / 非业务档轻型)
- actor.py:VISITOR_PERMISSIONS 补 fund:quote:read(E6 对访客开放,DEC-W27-11)
- tools/:seed_compliance_baseline 新增 TPL_DISCLAIMER_LIGHT;publish 工具改为
  「从生效版本派生原列表再追加」(_inherit_tools 取不到返回 None,防静默改窄)

## 实测
- 金标扩容 46 → 55(新增 Q 组行情 5 条 + C 组闲聊 4 条)
- M-1 55/55、M-4 55/55;M-5 与四项零容忍(M-7/M-8/M-9/M-10)全 0
- 原 46 条可比基线逐项不变(46/46);M-6 9.1%(5 条全在转人工白名单内)
- 全量 pytest 2094 passed / 3 skipped;ruff 零新增(余 5 条与 HEAD 逐条对应)
- 配置版本 244(cs-tools-75813de45421)已发布并激活

## 文档
- 新增 D3.9(设计专册);D1.1 §34 补「代码实施已同轮完成」并把旧表述作废
- D3.7:新增 Q 组判据表 + §6.4 第四次实测 + M-3/M-9 口径澄清
- D2.9:新增 §2.11(9 条新增用例的实测答复原文)+ 汇总表改双列口径
- D2.1:新增 v6.41 执行条目
- 权威副本(D:\桌面\金融\)→ 仓库镜像 全量比对一致(0 缺失 / 0 不一致)

## 诚实未做项
- 知识出口(E3/E4)侧的实体锚点闸门未做(故金标扩容 9 条而非 12 条,A-11~A-13 未成集)
- 阈值未重标(D3.9 §3.4 只给方法);M-3 分母未改(仅明示口径);
  D1.1 §0 与 §4.0 历史计数偏移(差 1)仍沿用
2026-09-21 22:32:39 +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"]