Files
QMDSearch/tests/test_admin_page.py
T
kplam 92b062c048 feat: 新增用户管理(用户增删改查、密码重置、角色权限、会话认证)与 API 指南
- 新增 app/api/deps.py、app/core/users.py、app/core/sessions.py:会话鉴权依赖、
  用户存储(PBKDF2-HMAC-SHA256 + 随机 salt,Redis/内存降级)、会话签发与校验(TTL 12h)
- auth.py 新增用户管理端点(列表/创建/重置密码/删除)与 admin/user 角色权限边界,
  user 访问用户管理返回 1006,禁删自己与最后一个 admin
- admin.html 新增用户管理面板(仅 admin 挂载)与 API 指南在线测试台
- Dockerfile 将 uv 放入 PATH;docker-compose 调整 qdrant 依赖为 service_started
  并移除依赖 curl 的 healthcheck(官方镜像不含 curl)
- 新增用户管理测试(users/sessions/auth_api/auth_integration),全量 461 项测试通过

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-07-31 21:29:02 +08:00

346 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.
"""管理页面挂载测试(GET /admin)
覆盖:
1. 路由存在性:200 + content-type 为 text/html
2. 五区块可识别标记(文案与 section id)
3. 零外部依赖:无 http(s) 外链资源、无 CDN 引用
4. fetch 调用路径与后端 API 契约一致(含 /api/v1/auth/*
5. 删除操作的 confirm() 二次确认逻辑
6. 登录门禁:登录卡片、localStorage key、/auth/me 验证、/auth/login 路径
7. 顶栏用户区:用户名、角色徽章、修改密码、退出登录
8. 修改密码:旧/新/确认表单、must_change_password 强制改密
9. 用户管理区块:列表/创建表单/角色下拉/重置/删除 confirm、admin 角色门禁
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
"""
import re
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services.qdrant import QdrantService
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
"""TestClientlifespan 中的 Qdrant 集合初始化替换为空操作"""
async def _noop_ensure_collections(self: QdrantService) -> None:
return None
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
with TestClient(app) as test_client:
yield test_client
@pytest.fixture
def admin_html(client: TestClient) -> str:
"""请求 /admin 并返回 HTML 文本(前置断言 200)"""
resp = client.get("/admin")
assert resp.status_code == 200
return resp.text
def test_admin_page_ok(client: TestClient) -> None:
"""GET /admin → 200content-type 含 text/html"""
resp = client.get("/admin")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_admin_page_has_five_sections(admin_html: str) -> None:
"""HTML 含五个区块的文案与对应 section id"""
for section_id in (
"section-overview",
"section-docs",
"section-ingest",
"section-search",
"section-categories",
):
assert section_id in admin_html
for label in ("概览", "文档管理", "文档入库", "检索测试台", "类目列表"):
assert label in admin_html
def test_admin_page_no_external_resources(admin_html: str) -> None:
"""零外部依赖:无 src/href http(s) 外链,无 CDN 引用"""
assert re.search(r'src=["\']https?://', admin_html) is None
assert re.search(r'href=["\']https?://', admin_html) is None
assert re.search(r"//cdn", admin_html) is None
def test_admin_page_fetch_paths(admin_html: str) -> None:
"""fetch 调用路径与后端 API 契约一致(含认证相关端点)"""
for path in (
"/api/v1/search",
"/api/v1/documents",
"/api/v1/knowledge/stats",
"/api/v1/knowledge/categories",
"/api/v1/auth/login",
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/password",
"/api/v1/auth/users",
):
assert path in admin_html
def _collect_route_paths(routes: list) -> set[str]:
"""递归收集路由路径(FastAPI 0.140+ include_router 包装为 _IncludedRouter"""
paths: set[str] = set()
for route in routes:
path = getattr(route, "path", None)
if path:
paths.add(path)
sub_router = getattr(route, "original_router", None)
if sub_router is not None:
paths |= _collect_route_paths(sub_router.routes)
return paths
def test_admin_page_fetch_paths_in_real_routes(admin_html: str) -> None:
"""页面 api() 调用路径均在真实后端路由集合内(含 tasks 与 /api/v1/auth/* 路径)"""
route_paths = _collect_route_paths(app.routes)
assert "/api/v1/documents/tasks/{task_id}" in route_paths
for auth_path in (
"/api/v1/auth/login",
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/password",
"/api/v1/auth/users",
):
assert auth_path in route_paths
prefixes = set(re.findall(r'api\("([^"?]+)', admin_html))
assert prefixes, "页面应包含 api() 调用"
for prefix in prefixes:
assert any(path == prefix or path.startswith(prefix) for path in route_paths), (
f"页面 fetch 路径 {prefix} 不在后端路由集合内"
)
def test_admin_page_ingest_polling(admin_html: str) -> None:
"""入库区块:异步任务轮询逻辑标记"""
# 轮询任务状态端点
assert "/api/v1/documents/tasks/" in admin_html
# 状态中文映射
for text in ("排队中", "总结中", "分类中", "向量化中", "写入中", "完成", "失败"):
assert text in admin_html
# 提交确认与超时提示
assert "任务已提交" in admin_html
assert "任务仍在进行,可稍后凭 task_id 查询" in admin_html
# 5 分钟超时:150 次 × 2s
assert "150" in admin_html
# 防重复提交:轮询中禁用提交按钮
assert "disabled" in admin_html
def test_admin_page_has_confirm(admin_html: str) -> None:
"""删除操作包含 confirm() 二次确认逻辑"""
assert "confirm(" in admin_html
def test_admin_page_login_card(admin_html: str) -> None:
"""登录门禁:登录卡片、localStorage key、/auth/me 验证与 /auth/login 路径"""
assert 'id="login-overlay"' in admin_html
assert 'id="login-form"' in admin_html
assert 'id="login-username"' in admin_html
assert 'id="login-password"' in admin_html
assert "登录" in admin_html
# token 与用户信息持久化 key
assert "qmd_token" in admin_html
assert "qmd_user" in admin_html
assert "localStorage" in admin_html
# 页面加载时经 /auth/me 验证登录态
assert "/api/v1/auth/me" in admin_html
# 登录请求路径
assert "/api/v1/auth/login" in admin_html
def test_admin_page_user_area(admin_html: str) -> None:
"""顶栏用户区:用户名、角色徽章、修改密码与退出登录按钮"""
assert 'id="user-area"' in admin_html
assert 'id="user-name"' in admin_html
assert 'id="user-role"' in admin_html
assert "role-badge" in admin_html
assert 'id="btn-change-password"' in admin_html
assert "修改密码" in admin_html
assert 'id="btn-logout"' in admin_html
assert "退出登录" in admin_html
# 退出登录调用后端 logout 端点并清理 localStorage
assert "/api/v1/auth/logout" in admin_html
assert "clearAuth(" in admin_html
def test_admin_page_password_form(admin_html: str) -> None:
"""修改密码:旧/新/确认表单、改密端点与 must_change_password 强制改密层"""
assert 'id="password-overlay"' in admin_html
assert 'id="password-form"' in admin_html
assert 'id="password-old"' in admin_html
assert 'id="password-new"' in admin_html
assert 'id="password-confirm"' in admin_html
assert "/api/v1/auth/password" in admin_html
# must_change_password 登录后强制改密:提示语 + 取消按钮在强制模式隐藏
assert "must_change_password" in admin_html
assert "首次登录须" in admin_html
assert 'id="password-forced-tip"' in admin_html
assert 'id="btn-password-cancel"' in admin_html
assert "passwordForced" in admin_html
def test_admin_page_users_section(admin_html: str) -> None:
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码/删除 confirm/角色门禁"""
assert 'id="section-users"' in admin_html
assert 'id="users-tbody"' in admin_html
# 表头列:用户名/角色/须改密/创建时间/操作
for col in ("用户名", "角色", "须改密", "创建时间", "操作"):
assert col in admin_html
# 创建用户表单与角色下拉
assert 'id="user-create-form"' in admin_html
assert 'id="user-new-name"' in admin_html
assert 'id="user-new-password"' in admin_html
assert 'id="user-new-role"' in admin_html
assert '<option value="admin"' in admin_html
assert '<option value="user"' in admin_html
assert "创建用户" in admin_html
# 操作列:重置密码(弹输入)与删除(confirm 二次确认)
assert "重置密码" in admin_html
assert "prompt(" in admin_html
assert "确定删除用户" in admin_html
# 用户管理端点
assert "/api/v1/auth/users" in admin_html
# 角色门禁:仅 admin 挂载该区块进 DOM,非 admin 完全不渲染
assert 'role === "admin"' in admin_html
assert "mountUsersSection" in admin_html
assert "unmountUsersSection" in admin_html
def test_admin_page_auth_interceptor(admin_html: str) -> None:
"""请求拦截:统一注入 Authorization Bearer1005 回登录;1006 错误条提示"""
assert 'options.headers["Authorization"] = "Bearer " + token' in admin_html
# 1005 未认证/凭证无效 → 清 localStorage 并回登录卡片
assert "body.code === 1005" in admin_html
assert "clearAuth();" in admin_html
assert "showLogin();" in admin_html
# 1006 权限不足 → 错误码透传给错误条
assert "1006" in admin_html
def test_admin_page_api_guide_section(admin_html: str) -> None:
"""API 指南区块:导航按钮、section、静态清单与鉴权标注(user 角色也可见)"""
assert 'data-target="section-api-guide"' in admin_html
assert 'id="section-api-guide"' in admin_html
assert 'id="api-guide-list"' in admin_html
assert "API 指南" in admin_html
# 静态维护的端点清单
assert "API_GUIDE" in admin_html
# auth 中文标注:免登录 / 需登录 / 仅 admin
for label in ("免登录", "需登录", "仅 admin"):
assert label in admin_html
# 方法颜色徽章
for cls in ("method-get", "method-post", "method-delete"):
assert cls in admin_html
def test_admin_page_api_guide_try_panel(admin_html: str) -> None:
"""试一下面板:发送按钮/状态码/耗时/格式化 JSON 展示/curl 复制/upload 文件选择"""
assert "试一下" in admin_html
assert "try-panel" in admin_html
assert "try-toggle" in admin_html
# 发送按钮与结果区(状态码/耗时/pre 格式化展示)
assert "try-send" in admin_html
assert "发送" in admin_html
assert "状态码" in admin_html
assert "耗时" in admin_html
assert "try-result-pre" in admin_html
assert "JSON.stringify(JSON.parse(text), null, 2)" in admin_html
# 需鉴权端点自动带当前 token
assert 'ep.auth !== "none"' in admin_html
# body 非 JSON 本地报错不发请求
assert "未发送请求" in admin_html
# curl 示例与复制(clipboard + 降级 textarea 选中)
assert "curl 示例" in admin_html
assert "curl-copy" in admin_html
assert "复制" in admin_html
assert "navigator.clipboard" in admin_html
assert "execCommand" in admin_html
# base URL 用 location.origin 动态拼接
assert "location.origin" in admin_html
# upload 端点:file input + FormData(不带 Content-Type 头)
assert "try-file" in admin_html
assert 'fileInput.type = "file"' in admin_html
assert "FormData" in admin_html
def test_admin_page_api_guide_no_custom_url(admin_html: str) -> None:
"""安全约束:只能从清单选择端点,页面无自定义任意 URL 输入"""
assert "api-url" not in admin_html
assert "自定义 URL" not in admin_html.replace("不支持自定义 URL", "")
# 试一下面板只能从清单展开:端点 URL 由清单定义拼接,无自由输入
assert "不提供任意 URL 输入框" in admin_html
_API_GUIDE_ENTRY_RE = re.compile(r'method:\s*"(\w+)",\s*path:\s*"([^"]+)"')
def _extract_api_guide_entries(admin_html: str) -> list[tuple[str, str]]:
"""从页面 JS 提取 API_GUIDE 静态清单中的 (method, path) 列表"""
match = re.search(r"API_GUIDE\s*=\s*\[(.*?)\n\];", admin_html, re.S)
assert match, "页面应包含 API_GUIDE 静态清单"
return _API_GUIDE_ENTRY_RE.findall(match.group(1))
def _collect_route_methods(routes: list) -> set[tuple[str, str]]:
"""递归收集路由 (method, path) 对(FastAPI 0.140+ include_router 包装为 _IncludedRouter"""
pairs: set[tuple[str, str]] = set()
for route in routes:
path = getattr(route, "path", None)
methods = getattr(route, "methods", None)
if path and methods:
for method in methods:
pairs.add((method, path))
sub_router = getattr(route, "original_router", None)
if sub_router is not None:
pairs |= _collect_route_methods(sub_router.routes)
return pairs
def test_admin_page_api_guide_paths_in_real_routes(admin_html: str) -> None:
"""API_GUIDE 每个 method+path 均在真实后端路由集合内,且全部真实端点被覆盖(防漂移)"""
entries = _extract_api_guide_entries(admin_html)
assert entries, "API_GUIDE 应至少包含一个端点"
route_methods = _collect_route_methods(app.routes)
for method, path in entries:
assert (method, path) in route_methods, f"API_GUIDE 端点 {method} {path} 不在后端路由集合内"
# 全部真实端点覆盖:health/search/documents*/knowledge*/auth*
expected = {
("GET", "/api/v1/health"),
("POST", "/api/v1/search"),
("POST", "/api/v1/documents"),
("POST", "/api/v1/documents/upload"),
("GET", "/api/v1/documents/tasks/{task_id}"),
("GET", "/api/v1/documents"),
("GET", "/api/v1/documents/{doc_id}"),
("DELETE", "/api/v1/documents/{doc_id}"),
("GET", "/api/v1/knowledge/categories"),
("GET", "/api/v1/knowledge/stats"),
("POST", "/api/v1/auth/login"),
("POST", "/api/v1/auth/logout"),
("POST", "/api/v1/auth/password"),
("GET", "/api/v1/auth/me"),
("GET", "/api/v1/auth/users"),
("POST", "/api/v1/auth/users"),
("POST", "/api/v1/auth/users/{username}/password"),
("DELETE", "/api/v1/auth/users/{username}"),
}
assert expected <= route_methods, f"后端缺少预期端点: {sorted(expected - route_methods)}"
missing = expected - set(entries)
assert not missing, f"API_GUIDE 缺少端点: {sorted(missing)}"