ci/woodpecker/push/main Pipeline failed
New router app/api/doc_center.py: GET /api/cma/docs (list, ?with_content) and GET /api/cma/docs/{slug}; runtime disk read, role-gated (ceo/finance/business/it), no existing API semantics changed.
Content dir /srv/hermes/shared/cma-content (docs/*.md + categories.json). Placed outside the repo on purpose: the deploy tree equals the committed tree (deploy.sh checkout -f + clean -fdx), so in-repo content would be reset on every deploy and content edits would fail the deploy-tree dirty check.
CMAKnowledge.vue: 20 hardcoded items migrated to md, page now fetches at runtime and renders md with the existing marked dep (no new deps); table borders restored via :deep() since scoped styles never reach v-html content.
Tests: backend/tests/test_doc_center_20260924.py (20 cases). Full suite 1448 passed (baseline 1428 + 20).
216 lines
11 KiB
Python
216 lines
11 KiB
Python
"""文档中心(md 单一来源)端点测试 —— 任务 yanxue-20260924-cma-docs-center-poc
|
||
|
||
覆盖:
|
||
1. 列表:元数据/分类视图/排序/默认不带正文、`with_content=true` 带正文
|
||
2. 单篇:200 / 未知 slug 404 / 非法 slug 400(含路径穿越)
|
||
3. 鉴权:无 token → 401(不得成为 B 类缺口);持 token → 200
|
||
4. 健壮性:缺 front-matter / 缺必填字段 → skipped 且不影响其它文档;目录缺失 → available=false 不 500
|
||
5. **核心判据**:改 md 后同一进程再次请求即返回新内容(运行期读盘、无缓存、无 build-time 打包)
|
||
6. 生产内容目录金丝雀(目录不存在时跳过)
|
||
"""
|
||
import json
|
||
import os
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||
|
||
DOCS_ENV = "CMA_DOCS_DIR"
|
||
PROD_CONTENT = "/srv/hermes/shared/cma-content"
|
||
|
||
|
||
def write_doc(root, slug, body="#### 标题\n\n正文段落。\n", title="标题", category="term",
|
||
summary="摘要", category_name="术语解释", icon="📖", order=10, extra=""):
|
||
path = root / "docs" / f"{slug}.md"
|
||
path.write_text(
|
||
"---\n"
|
||
f'title: "{title}"\n'
|
||
f"category: {category}\n"
|
||
f"category_name: {category_name}\n"
|
||
f'icon: "{icon}"\n'
|
||
f'summary: "{summary}"\n'
|
||
f"order: {order}\n"
|
||
f"{extra}"
|
||
"---\n" + body,
|
||
encoding="utf-8",
|
||
)
|
||
return path
|
||
|
||
|
||
@pytest.fixture
|
||
def content_dir(tmp_path, monkeypatch):
|
||
"""隔离的临时内容目录(不动生产目录)"""
|
||
root = tmp_path / "cma-content"
|
||
(root / "docs").mkdir(parents=True)
|
||
(root / "categories.json").write_text(json.dumps({"categories": [
|
||
{"key": "faq", "name": "常见问题", "icon": "❓", "order": 40},
|
||
{"key": "term", "name": "术语解释", "icon": "📖", "order": 10},
|
||
]}, ensure_ascii=False), encoding="utf-8")
|
||
monkeypatch.setenv(DOCS_ENV, str(root))
|
||
return root
|
||
|
||
|
||
@pytest.fixture
|
||
def token(client: TestClient, db) -> str:
|
||
create_test_user(db)
|
||
return get_token_for_user(client)
|
||
|
||
|
||
class TestListDocs:
|
||
def test_meta_only_and_category_order(self, client, content_dir, token):
|
||
write_doc(content_dir, "term-b", title="B术语", order=20)
|
||
write_doc(content_dir, "term-a", title="A术语", order=10)
|
||
write_doc(content_dir, "faq-x", title="常见问题X", category="faq",
|
||
category_name="常见问题", icon="❓", order=10)
|
||
r = client.get("/api/cma/docs", headers=auth_header(token))
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
assert body["count"] == 3 and body["available"] is True and body["format"] == "markdown"
|
||
assert body["source"].endswith("/docs")
|
||
# 分类顺序 = categories.json 的 order(term 10 在 faq 40 之前,与文件写入顺序无关)
|
||
assert [c["key"] for c in body["categories"]] == ["term", "faq"]
|
||
assert [c["count"] for c in body["categories"]] == [2, 1]
|
||
assert body["categories"][0]["name"] == "术语解释" and body["categories"][0]["icon"] == "📖"
|
||
# 文档顺序 = (分类 order, 篇内 order, slug)
|
||
assert [d["slug"] for d in body["data"]] == ["term-a", "term-b", "faq-x"]
|
||
assert "content" not in body["data"][0], "默认不得带正文"
|
||
assert set(body["data"][0]) >= {"slug", "title", "summary", "category",
|
||
"category_name", "icon", "order", "bytes", "updated_at"}
|
||
assert body["skipped"] == []
|
||
|
||
def test_with_content(self, client, content_dir, token):
|
||
write_doc(content_dir, "term-a", body="#### H\n\n**加粗** 正文\n")
|
||
r = client.get("/api/cma/docs?with_content=true", headers=auth_header(token))
|
||
assert r.status_code == 200
|
||
assert r.json()["data"][0]["content"] == "#### H\n\n**加粗** 正文\n"
|
||
|
||
def test_filter_by_category(self, client, content_dir, token):
|
||
write_doc(content_dir, "term-a")
|
||
write_doc(content_dir, "faq-x", category="faq", category_name="常见问题")
|
||
r = client.get("/api/cma/docs?category=faq", headers=auth_header(token))
|
||
body = r.json()
|
||
assert [d["slug"] for d in body["data"]] == ["faq-x"]
|
||
# 分类视图按全量统计,不受过滤影响
|
||
assert [c["count"] for c in body["categories"]] == [1, 1]
|
||
|
||
def test_unregistered_category_still_listed(self, client, content_dir, token):
|
||
write_doc(content_dir, "other-a", category="其他类", category_name="其他类")
|
||
r = client.get("/api/cma/docs", headers=auth_header(token))
|
||
body = r.json()
|
||
assert body["count"] == 1
|
||
assert [c["key"] for c in body["categories"]] == ["term", "faq", "其他类"]
|
||
|
||
|
||
class TestGetDoc:
|
||
def test_detail(self, client, content_dir, token):
|
||
write_doc(content_dir, "term-a", body="#### H\n\n正文x\n")
|
||
r = client.get("/api/cma/docs/term-a", headers=auth_header(token))
|
||
assert r.status_code == 200
|
||
d = r.json()
|
||
assert d["slug"] == "term-a" and d["title"] == "标题" and d["content"] == "#### H\n\n正文x\n"
|
||
|
||
def test_unknown_slug_404(self, client, content_dir, token):
|
||
r = client.get("/api/cma/docs/no-such-doc", headers=auth_header(token))
|
||
assert r.status_code == 404
|
||
|
||
@pytest.mark.parametrize("slug", ["Upper", "with space", "a@b", "-lead", "x" * 65])
|
||
def test_invalid_slug_400(self, client, content_dir, token, slug):
|
||
r = client.get(f"/api/cma/docs/{slug}", headers=auth_header(token))
|
||
assert r.status_code in (400, 404), f"非法 slug 不得进入文件系统查找: {slug} → {r.status_code}"
|
||
if r.status_code == 400:
|
||
assert "无效的文档标识" in r.text
|
||
|
||
def test_path_traversal_blocked(self, content_dir):
|
||
"""越权取文件:`../`、绝对路径、URL 编码一律被 slug 白名单拦下(无 HTTP 层绕行)"""
|
||
from app.api import doc_center
|
||
from fastapi import HTTPException
|
||
|
||
for bad in ["../categories", "..%2F..%2Fetc%2Fpasswd", "/etc/passwd", "%2e%2e%2fsecret",
|
||
"....//x", "a/b"]:
|
||
with pytest.raises(HTTPException) as ei:
|
||
doc_center.get_doc(bad)
|
||
assert ei.value.status_code == 400
|
||
# 正对照:合法 slug 走查文件路径(不存在 → 404,不是 400)
|
||
with pytest.raises(HTTPException) as ei:
|
||
doc_center.get_doc("term-a")
|
||
assert ei.value.status_code == 404
|
||
|
||
|
||
class TestAuth:
|
||
def test_no_token_401(self, client, content_dir):
|
||
assert client.get("/api/cma/docs").status_code == 401
|
||
assert client.get("/api/cma/docs/term-a").status_code == 401
|
||
|
||
def test_with_token_200(self, client, content_dir, token):
|
||
write_doc(content_dir, "term-a")
|
||
assert client.get("/api/cma/docs", headers=auth_header(token)).status_code == 200
|
||
|
||
|
||
class TestRobustness:
|
||
def test_bad_md_skipped_not_fatal(self, client, content_dir, token):
|
||
(content_dir / "docs" / "no-front.md").write_text("没有 front-matter\n", encoding="utf-8")
|
||
write_doc(content_dir, "missing-title", title="", summary="s")
|
||
(content_dir / "docs" / "BadSlug.md").write_text(
|
||
'---\ntitle: "T"\ncategory: term\nsummary: "S"\n---\nX\n', encoding="utf-8")
|
||
write_doc(content_dir, "term-good")
|
||
r = client.get("/api/cma/docs", headers=auth_header(token))
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert [d["slug"] for d in body["data"]] == ["term-good"]
|
||
files = {s["file"]: s["reason"] for s in body["skipped"]}
|
||
assert "no-front.md" in files and "front-matter" in files["no-front.md"]
|
||
assert "missing-title.md" in files and "title" in files["missing-title.md"]
|
||
assert "BadSlug.md" in files and "slug" in files["BadSlug.md"]
|
||
|
||
def test_missing_dir_not_500(self, client, tmp_path, monkeypatch, token):
|
||
monkeypatch.setenv(DOCS_ENV, str(tmp_path / "not-exist"))
|
||
r = client.get("/api/cma/docs", headers=auth_header(token))
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["count"] == 0 and body["available"] is False and body["data"] == []
|
||
|
||
def test_front_matter_multi_colon_and_quotes(self, content_dir):
|
||
from app.api import doc_center
|
||
|
||
p = write_doc(content_dir, "term-q", title='带:冒号"引号"的标题', summary="摘要: 含冒号")
|
||
doc, err = doc_center._read_doc(p)
|
||
assert err is None
|
||
assert doc["title"] == '带:冒号"引号"的标题' and doc["summary"] == "摘要: 含冒号"
|
||
|
||
|
||
class TestRuntimeSource:
|
||
"""核心判据:内容运行期读盘 —— 改 md 即生效,无需改前端 / rebuild / 重启"""
|
||
|
||
def test_edit_md_visible_on_next_request(self, client, content_dir, token):
|
||
p = write_doc(content_dir, "term-a", body="#### 旧内容\n")
|
||
r1 = client.get("/api/cma/docs/term-a", headers=auth_header(token)).json()
|
||
assert r1["content"] == "#### 旧内容\n"
|
||
# 只改 md 文件(不改码、不重启、不构建)
|
||
p.write_text(p.read_text(encoding="utf-8").replace("旧内容", "新内容"), encoding="utf-8")
|
||
r2 = client.get("/api/cma/docs/term-a", headers=auth_header(token)).json()
|
||
assert r2["content"] == "#### 新内容\n"
|
||
|
||
def test_add_md_visible_without_restart(self, client, content_dir, token):
|
||
assert client.get("/api/cma/docs", headers=auth_header(token)).json()["count"] == 0
|
||
write_doc(content_dir, "term-new", title="新增篇目")
|
||
body = client.get("/api/cma/docs?with_content=true", headers=auth_header(token)).json()
|
||
assert body["count"] == 1 and body["data"][0]["title"] == "新增篇目"
|
||
|
||
|
||
@pytest.mark.skipif(not os.path.isdir(PROD_CONTENT), reason="生产内容目录不存在(非本机环境)")
|
||
class TestProdContentCanary:
|
||
"""生产内容目录金丝雀:真实目录必须可读、无 skipped、分类齐备(防内容被写坏)"""
|
||
|
||
def test_prod_content_readable(self, client, token, monkeypatch):
|
||
monkeypatch.setenv(DOCS_ENV, PROD_CONTENT)
|
||
r = client.get("/api/cma/docs", headers=auth_header(token))
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["available"] is True and body["skipped"] == [], body["skipped"]
|
||
assert body["count"] >= 20, f"迁移应有 20 篇,实际 {body['count']}"
|
||
assert [c["key"] for c in body["categories"]] == ["term", "formula", "practice", "faq"]
|
||
for slug in ("term-kpi", "formula-achievement-rate", "practice-threshold-tips", "faq-data-sync"):
|
||
d = client.get(f"/api/cma/docs/{slug}", headers=auth_header(token)).json()
|
||
assert d["title"] and d["content"].strip()
|