chore: 完成全量功能迭代与部署准备

- 移除冗余依赖包
- 新增账号禁用校验与用户管理能力
- 新增文档下载与管理页面文件展示
- 新增API文档页面与用户管理前端页面
- 重构时区处理与docker-compose部署配置
- 完善测试用例与项目文档
This commit is contained in:
2026-08-01 00:02:42 +08:00
parent f92eff6f65
commit 6c6f690788
19 changed files with 2000 additions and 485 deletions
+163 -9
View File
@@ -5,11 +5,11 @@
2. 五区块可识别标记(文案与 section id)
3. 零外部依赖:无 http(s) 外链资源、无 CDN 引用
4. fetch 调用路径与后端 API 契约一致(含 /api/v1/auth/*
5. 删除操作的 confirm() 二次确认逻辑
5. 删除/重置操作的通用 overlay 弹窗组件(替换原生 prompt/confirm
6. 登录门禁:登录卡片、localStorage key、/auth/me 验证、/auth/login 路径
7. 顶栏用户区:用户名、角色徽章、修改密码、退出登录
8. 修改密码:旧/新/确认表单、must_change_password 强制改密
9. 用户管理区块:列表/创建表单/角色下拉/重置/删除 confirm、admin 角色门禁
9. 用户管理区块:列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗、admin 角色门禁
10. 请求拦截:Authorization Bearer 注入、1005 回登录、1006 错误条
11. API 指南区块:导航/section、API_GUIDE 清单与真实路由一致性、试一下面板、
curl 复制、auth 标注、upload 文件选择、禁止自定义 URL
@@ -140,9 +140,10 @@ def test_admin_page_ingest_polling(admin_html: str) -> None:
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_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:
@@ -193,8 +194,52 @@ def test_admin_page_password_form(admin_html: str) -> None:
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:
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码/删除 confirm/角色门禁"""
"""用户管理区块:用户列表/创建表单/角色下拉/重置密码弹窗/删除确认弹窗/角色门禁"""
assert 'id="section-users"' in admin_html
assert 'id="users-tbody"' in admin_html
# 表头列:用户名/角色/须改密/创建时间/操作
@@ -208,10 +253,10 @@ def test_admin_page_users_section(admin_html: str) -> None:
assert '<option value="admin"' in admin_html
assert '<option value="user"' in admin_html
assert "创建用户" in admin_html
# 操作列:重置密码(弹输入)与删除(confirm 二次确认
# 操作列:重置密码 + 删除均走通用弹窗组件(无 prompt/confirm 调用
assert "重置密码" in admin_html
assert "prompt(" in admin_html
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 完全不渲染
@@ -220,6 +265,115 @@ def test_admin_page_users_section(admin_html: str) -> None:
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
+168 -2
View File
@@ -245,8 +245,9 @@ class TestUsersCrud:
users = body["data"]
assert len(users) == 1
admin = users[0]
assert set(admin.keys()) == {"username", "role", "must_change_password", "created_at"}
assert set(admin.keys()) == {"username", "role", "must_change_password", "enabled", "created_at"}
assert admin["username"] == "admin"
assert admin["enabled"] is True
assert "password_hash" not in admin
assert "salt" not in admin
@@ -271,10 +272,11 @@ class TestUsersCrud:
assert body["code"] == 0
data = body["data"]
assert set(data.keys()) == {"username", "role", "must_change_password", "created_at"}
assert set(data.keys()) == {"username", "role", "must_change_password", "enabled", "created_at"}
assert data["username"] == "carol"
assert data["role"] == "user"
assert data["must_change_password"] is False
assert data["enabled"] is True
# 新用户可登录
assert _login(client, "carol", "carol-pass-123")["code"] == 0
@@ -386,6 +388,170 @@ class TestUsersCrud:
assert body["code"] == 1001
class TestUpdateUser:
"""PATCH /api/v1/auth/users/{username}:更新角色/启用状态"""
def test_update_role_success(self, client: TestClient, admin_headers: dict[str, str]) -> None:
client.post(
"/api/v1/auth/users",
json={"username": "carol", "password": "carol-pass-123"},
headers=admin_headers,
)
body = client.patch(
"/api/v1/auth/users/carol", json={"role": "admin"}, headers=admin_headers
).json()
assert body["code"] == 0
assert body["data"]["role"] == "admin"
assert body["data"]["enabled"] is True
def test_update_enabled_success(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
user_store, _ = auth_stores
_create_user(user_store, "dave", "dave-pass-123", role="user")
body = client.patch(
"/api/v1/auth/users/dave", json={"enabled": False}, headers=admin_headers
).json()
assert body["code"] == 0
assert body["data"]["enabled"] is False
assert body["data"]["role"] == "user"
def test_update_both_role_and_enabled(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
user_store, _ = auth_stores
_create_user(user_store, "erin", "erin-pass-123", role="user")
body = client.patch(
"/api/v1/auth/users/erin",
json={"role": "admin", "enabled": False},
headers=admin_headers,
).json()
assert body["code"] == 0
assert body["data"]["role"] == "admin"
assert body["data"]["enabled"] is False
def test_at_least_one_field_required(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.patch(
"/api/v1/auth/users/admin", json={}, headers=admin_headers
).json()
assert body["code"] == 1001
assert body["data"] is None
def test_user_not_found(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.patch(
"/api/v1/auth/users/ghost", json={"role": "user"}, headers=admin_headers
).json()
assert body["code"] == 1004
assert body["data"] is None
def test_invalid_role(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.patch(
"/api/v1/auth/users/admin", json={"role": "superuser"}, headers=admin_headers
).json()
assert body["code"] == 1001
def test_last_admin_demote_forbidden(self, client: TestClient, admin_headers: dict[str, str]) -> None:
# 唯一 admin 降级为 user → 1001
body = client.patch(
"/api/v1/auth/users/admin", json={"role": "user"}, headers=admin_headers
).json()
assert body["code"] == 1001
# 未生效
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
def test_last_admin_disable_forbidden(self, client: TestClient, admin_headers: dict[str, str]) -> None:
# 唯一 admin 禁用 → 1001
body = client.patch(
"/api/v1/auth/users/admin", json={"enabled": False}, headers=admin_headers
).json()
assert body["code"] == 1001
def test_non_admin_forbidden(self, client: TestClient, auth_stores) -> None:
user_store, session_store = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
headers = _headers(session_store, "bob", "user")
body = client.patch(
"/api/v1/auth/users/bob", json={"role": "admin"}, headers=headers
).json()
assert body["code"] == 1006
def test_demote_when_multiple_admins_ok(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
# 存在第二个 admin 时,可降级其中一个
user_store, _ = auth_stores
_create_user(user_store, "admin2", "admin2-pass-123", role="admin")
body = client.patch(
"/api/v1/auth/users/admin2", json={"role": "user"}, headers=admin_headers
).json()
assert body["code"] == 0
assert body["data"]["role"] == "user"
class TestDisabledUser:
"""禁用用户:登录拦截 + 旧 token 失效 + 业务端点拦截"""
def test_disabled_user_login_blocked(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
user_store, _ = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
# 禁用 bob
client.patch(
"/api/v1/auth/users/bob", json={"enabled": False}, headers=admin_headers
)
# 登录 → 1005 账号已禁用
body = _login(client, "bob", "bob-pass-123")
assert body["code"] == 1005
assert body["data"] is None
def test_disabled_user_old_token_invalidated(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
user_store, _ = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
# bob 登录拿到 token
login_body = _login(client, "bob", "bob-pass-123")
assert login_body["code"] == 0
bob_headers = {"Authorization": f"Bearer {login_body['data']['token']}"}
# 禁用 bob → session 清除
client.patch(
"/api/v1/auth/users/bob", json={"enabled": False}, headers=admin_headers
)
# 旧 token 调业务端点 → 1005session 已清)
body = client.get("/api/v1/auth/me", headers=bob_headers).json()
assert body["code"] == 1005
def test_disabled_user_blocked_even_with_valid_session(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
"""禁用用户即使持有效 session(直接签发绕过登录),业务端点仍拦截 1005"""
user_store, session_store = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
# 禁用 bob → session 清除
client.patch(
"/api/v1/auth/users/bob", json={"enabled": False}, headers=admin_headers
)
# 直接为 bob 签发新 session 绕过登录与清理,验证 get_current_user 的 enabled 拦截
bob_headers = _headers(session_store, "bob", "user")
body = client.get("/api/v1/auth/me", headers=bob_headers).json()
assert body["code"] == 1005
class TestDocumentAuth:
"""文档端点鉴权:变更类需登录,GET 系列免登录"""
+247
View File
@@ -0,0 +1,247 @@
"""Spec Task 5:用户管理增强(PATCH 端点 + enabled 字段)全链路集成验证
内存 UserStore/SessionStore 经 conftest.auth_stores 夹具注入 app.api.deps 单例,
通过 TestClient 走真实 HTTP 链路(不跑 lifespan,无需真实 Redis/Qdrant),覆盖:
- 改角色生效:admin 创建 user → PATCH 改 admin → GET /auth/me 与重新登录均反映新角色
- 禁用用户:旧 token 立即失效(session 已清,1005);重新登录 1005("账号已禁用"
- 重新启用:PATCH enabled=True → login 恢复成功
- 最后 admin 保护:唯一 admin 降级/禁用自己 → 1001(且自身状态未变)
- PATCH 校验:空 body / 非法 role → 1001;不存在用户 → 1004;非 admin 调用 → 1006
"""
from typing import Any
from fastapi.testclient import TestClient
from app.main import app
def _login(client: TestClient, username: str, password: str) -> dict[str, Any]:
"""调登录接口并返回响应体"""
return client.post("/api/v1/auth/login", json={"username": username, "password": password}).json()
def _bearer(token: str) -> dict[str, str]:
"""构造 Authorization Bearer 请求头"""
return {"Authorization": f"Bearer {token}"}
def _admin_login(client: TestClient) -> dict[str, str]:
"""以 auth_stores 预置的 adminadmin/admin-pass-123)登录,返回 Bearer 请求头"""
resp = _login(client, "admin", "admin-pass-123")
assert resp["code"] == 0, f"admin 登录失败: {resp}"
return _bearer(resp["data"]["token"])
def _create_user(
client: TestClient,
admin_headers: dict[str, str],
username: str,
password: str,
role: str = "user",
) -> None:
"""admin 创建用户并断言成功"""
resp = client.post(
"/api/v1/auth/users",
json={"username": username, "password": password, "role": role},
headers=admin_headers,
).json()
assert resp["code"] == 0, f"创建用户 {username} 失败: {resp}"
class TestRoleChange:
"""改角色生效全链路"""
def test_role_change_takes_effect(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
# admin 创建 user 角色账号 alice
_create_user(client, admin_headers, "alice", "alice-pass-123", role="user")
# alice 登录,初始角色 user
alice_login = _login(client, "alice", "alice-pass-123")
assert alice_login["code"] == 0
assert alice_login["data"]["role"] == "user"
alice_headers = _bearer(alice_login["data"]["token"])
# 改角色前 GET /auth/me 反映 user 角色
me_before = client.get("/api/v1/auth/me", headers=alice_headers).json()
assert me_before["code"] == 0
assert me_before["data"]["role"] == "user"
# admin PATCH 改 alice 角色为 admin
patched = client.patch(
"/api/v1/auth/users/alice",
json={"role": "admin"},
headers=admin_headers,
).json()
assert patched["code"] == 0
assert patched["data"]["role"] == "admin"
# 同一 token 立即反映新角色(角色变更不清 session,用户记录实时读取)
me_after = client.get("/api/v1/auth/me", headers=alice_headers).json()
assert me_after["code"] == 0
assert me_after["data"]["role"] == "admin"
# 重新登录也反映新角色
relogin = _login(client, "alice", "alice-pass-123")
assert relogin["code"] == 0
assert relogin["data"]["role"] == "admin"
class TestDisableUser:
"""禁用用户:旧 session 清除 + 登录拒绝"""
def test_disable_user_clears_session_and_blocks_login(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "bob", "bob-pass-123")
# bob 登录拿 token
bob_login = _login(client, "bob", "bob-pass-123")
assert bob_login["code"] == 0
bob_headers = _bearer(bob_login["data"]["token"])
# admin 禁用 bob(清空其全部 session
disabled = client.patch(
"/api/v1/auth/users/bob",
json={"enabled": False},
headers=admin_headers,
).json()
assert disabled["code"] == 0
assert disabled["data"]["enabled"] is False
# bob 旧 token 调鉴权端点 → 1005(session 已清,凭证无效)
me = client.get("/api/v1/auth/me", headers=bob_headers).json()
assert me["code"] == 1005
# bob 重新登录 → 1005(账号已禁用)
relogin = _login(client, "bob", "bob-pass-123")
assert relogin["code"] == 1005
assert "禁用" in relogin["message"]
class TestReenableUser:
"""重新启用:login 恢复成功"""
def test_reenable_user_allows_login(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "carol", "carol-pass-123")
# 先禁用 carol,确认登录被拒
disabled = client.patch(
"/api/v1/auth/users/carol",
json={"enabled": False},
headers=admin_headers,
).json()
assert disabled["code"] == 0
assert _login(client, "carol", "carol-pass-123")["code"] == 1005
# 重新启用
enabled = client.patch(
"/api/v1/auth/users/carol",
json={"enabled": True},
headers=admin_headers,
).json()
assert enabled["code"] == 0
assert enabled["data"]["enabled"] is True
# login 恢复成功
relogin = _login(client, "carol", "carol-pass-123")
assert relogin["code"] == 0
assert relogin["data"]["username"] == "carol"
class TestLastAdminProtection:
"""最后 admin 保护:唯一 admin 不可降级/禁用"""
def test_cannot_demote_last_admin(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
# 唯一 adminadmin)尝试降级自己 role=user → 1001
resp = client.patch(
"/api/v1/auth/users/admin",
json={"role": "user"},
headers=admin_headers,
).json()
assert resp["code"] == 1001
# admin 未被降级,仍可访问 admin 专属端点
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
def test_cannot_disable_last_admin(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
# 唯一 admin 尝试禁用自己 enabled=False → 1001
resp = client.patch(
"/api/v1/auth/users/admin",
json={"enabled": False},
headers=admin_headers,
).json()
assert resp["code"] == 1001
# admin 未被禁用,旧 token 仍可用
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
class TestPatchValidation:
"""PATCH 端点校验:空 body / 非法 role / 不存在用户 / 非 admin"""
def test_empty_body_rejected(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "dave", "dave-pass-123")
# 空 bodyrole 与 enabled 均缺)→ 模型校验 1001
resp = client.patch(
"/api/v1/auth/users/dave",
json={},
headers=admin_headers,
).json()
assert resp["code"] == 1001
def test_invalid_role_rejected(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "dave", "dave-pass-123")
# role 非法(非 admin/user)→ 模型校验 1001
resp = client.patch(
"/api/v1/auth/users/dave",
json={"role": "superuser"},
headers=admin_headers,
).json()
assert resp["code"] == 1001
def test_nonexistent_user_rejected(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
# 不存在用户 → 1004
resp = client.patch(
"/api/v1/auth/users/ghost",
json={"role": "admin"},
headers=admin_headers,
).json()
assert resp["code"] == 1004
def test_non_admin_forbidden(self, auth_stores) -> None:
client = TestClient(app)
admin_headers = _admin_login(client)
_create_user(client, admin_headers, "eve", "eve-pass-123", role="user")
# eveuser 角色)登录后调 PATCH → 1006
eve_login = _login(client, "eve", "eve-pass-123")
assert eve_login["code"] == 0
eve_headers = _bearer(eve_login["data"]["token"])
resp = client.patch(
"/api/v1/auth/users/eve",
json={"role": "admin"},
headers=eve_headers,
).json()
assert resp["code"] == 1006
+96
View File
@@ -17,6 +17,7 @@ import pytest
from app.core.users import (
UserExistsError,
UserNotFoundError,
UserStore,
UserStoreError,
bootstrap_admin,
@@ -225,6 +226,101 @@ class TestCountAdmins:
assert await store.count_admins() == 1
class TestEnabledField:
"""enabled 字段:默认 True、create 传参、get/list 回读、存量兼容"""
async def test_default_enabled_is_true(self):
record = await UserStore(FakeRedis()).create("alice", "password123")
assert record.enabled is True
async def test_create_with_enabled_false(self):
redis = FakeRedis()
store = UserStore(redis)
record = await store.create("alice", "password123", enabled=False)
assert record.enabled is False
# 持久化后回读仍为 False
assert (await store.get("alice")).enabled is False
# 落库 JSON 含 enabled 字段
assert json.loads(redis.store["user:alice"])["enabled"] is False
async def test_get_list_roundtrip_enabled(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123", enabled=False)
await store.create("bob", "password123", role="admin")
assert (await store.get("alice")).enabled is False
assert (await store.get("bob")).enabled is True
records = {r.username: r for r in await store.list()}
assert records["alice"].enabled is False
assert records["bob"].enabled is True
async def test_legacy_record_without_enabled_defaults_true(self):
"""存量记录无 enabled 字段时按 True 兼容(get/list"""
redis = FakeRedis()
store = UserStore(redis)
# 直接写入无 enabled 字段的存量记录
redis.store["user:legacy"] = json.dumps(
{
"username": "legacy",
"role": "user",
"password_hash": "hash",
"salt": "00" * 16,
"must_change_password": False,
"created_at": "2024-01-01T00:00:00+00:00",
}
)
record = await store.get("legacy")
assert record is not None
assert record.enabled is True
records = await store.list()
assert records[0].enabled is True
class TestUpdateUser:
"""update_user:角色/启用状态更新与校验"""
async def test_update_role(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123")
updated = await store.update_user("alice", role="admin")
assert updated.role == "admin"
assert updated.enabled is True # 未改动
# 持久化
record = await store.get("alice")
assert record is not None
assert record.role == "admin"
async def test_update_enabled(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123", role="admin")
updated = await store.update_user("alice", enabled=False)
assert updated.enabled is False
assert updated.role == "admin" # 未改动
async def test_update_both(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123")
updated = await store.update_user("alice", role="admin", enabled=False)
assert updated.role == "admin"
assert updated.enabled is False
async def test_user_not_found_raises(self):
with pytest.raises(UserNotFoundError):
await UserStore(FakeRedis()).update_user("nobody", role="admin")
async def test_invalid_role_raises_value_error(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123")
with pytest.raises(ValueError):
await store.update_user("alice", role="superuser")
async def test_no_fields_noop(self):
store = UserStore(FakeRedis())
await store.create("alice", "password123", role="admin")
updated = await store.update_user("alice")
assert updated.role == "admin"
assert updated.enabled is True
class TestBootstrapAdmin:
"""空库引导创建默认管理员"""