Files
QMDSearch/tests/test_admin_page.py
T
kplam 6c6f690788 chore: 完成全量功能迭代与部署准备
- 移除冗余依赖包
- 新增账号禁用校验与用户管理能力
- 新增文档下载与管理页面文件展示
- 新增API文档页面与用户管理前端页面
- 重构时区处理与docker-compose部署配置
- 完善测试用例与项目文档
2026-08-01 00:02:42 +08:00

516 lines
22 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. 删除/重置操作的通用 overlay 弹窗组件(替换原生 prompt/confirm
6. 登录门禁:登录卡片、localStorage key、/auth/me 验证、/auth/login 路径
7. 顶栏用户区:用户名、角色徽章、修改密码、退出登录
8. 修改密码:旧/新/确认表单、must_change_password 强制改密
9. 用户管理区块:列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗、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_no_prompt_confirm_calls(admin_html: str) -> None:
"""清理:弹窗组件已替换原生 prompt/confirm,全页面无 prompt( 与 confirm( 调用残留"""
assert "prompt(" not in admin_html
assert "confirm(" not 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_profile_section(admin_html: str) -> None:
"""个人中心区块:导航按钮、section、账号信息卡片、改密表单、退出按钮、
user 角色默认进入、GET /auth/me 与 POST /auth/password 路径"""
# section 与导航按钮(不限 admin,所有登录用户可见)
assert 'id="section-profile"' in admin_html
assert 'id="nav-profile"' in admin_html
assert 'data-target="section-profile"' in admin_html
assert "个人中心" in admin_html
# 账号信息卡片字段:用户名 / 角色 / 创建时间 / 须改密 / 启用状态
assert 'id="profile-cards"' in admin_html
for label in ("用户名", "角色", "创建时间", "须改密", "启用状态"):
assert label in admin_html
# 改密表单:旧密码 / 新密码 / 确认 / 强度提示 / 提交按钮
assert 'id="profile-password-form"' in admin_html
assert 'id="profile-old-password"' in admin_html
assert 'id="profile-new-password"' in admin_html
assert 'id="profile-confirm-password"' in admin_html
assert 'id="profile-strength-hint"' in admin_html
assert 'id="btn-profile-password-submit"' in admin_html
# 强度提示复用 Task 3 文案(<8 弱 / ≥8 中 / ≥12 强)
assert "密码强度:" in admin_html
for level in ("", "", ""):
assert level in admin_html
# 两次不一致本地拦截
assert "两次输入的新密码不一致" in admin_html
# 提交按钮 loading 态
assert "提交中…" in admin_html
# 退出登录按钮(复用现有 logout 逻辑)
assert 'id="btn-profile-logout"' in admin_html
assert "退出登录" in admin_html
assert "doLogout" in admin_html
# 数据来源:GET /auth/me 加载账号信息;POST /auth/password 改密
assert "/api/v1/auth/me" in admin_html
assert "/api/v1/auth/password" in admin_html
# 个人中心加载与渲染函数
assert "loadProfile" in admin_html
assert "renderProfile" in admin_html
# user 角色默认进个人中心(admin 仍默认进概览)
assert 'activateSection("section-profile")' in admin_html
assert 'activateSection("section-overview")' in admin_html
# activateSection 触发 loadProfile
assert 'targetId === "section-profile"' in admin_html
def test_admin_page_users_section(admin_html: str) -> None:
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗/角色门禁"""
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
# 操作列:重置密码 + 删除均走通用弹窗组件(无 prompt/confirm 调用)
assert "重置密码" in admin_html
assert "resetUserPassword" in admin_html
assert "deleteUser" 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_users_filter_and_inline_edit(admin_html: str) -> None:
"""用户管理:列表搜索筛选 + 行内角色编辑 + 启用开关 + 当前 admin 自身行防降级"""
# 列表搜索筛选:用户名搜索框 + 角色筛选下拉
assert 'id="user-search"' in admin_html
assert 'id="user-role-filter"' in admin_html
assert '<option value="all"' in admin_html
# 输入/变更事件触发本地过滤(不重新请求后端)
assert 'addEventListener("input", applyUsersFilter)' in admin_html
assert 'addEventListener("change", applyUsersFilter)' in admin_html
assert "applyUsersFilter" in admin_html
# 表头加「启用」列
assert "启用" in admin_html
# 行内角色编辑:select + 保存按钮(仅当值改变时启用)
assert "user-role-select" in admin_html
assert "user-role-save" in admin_html
assert "updateUserRole" in admin_html
# 行内启用/禁用开关:按钮文字 + 样式区分
assert "user-enabled-toggle" in admin_html
assert "toggleUserEnabled" in admin_html
# PATCH /auth/users/{username} 端点调用(role 与 enabled 两条分支)
assert 'method: "PATCH"' in admin_html
assert "encodeURIComponent(username)" in admin_html
# 当前登录 admin 自身行防自锁降级:role select disabled + 启用开关 disabled
assert "isSelf" in admin_html
assert "不能禁用当前登录账号" in admin_html
def test_admin_page_modal_component(admin_html: str) -> None:
"""通用 overlay 弹窗组件:modal-overlay/modal-card 结构 + openModal/closeModal API"""
# CSS 类
assert "modal-overlay" in admin_html
assert "modal-card" in admin_html
assert "modal-title" in admin_html
assert "modal-content" in admin_html
assert "modal-actions" in admin_html
# 挂载点容器
assert 'id="modal-root"' in admin_html
# 通用 JS API
assert "function openModal(" in admin_html
assert "function closeModal(" in admin_html
assert "function isModalSubmitting(" in admin_html
assert "function setModalSubmitting(" in admin_html
assert "function showModelError(" in admin_html
assert "function hideModelError(" in admin_html
# 提交中标记属性
assert "data-submitting" in admin_html
def test_admin_page_modal_cancel_and_loading(admin_html: str) -> None:
"""弹窗可取消(点遮罩/取消按钮/ESC)+ 提交中不可取消 + loading 态(提交中禁用)"""
# 点遮罩关闭:e.target === overlay
assert "e.target === overlay" in admin_html
# 取消按钮存在
assert "取消" in admin_html
# ESC 键关闭最顶层弹窗
assert "Escape" in admin_html
# 提交中不可关闭:遮罩点击与 ESC 均检查 data-submitting !== "true"
assert 'data-submitting") !== "true"' in admin_html
assert 'data-submitting") === "true"' in admin_html
# 提交 loading 文案 + 按钮禁用
assert "提交中…" in admin_html
assert 'submitBtn.disabled = true' in admin_html
# isModalSubmitting 守卫:取消按钮在提交中不响应
assert "isModalSubmitting(" in admin_html
def test_admin_page_reset_password_modal(admin_html: str) -> None:
"""重置密码弹窗:新密码/确认/强度提示/两次不一致本地拦截/loading 态"""
# 弹窗标题含用户名占位
assert "重置用户" in admin_html
assert "密码" in admin_html
# 新密码 + 确认密码输入框(动态创建,id 经 JS 属性赋值)
assert 'newInput.id = "modal-reset-new"' in admin_html
assert 'confirmInput.id = "modal-reset-confirm"' in admin_html
# 密码强度提示节点
assert 'hint.id = "modal-reset-strength"' in admin_html
assert "modal-strength-hint" in admin_html
# 强度三档纯文案:<8 弱 / ≥8 中 / ≥12 强
assert "密码强度:" in admin_html
for level in ("", "", ""):
assert level in admin_html
# 两次不一致本地拦截
assert "两次输入的新密码不一致" in admin_html
# 重置密码端点
assert "/api/v1/auth/users/" in admin_html
assert "/password" in admin_html
# 提交按钮 loading 态
assert "setModalSubmitting" in admin_html
def test_admin_page_delete_user_modal(admin_html: str) -> None:
"""删除确认弹窗:警告文案 + 输入用户名匹配才可提交 + loading 态"""
# 弹窗标题
assert "删除用户" in admin_html
# 警告文案
assert "此操作不可恢复,将清除该用户及其全部会话" in admin_html
# 「请输入用户名 {username} 以确认」+ 文本输入框(动态创建,id 经 JS 属性赋值)
assert "请输入用户名" in admin_html
assert "以确认" in admin_html
assert 'input.id = "modal-delete-input"' in admin_html
# 输入 !== username 时删除按钮禁用;匹配后启用
assert "submitBtn.disabled = true" in admin_html
assert "input.value !== username" in admin_html
# 删除端点
assert 'method: "DELETE"' in admin_html
# 提交按钮 loading 态
assert "setModalSubmitting" 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_doc_detail_file_link(admin_html: str) -> None:
"""文档详情区:有关联文件时展示原始文件行与下载链接(textContent 防 XSS"""
# 条件判断与字段访问
assert "data.file" in admin_html
assert "file.filename" in admin_html
assert "file.size_bytes" in admin_html
# 大小格式化辅助
assert "formatFileSize" in admin_html
# 下载链接用 el("a") 创建,设 href 与 download 属性,不 innerHTML
assert 'el("a"' in admin_html
assert "download" in admin_html
# 行标签文案
assert "原始文件" in admin_html
assert "下载" 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)}"