feat(docs-center): CMAKnowledge reads md single source via new /api/cma/docs
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).
This commit is contained in:
Hermes CI Fix
2026-09-24 01:07:29 +08:00
parent 72d4d97c83
commit ede0524c22
4 changed files with 512 additions and 286 deletions
+215
View File
@@ -0,0 +1,215 @@
"""文档中心(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()