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>
This commit is contained in:
2026-07-31 21:29:02 +08:00
parent 2ab8b56a01
commit 92b062c048
24 changed files with 2771 additions and 350 deletions
+451
View File
@@ -0,0 +1,451 @@
"""认证与用户管理 API 测试(TestClient + 内存 UserStore/SessionStore,不真实联网)
覆盖:
- POST /auth/login:成功 / 密码错误 / 用户不存在 / 缺字段
- POST /auth/logout:成功注销后会话失效;无 token 1005
- POST /auth/password:旧密码错误 1005、弱密码 1001、成功后旧 token 仍可用(Task1 语义:改密不清 session
- must_change_password:登录成功但业务端点 1006,改密后恢复
- /auth/users CRUDadmin 全流程;user 角色 1006;重名/非法用户名/弱密码/非法角色 1001;
删自己/最后 admin 1001;不存在 1004;重置他人密码后旧密码失效且会话被清除
- 文档端点鉴权:POST /documents、DELETE /documents/{id} 无 token 1005、user 角色 token 可调;
GET /documents、POST /search 无 token 正常(免登录回归)
内存存储注入复用 conftest 的 auth_stores / admin_headers 夹具。
"""
import asyncio
from collections.abc import Iterator
from typing import Any
import pytest
from fastapi.testclient import TestClient
from app.api.v1 import document as document_module
from app.api.v1 import search as search_module
from app.main import app
from app.models.document import DocumentInput
from app.models.search import SearchRequest, SearchResponse
from app.services.qdrant import QdrantService
class FakeManager:
"""假入库任务管理器:记录 submit 调用并返回固定 task_id"""
def __init__(self, task_id: str = "task-1") -> None:
self.task_id = task_id
self.submitted: list[DocumentInput] = []
async def submit(self, doc: DocumentInput) -> str:
self.submitted.append(doc)
return self.task_id
class FakeQdrant:
"""假 QdrantService:列表返回空、删除返回全 0"""
async def scroll_l1(self, limit: int = 20, offset: str | None = None) -> tuple[list, str | None]:
return [], None
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
return {"doc_l1": 0, "doc_l2": 0, "doc_l3": 0, "chunks": 0}
class FakeRetriever:
"""假检索器:返回空命中的 SearchResponse"""
async def search(self, request: SearchRequest) -> SearchResponse:
return SearchResponse(query=request.query)
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch, auth_stores) -> Iterator[TestClient]:
"""TestClientlifespan 建集合改空操作;auth_stores 注入内存认证存储(含 admin/admin-pass-123"""
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
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 _create_user(user_store, username: str, password: str, *, role: str = "user", must_change: bool = False) -> None:
"""同步包装:向内存 UserStore 写入用户"""
asyncio.run(user_store.create(username, password, role=role, must_change_password=must_change))
def _headers(session_store, username: str, role: str) -> dict[str, str]:
"""同步包装:为指定用户签发 session 并返回 Bearer 请求头"""
token = asyncio.run(session_store.create(username, role))
return {"Authorization": f"Bearer {token}"}
class TestLogin:
"""POST /api/v1/auth/login"""
def test_login_success(self, client: TestClient) -> None:
body = _login(client, "admin", "admin-pass-123")
assert body["code"] == 0
data = body["data"]
assert data["token"]
assert data["username"] == "admin"
assert data["role"] == "admin"
assert data["must_change_password"] is False
def test_login_wrong_password(self, client: TestClient) -> None:
body = _login(client, "admin", "wrong-password")
assert body["code"] == 1005
assert body["data"] is None
def test_login_user_not_found(self, client: TestClient) -> None:
body = _login(client, "nobody", "whatever-123")
assert body["code"] == 1005
assert body["data"] is None
def test_login_missing_field(self, client: TestClient) -> None:
resp = client.post("/api/v1/auth/login", json={"username": "admin"})
body = resp.json()
assert body["code"] == 1001
assert body["data"] is None
class TestMe:
"""GET /api/v1/auth/me"""
def test_me_success(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.get("/api/v1/auth/me", headers=admin_headers).json()
assert body["code"] == 0
data = body["data"]
assert data["username"] == "admin"
assert data["role"] == "admin"
assert "password_hash" not in data
assert "salt" not in data
def test_me_without_token(self, client: TestClient) -> None:
body = client.get("/api/v1/auth/me").json()
assert body["code"] == 1005
class TestLogout:
"""POST /api/v1/auth/logout"""
def test_logout_invalidates_session(self, client: TestClient, admin_headers: dict[str, str]) -> None:
resp = client.post("/api/v1/auth/logout", headers=admin_headers)
assert resp.json()["code"] == 0
# 注销后同一 token 不再可用
body = client.get("/api/v1/auth/users", headers=admin_headers).json()
assert body["code"] == 1005
def test_logout_without_token(self, client: TestClient) -> None:
body = client.post("/api/v1/auth/logout").json()
assert body["code"] == 1005
assert body["data"] is None
class TestChangePassword:
"""POST /api/v1/auth/password"""
def test_wrong_old_password(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.post(
"/api/v1/auth/password",
json={"old_password": "wrong-old", "new_password": "new-pass-456"},
headers=admin_headers,
).json()
assert body["code"] == 1005
assert body["data"] is None
def test_weak_new_password(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.post(
"/api/v1/auth/password",
json={"old_password": "admin-pass-123", "new_password": "short"},
headers=admin_headers,
).json()
assert body["code"] == 1001
assert body["data"] is None
def test_success_keeps_session_and_new_password_works(
self, client: TestClient, admin_headers: dict[str, str]
) -> None:
body = client.post(
"/api/v1/auth/password",
json={"old_password": "admin-pass-123", "new_password": "new-pass-456"},
headers=admin_headers,
).json()
assert body["code"] == 0
# Task1 语义:set_password 不清 session,旧 token 仍可用
assert client.get("/api/v1/auth/users", headers=admin_headers).json()["code"] == 0
# 新密码可登录,旧密码失效
assert _login(client, "admin", "new-pass-456")["code"] == 0
assert _login(client, "admin", "admin-pass-123")["code"] == 1005
def test_change_password_without_token(self, client: TestClient) -> None:
body = client.post(
"/api/v1/auth/password", json={"old_password": "a", "new_password": "new-pass-456"}
).json()
assert body["code"] == 1005
class TestMustChangePassword:
"""must_change_password 用户:登录放行、业务端点拦截、改密后恢复"""
def test_blocked_until_password_change(
self, client: TestClient, auth_stores, monkeypatch: pytest.MonkeyPatch
) -> None:
user_store, session_store = auth_stores
_create_user(user_store, "rookie", "temp-pass-123", role="admin", must_change=True)
monkeypatch.setattr(document_module, "_get_task_manager", lambda: FakeManager())
# 登录成功,响应携带 must_change_password 标记
body = _login(client, "rookie", "temp-pass-123")
assert body["code"] == 0
assert body["data"]["must_change_password"] is True
headers = {"Authorization": f"Bearer {body['data']['token']}"}
# 业务端点(users 列表 / 文档入库)被拦截
assert client.get("/api/v1/auth/users", headers=headers).json()["code"] == 1006
resp = client.post("/api/v1/documents", json={"text": "正文"}, headers=headers)
assert resp.json()["code"] == 1006
# 改密(被拦截用户唯一可用接口)后恢复
change = client.post(
"/api/v1/auth/password",
json={"old_password": "temp-pass-123", "new_password": "new-pass-456"},
headers=headers,
).json()
assert change["code"] == 0
assert client.get("/api/v1/auth/users", headers=headers).json()["code"] == 0
resp = client.post("/api/v1/documents", json={"text": "正文"}, headers=headers)
assert resp.status_code == 202
class TestUsersCrud:
"""用户管理端点(/auth/users*,全部要求 admin"""
def test_list_users_sanitized(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.get("/api/v1/auth/users", headers=admin_headers).json()
assert body["code"] == 0
users = body["data"]
assert len(users) == 1
admin = users[0]
assert set(admin.keys()) == {"username", "role", "must_change_password", "created_at"}
assert admin["username"] == "admin"
assert "password_hash" not in admin
assert "salt" not in admin
def test_users_endpoints_forbidden_for_user_role(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")
assert client.get("/api/v1/auth/users", headers=headers).json()["code"] == 1006
body = client.post(
"/api/v1/auth/users", json={"username": "carol", "password": "carol-pass-123"}, headers=headers
).json()
assert body["code"] == 1006
assert client.delete("/api/v1/auth/users/admin", headers=headers).json()["code"] == 1006
def test_create_user_success(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.post(
"/api/v1/auth/users",
json={"username": "carol", "password": "carol-pass-123", "role": "user"},
headers=admin_headers,
).json()
assert body["code"] == 0
data = body["data"]
assert set(data.keys()) == {"username", "role", "must_change_password", "created_at"}
assert data["username"] == "carol"
assert data["role"] == "user"
assert data["must_change_password"] is False
# 新用户可登录
assert _login(client, "carol", "carol-pass-123")["code"] == 0
def test_create_user_duplicate(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.post(
"/api/v1/auth/users",
json={"username": "admin", "password": "another-pass-123"},
headers=admin_headers,
).json()
assert body["code"] == 1001
assert body["data"] is None
def test_create_user_invalid_name(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.post(
"/api/v1/auth/users",
json={"username": "x", "password": "valid-pass-123"},
headers=admin_headers,
).json()
assert body["code"] == 1001
def test_create_user_weak_password(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.post(
"/api/v1/auth/users",
json={"username": "dave", "password": "short"},
headers=admin_headers,
).json()
assert body["code"] == 1001
def test_create_user_invalid_role(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.post(
"/api/v1/auth/users",
json={"username": "dave", "password": "dave-pass-123", "role": "superuser"},
headers=admin_headers,
).json()
assert body["code"] == 1001
def test_reset_password(self, client: TestClient, auth_stores, admin_headers: dict[str, str]) -> None:
user_store, session_store = auth_stores
_create_user(user_store, "erin", "erin-pass-123", role="user")
old_headers = _headers(session_store, "erin", "user")
body = client.post(
"/api/v1/auth/users/erin/password",
json={"new_password": "erin-new-456"},
headers=admin_headers,
).json()
assert body["code"] == 0
# 旧密码失效、新密码可登录;该用户既有 session 被清除
assert _login(client, "erin", "erin-pass-123")["code"] == 1005
assert _login(client, "erin", "erin-new-456")["code"] == 0
assert client.post("/api/v1/auth/logout", headers=old_headers).json()["code"] == 1005
def test_reset_password_user_not_found(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.post(
"/api/v1/auth/users/ghost/password",
json={"new_password": "ghost-pass-123"},
headers=admin_headers,
).json()
assert body["code"] == 1004
def test_reset_password_weak(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.post(
"/api/v1/auth/users/admin/password",
json={"new_password": "short"},
headers=admin_headers,
).json()
assert body["code"] == 1001
def test_delete_user_success_clears_sessions(
self, client: TestClient, auth_stores, admin_headers: dict[str, str]
) -> None:
user_store, session_store = auth_stores
_create_user(user_store, "admin2", "admin2-pass-123", role="admin")
admin2_headers = _headers(session_store, "admin2", "admin")
body = client.delete("/api/v1/auth/users/admin2", headers=admin_headers).json()
assert body["code"] == 0
# 用户消失:登录 1005;其 session 已清除
assert _login(client, "admin2", "admin2-pass-123")["code"] == 1005
assert client.get("/api/v1/auth/users", headers=admin2_headers).json()["code"] == 1005
def test_delete_user_not_found(self, client: TestClient, admin_headers: dict[str, str]) -> None:
body = client.delete("/api/v1/auth/users/ghost", headers=admin_headers).json()
assert body["code"] == 1004
def test_delete_self_forbidden(self, client: TestClient, auth_stores) -> None:
user_store, session_store = auth_stores
# 存在另一个 admin,排除“最后 admin”规则干扰,单独验证删自己
_create_user(user_store, "admin2", "admin2-pass-123", role="admin")
admin2_headers = _headers(session_store, "admin2", "admin")
body = client.delete("/api/v1/auth/users/admin2", headers=admin2_headers).json()
assert body["code"] == 1001
def test_delete_last_admin_forbidden(self, client: TestClient, admin_headers: dict[str, str]) -> None:
# 库中唯一 admin 即当前登录者,删除被拒绝
body = client.delete("/api/v1/auth/users/admin", headers=admin_headers).json()
assert body["code"] == 1001
class TestDocumentAuth:
"""文档端点鉴权:变更类需登录,GET 系列免登录"""
def test_post_documents_requires_auth(self, client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(document_module, "_get_task_manager", lambda: FakeManager())
body = client.post("/api/v1/documents", json={"text": "正文"}).json()
assert body["code"] == 1005
assert body["data"] is None
def test_post_documents_with_user_role(
self, client: TestClient, auth_stores, monkeypatch: pytest.MonkeyPatch
) -> None:
user_store, session_store = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
manager = FakeManager(task_id="task-bob")
monkeypatch.setattr(document_module, "_get_task_manager", lambda: manager)
resp = client.post(
"/api/v1/documents", json={"text": "正文"}, headers=_headers(session_store, "bob", "user")
)
assert resp.status_code == 202
assert resp.json()["data"]["task_id"] == "task-bob"
assert len(manager.submitted) == 1
def test_delete_document_requires_auth(self, client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(document_module, "_get_qdrant", lambda: FakeQdrant())
body = client.delete("/api/v1/documents/doc-1").json()
assert body["code"] == 1005
def test_delete_document_with_user_role(
self, client: TestClient, auth_stores, monkeypatch: pytest.MonkeyPatch
) -> None:
user_store, session_store = auth_stores
_create_user(user_store, "bob", "bob-pass-123", role="user")
monkeypatch.setattr(document_module, "_get_qdrant", lambda: FakeQdrant())
body = client.delete("/api/v1/documents/doc-1", headers=_headers(session_store, "bob", "user")).json()
assert body["code"] == 0
assert body["data"]["deleted_total"] == 0
def test_get_documents_no_token(self, client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""GET /documents 免登录回归"""
monkeypatch.setattr(document_module, "_get_qdrant", lambda: FakeQdrant())
body = client.get("/api/v1/documents").json()
assert body["code"] == 0
assert body["data"] == {"items": [], "next_offset": None}
def test_search_no_token(self, client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""POST /search 免登录回归(检索鉴权由 conftest 覆盖旧 JWT 依赖放行)"""
monkeypatch.setattr(search_module, "_retriever", FakeRetriever())
body = client.post("/api/v1/search", json={"query": "任意查询"}).json()
assert body["code"] == 0
assert body["data"]["hits"] == []