92b062c048
- 新增 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>
346 lines
14 KiB
Python
346 lines
14 KiB
Python
"""管理页面挂载测试(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]:
|
||
"""TestClient,lifespan 中的 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 → 200,content-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 Bearer;1005 回登录;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)}"
|