test: 补端点编号守卫与权限判定的 HTTP 层用例

补两道守卫,覆盖 2026-09-12 那次合并评审暴露出来的两个盲区。

一、`docs/05` §19 端点编号唯一性(新增 `tools/check_docs_endpoint_ids.py`)
- 背景:`tools/check_authoritative_docs.py` 只校验 `docs/` 的**文件名编号**,不校验
  §19 的**端点编号**。两条线各自新增端点时都占了 `A034`/`A035`,合并后 §19 同时
  存在两个 `A034` 与两个 `A035`,而文档守卫照样通过 —— 编号复用会静默遗留。
- 口径要点:**不枚举前缀白名单**,前缀从数据里归纳后连同数量一起输出。同一件事上
  还踩过一次"扫描正则写成 `[AMKCS]`,漏掉 `O`/`R` 两段,把 62 个端点报成 55 个"——
  漏掉的号段一旦被复用,脚本仍会报"重复 0"。所以覆盖报告会打印
   `62 个端点编号 / 6 个号段(A×40、C×7、K×4、M×4、O×3、R×4)`,让漏扫本身可见。
- 只读、不依赖任何环境变量(纯解析文档),可在裸检出环境直接跑。

二、端点权限判定的 HTTP 层用例(新增 `tests/unit/api/test_permission_enforcement.py`)
- 背景:既有用例都通过 `build_request_context` 覆写注入**已经带好权限**的上下文,
  只覆盖"有权限能通",覆盖不到"缺权限必须被拒"。而"权限码在库里根本不存在"这类
  环境数据问题(本次三个新权限码)恰恰只会在这一层暴露:权限判定发生在身份解析之后,
  服务层测试自己构造 `RequestContext`,权限字段由测试塞入,所以全绿也照样漏。
- 覆盖客服二期三个权限码对应的 5 个端点:
  `handover:read`(工单列表/详情)、`memory:candidate:review`(候选列表/审核)、
  `memory:candidate:confirm`(用户确认)。
- 正反双向断言:缺权限 → 必须 403 且错误码为 `AGENT_PERMISSION_DENIED`;
  带权限 → **不能**再是 403(这一条把端点要求的权限码钉住,改动即红)。
- 只替换两处边界:`build_request_context`(跳过 JWT 与身份库)与
  `AuthorizationService` 的审计落库(内存替身);真实路由、真实权限判定与真实 403 信封。
- 已做反向验证:给一个不存在的权限码时,5 个端点全部被拒(403),确认正向断言非空过。

验证(隔离 worktree,基线 `origin/qyqy_develop` = 4d8edb4):
pytest tests/unit tests/contract -> 1294 passed, 2 skipped, 0 failed;
ruff check app tests tools alembic 全通过;mypy app 244 源文件 0 错;
check_authoritative_docs(50 份文档无撞号)与本脚本均通过。
This commit is contained in:
张胜宇
2026-09-12 12:57:16 +08:00
parent 4d8edb4b7f
commit c5adf01603
3 changed files with 450 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
"""`docs/05-接口文档.md` §19 端点编号唯一性(只读,不连库)。
规则与动机见 `tools/check_docs_endpoint_ids.py` 的模块说明:`check_authoritative_docs.py`
只校验 `docs/` 的**文件名编号**,不校验 §19 的**端点编号** —— 2026-09-12 两条线各自
新增端点时都占了 `A034`/`A035`,合并后同时存在两个 `A034` 与两个 `A035` 却仍然通过守卫。
这里额外守住两件事:
1. **不能"空扫通过"**:真实文档必须真的扫到足量编号,否则脚本坏掉了也会显示"无重复";
2. **覆盖范围要可见**:扫描不得依赖前缀白名单 —— 同一件事上曾因正则写成 `[AMKCS]`
而漏掉 `O`/`R` 两段,把 62 个端点报成 55 个;漏掉的号段一旦被复用,脚本仍会报
"重复 0"。所以覆盖报告必须列出每个号段的数量。
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import ModuleType
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[3]
#: 真实 §19 的规模下限:远小于实际数量,只为拦住"扫到 0 条也算通过"。
MIN_REAL_ENDPOINTS = 40
def _load_tool_module() -> ModuleType:
path = PROJECT_ROOT / "tools" / "check_docs_endpoint_ids.py"
spec = importlib.util.spec_from_file_location("check_docs_endpoint_ids", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _write_doc(tmp_path: Path, body: str) -> Path:
doc = tmp_path / "05-接口文档.md"
doc.write_text(
"# 接口文档\n\n## 19. 接口总目录\n\n"
"| 编号 | 方法与路径 | 权限 | 幂等 | 成功状态 | 审计 |\n"
"|---|---|---|---|---|---|\n"
f"{body}\n\n## 20. 变更流程\n\n占位。\n",
encoding="utf-8",
)
return doc
def test_real_document_has_no_duplicate_endpoint_ids() -> None:
module = _load_tool_module()
assert module.collect_findings() == []
def test_real_document_actually_scans_enough_endpoints() -> None:
"""防止脚本坏掉后"什么都没扫到"却报通过。"""
module = _load_tool_module()
ids, _ = module.collect_rows(module.read_section())
assert len(ids) >= MIN_REAL_ENDPOINTS
assert len(set(ids)) == len(ids)
def test_duplicate_endpoint_id_is_detected(tmp_path: Path) -> None:
module = _load_tool_module()
doc = _write_doc(
tmp_path,
"| A034 | `POST /api/v1/auth/tokens` | 公开 | 否 | `200` | 否 |\n"
"| A034 | `GET /api/v1/admin/customer-profile-candidates` | `x:y` | 否 | `200` | 否 |\n",
)
findings = module.collect_findings(doc)
assert any("A034" in item and "重复" in item for item in findings)
def test_unrecognized_first_column_is_reported(tmp_path: Path) -> None:
module = _load_tool_module()
doc = _write_doc(tmp_path, "| A034(临时) | `GET /x` | `a:b` | 否 | `200` | 否 |\n")
findings = module.collect_findings(doc)
assert any("无法识别" in item for item in findings)
def test_coverage_report_lists_every_prefix_segment(tmp_path: Path) -> None:
"""覆盖报告必须暴露各号段数量 —— 漏扫一个前缀就会在这里显形。"""
module = _load_tool_module()
doc = _write_doc(
tmp_path,
"| A001 | `GET /a1` | `a:b` | 否 | `200` | 否 |\n"
"| A002 | `GET /a2` | `a:b` | 否 | `200` | 否 |\n"
"| M003 | `GET /m3` | `a:b` | 否 | `200` | 否 |\n"
"| O001 | `GET /o1` | `a:b` | 否 | `200` | 否 |\n"
"| R001 | `GET /r1` | `a:b` | 否 | `200` | 否 |\n",
)
report = module.describe_coverage(doc)
for expected in ("5 个端点编号", "4 个号段", "A×2", "M×1", "O×1", "R×1"):
assert expected in report, report
def test_missing_section_raises(tmp_path: Path) -> None:
module = _load_tool_module()
doc = tmp_path / "05-接口文档.md"
doc.write_text("# 接口文档\n\n## 1. 概述\n\n没有 §19。\n", encoding="utf-8")
with pytest.raises(module.DocumentStructureError):
module.collect_findings(doc)
def test_empty_checklist_prepares_no_false_success(tmp_path: Path) -> None:
"""§19 存在但一张表都没有时,必须报结构异常,而不是"无重复"。"""
module = _load_tool_module()
doc = _write_doc(tmp_path, "本节没有表格。")
findings = module.collect_findings(doc)
assert any("没有扫描到任何端点编号" in item for item in findings)