chore: 完成全量功能迭代与部署准备
- 移除冗余依赖包 - 新增账号禁用校验与用户管理能力 - 新增文档下载与管理页面文件展示 - 新增API文档页面与用户管理前端页面 - 重构时区处理与docker-compose部署配置 - 完善测试用例与项目文档
This commit is contained in:
+163
-9
@@ -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 Bearer;1005 回登录;1006 错误条提示"""
|
||||
assert 'options.headers["Authorization"] = "Bearer " + token' in admin_html
|
||||
|
||||
+168
-2
@@ -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 调业务端点 → 1005(session 已清)
|
||||
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 系列免登录"""
|
||||
|
||||
|
||||
@@ -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 预置的 admin(admin/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)
|
||||
|
||||
# 唯一 admin(admin)尝试降级自己 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")
|
||||
|
||||
# 空 body(role 与 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")
|
||||
|
||||
# eve(user 角色)登录后调 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
|
||||
@@ -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:
|
||||
"""空库引导创建默认管理员"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user