Files
QMDSearch/tests/test_auth_api.py
T
kplam 92b062c048 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>
2026-07-31 21:29:02 +08:00

452 lines
18 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.
"""认证与用户管理 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"] == []