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:
@@ -0,0 +1,132 @@
|
||||
"""校验 `docs/05-接口文档.md` §19「接口总目录」的端点编号唯一性(只读,不连数据库)。
|
||||
|
||||
## 为什么需要它
|
||||
|
||||
`tools/check_authoritative_docs.py` 只校验 `docs/` 的**文件名编号**与权威性声明,
|
||||
**不校验 §19 里每个端点的编号**。2026-09-12 出过一次真实事故:两条线各自新增端点时
|
||||
都占用了 `A034`/`A035`,合并后 §19 同时存在两个 `A034` 与两个 `A035`,
|
||||
而文档守卫照样通过 —— 编号复用比"编号不够"更麻烦,而且**静默遗留**。
|
||||
|
||||
## 口径(含覆盖自检)
|
||||
|
||||
- 扫描范围:§19 章节内的**所有**表格行首列;
|
||||
- **不枚举前缀白名单**,前缀从数据里归纳后打印出来。同一件事上还踩过一次
|
||||
"扫描正则写成 `[AMKCS]` 就漏掉 `O`/`R` 两段,把 62 个端点报成 55 个" ——
|
||||
一旦被漏掉的号段将来被复用,脚本仍会报"重复 0"。所以这里把
|
||||
**"扫到哪几个号段、各多少条、共计多少"** 一并输出,让覆盖范围本身可核对;
|
||||
- 首列不是 `XXX###` 形状的行会被显式列出来(而不是静默跳过);
|
||||
- 只读:不修改任何文件。
|
||||
|
||||
用法:
|
||||
|
||||
python tools/check_docs_endpoint_ids.py
|
||||
|
||||
退出码:0 = 无重复;1 = 有重复或文档结构异常。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
INTERFACE_DOC = PROJECT_ROOT / "docs" / "05-接口文档.md"
|
||||
|
||||
#: §19 的起止标题(`: \\s*` 之间允许一个空格,避免依赖标题里的具体文字)。
|
||||
SECTION_START = re.compile(r"^##\s*19\.")
|
||||
SECTION_END = re.compile(r"^##\s*20\.")
|
||||
|
||||
#: 表格行:只取首列,容忍单元格两侧空白。
|
||||
TABLE_ROW = re.compile(r"^\|(.+)\|\s*$")
|
||||
#: Markdown 分隔行:| --- | :---: | 之类。
|
||||
SEPARATOR = re.compile(r"^[\s\-:|]+$")
|
||||
#: 端点编号形状:1~4 个大写字母 + 3~4 位数字(A034 / M003 / R001)。
|
||||
ENDPOINT_ID = re.compile(r"^[A-Z]{1,4}\d{3,4}$")
|
||||
|
||||
|
||||
class DocumentStructureError(RuntimeError):
|
||||
"""`docs/05` 的 §19 结构不符合预期,无法安全校验。"""
|
||||
|
||||
|
||||
def read_section(path: Path = INTERFACE_DOC) -> list[str]:
|
||||
"""返回 §19 的正文行(不含起止标题)。"""
|
||||
if not path.exists():
|
||||
raise DocumentStructureError(f"缺少接口权威文档:{path}")
|
||||
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
start = next((i for i, line in enumerate(lines) if SECTION_START.match(line)), None)
|
||||
if start is None:
|
||||
raise DocumentStructureError("接口文档里找不到 §19 章节标题")
|
||||
end = next(
|
||||
(i for i in range(start + 1, len(lines)) if SECTION_END.match(lines[i])),
|
||||
len(lines),
|
||||
)
|
||||
return lines[start + 1 : end]
|
||||
|
||||
|
||||
def collect_rows(lines: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""返回 `(首列编号列表, 无法识别的首列列表)`。
|
||||
|
||||
表头行、分隔行、非表格行都会被跳过;非空但形状不对的首列会被归类为
|
||||
"无法识别",以便显式暴露而不是静默漏扫。
|
||||
"""
|
||||
ids: list[str] = []
|
||||
unrecognized: list[str] = []
|
||||
for line in lines:
|
||||
match = TABLE_ROW.match(line.strip())
|
||||
if match is None:
|
||||
continue
|
||||
first_cell = match.group(1).split("|", 1)[0].strip()
|
||||
if not first_cell or SEPARATOR.match(first_cell):
|
||||
continue
|
||||
if first_cell == "编号": # 表头
|
||||
continue
|
||||
if ENDPOINT_ID.match(first_cell):
|
||||
ids.append(first_cell)
|
||||
else:
|
||||
unrecognized.append(first_cell)
|
||||
return ids, unrecognized
|
||||
|
||||
|
||||
def collect_findings(path: Path = INTERFACE_DOC) -> list[str]:
|
||||
"""返回问题列表;空列表表示一致。"""
|
||||
lines = read_section(path)
|
||||
ids, unrecognized = collect_rows(lines)
|
||||
|
||||
problems: list[str] = []
|
||||
if not ids:
|
||||
problems.append("§19 没有扫描到任何端点编号,文档结构可能已变化,请人工确认")
|
||||
|
||||
counter = Counter(ids)
|
||||
for endpoint_id in sorted(code for code, count in counter.items() if count > 1):
|
||||
problems.append(f"端点编号 {endpoint_id} 重复 {counter[endpoint_id]} 次")
|
||||
|
||||
for cell in sorted(set(unrecognized)):
|
||||
problems.append(f"§19 首列无法识别为端点编号:{cell!r}")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def describe_coverage(path: Path = INTERFACE_DOC) -> str:
|
||||
"""返回覆盖范围报告 —— 让"扫到了什么"可见,而不是只报"没问题"。"""
|
||||
ids, _ = collect_rows(read_section(path))
|
||||
prefixes = Counter(re.match(r"[A-Z]+", code).group() for code in ids)
|
||||
detail = "、".join(f"{prefix}×{count}" for prefix, count in sorted(prefixes.items()))
|
||||
return f"§19 覆盖:{len(ids)} 个端点编号 / {len(prefixes)} 个号段({detail})"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
problems = collect_findings()
|
||||
print(describe_coverage())
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f" ✗ {problem}")
|
||||
print(f"docs/05 §19 端点编号校验失败:{len(problems)} 处问题")
|
||||
return 1
|
||||
print("docs/05 §19 端点编号无重复")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user