"""认证 API 与核心函数的单元测试(mock UserStore,不依赖真实 Redis) 覆盖: - POST /auth/login:成功 / 密码错误 / 用户不存在 - POST /auth/register:成功 / 用户已存在 / 注册关闭 - GET /auth/me:有效 token / 无 token / 无效 token(需走真实 get_current_user) - require_admin:非 admin 抛 FORBIDDEN(直接测函数) - create_access_token + decode_token 往返一致 - hash_password + verify_password 正确 / 错误 UserStore 的 authenticate/create 在 FakeUserStore 中 mock,避免依赖真实 Redis。 """ from datetime import UTC, datetime import pytest from fastapi.testclient import TestClient from app.api.response import ApiError from app.api.v1 import auth as auth_module from app.config import settings from app.core import auth as auth_core from app.core.auth import ( ERR_BAD_CREDENTIALS, ERR_FORBIDDEN, ERR_REGISTER_DISABLED, ERR_TOKEN_INVALID, ERR_UNAUTHORIZED, ERR_USER_EXISTS, create_access_token, decode_token, get_current_user, hash_password, require_admin, verify_password, ) from app.main import app from app.models.auth import AuthUser, StoredUser def _now() -> datetime: return datetime.now(UTC) class FakeUserStore: """假 UserStore:按预置数据返回结果或抛 ApiError,记录调用 authenticate/create 使用预置 hashed_password,不调用真实 hash_password。 """ def __init__( self, user: StoredUser | None = None, exists: bool = False, create_error: ApiError | None = None, auth_fail: bool = False, ) -> None: self.user = user self._exists = exists self.create_error = create_error self.auth_fail = auth_fail self.create_calls: list[tuple[str, str, str]] = [] async def get(self, username: str) -> StoredUser | None: if self.user is not None and self.user.username == username: return self.user return None async def exists(self, username: str) -> bool: return self._exists async def create(self, username: str, password: str, role: str = "user") -> StoredUser: if self.create_error is not None: raise self.create_error user = StoredUser( username=username, role=role, created_at=_now(), hashed_password="fake-hash", ) self.create_calls.append((username, password, role)) return user async def authenticate(self, username: str, password: str) -> StoredUser: if self.auth_fail or self.user is None or self.user.username != username: raise ApiError(ERR_BAD_CREDENTIALS, "用户名或密码错误") return self.user class NullUserStore: """恒返回 None 的空存储:用于 /auth/me 测试,让 get_current_user 降级用 JWT payload""" async def get(self, username: str) -> StoredUser | None: return None async def exists(self, username: str) -> bool: return False def _install_store(monkeypatch: pytest.MonkeyPatch, store: FakeUserStore) -> None: """将 auth 路由模块的 get_user_store 替换为假存储""" monkeypatch.setattr(auth_module, "get_user_store", lambda: store) def _make_stored_user(username: str = "alice", role: str = "user") -> StoredUser: return StoredUser(username=username, role=role, created_at=_now(), hashed_password="fake-hash") class TestAuthLoginApi: """POST /auth/login""" def test_login_success(self, monkeypatch: pytest.MonkeyPatch): user = _make_stored_user(username="alice", role="admin") _install_store(monkeypatch, FakeUserStore(user=user)) client = TestClient(app) resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "secret123"}) assert resp.status_code == 200 body = resp.json() assert body["code"] == 0 data = body["data"] assert data["access_token"] assert data["token_type"] == "bearer" assert data["expires_in"] == settings.jwt_expire_minutes * 60 assert data["user"]["username"] == "alice" assert data["user"]["role"] == "admin" assert "created_at" in data["user"] def test_login_wrong_password(self, monkeypatch: pytest.MonkeyPatch): user = _make_stored_user(username="alice") # authenticate 失败(密码不匹配) _install_store(monkeypatch, FakeUserStore(user=user, auth_fail=True)) client = TestClient(app) resp = client.post("/api/v1/auth/login", json={"username": "alice", "password": "WRONG"}) body = resp.json() assert body["code"] == ERR_BAD_CREDENTIALS assert body["data"] is None def test_login_user_not_found(self, monkeypatch: pytest.MonkeyPatch): _install_store(monkeypatch, FakeUserStore(user=None)) client = TestClient(app) resp = client.post("/api/v1/auth/login", json={"username": "nobody", "password": "whatever"}) body = resp.json() assert body["code"] == ERR_BAD_CREDENTIALS class TestAuthRegisterApi: """POST /auth/register""" def test_register_success(self, monkeypatch: pytest.MonkeyPatch): store = FakeUserStore(user=None, exists=False) _install_store(monkeypatch, store) client = TestClient(app) resp = client.post( "/api/v1/auth/register", json={"username": "newbie", "password": "passwd123"} ) assert resp.status_code == 200 body = resp.json() assert body["code"] == 0 data = body["data"] assert data["access_token"] assert data["user"]["username"] == "newbie" assert data["user"]["role"] == "user" assert len(store.create_calls) == 1 assert store.create_calls[0][0] == "newbie" assert store.create_calls[0][2] == "user" def test_register_user_exists(self, monkeypatch: pytest.MonkeyPatch): store = FakeUserStore( exists=True, create_error=ApiError(ERR_USER_EXISTS, "用户名已存在: newbie"), ) _install_store(monkeypatch, store) client = TestClient(app) resp = client.post( "/api/v1/auth/register", json={"username": "newbie", "password": "passwd123"} ) body = resp.json() assert body["code"] == ERR_USER_EXISTS assert body["data"] is None def test_register_disabled(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(settings, "auth_register_enabled", False) _install_store(monkeypatch, FakeUserStore()) client = TestClient(app) resp = client.post( "/api/v1/auth/register", json={"username": "newbie", "password": "passwd123"} ) body = resp.json() assert body["code"] == ERR_REGISTER_DISABLED assert body["data"] is None class TestAuthMeApi: """GET /auth/me:需走真实 get_current_user,测试内清除 override""" def test_me_with_valid_token(self, monkeypatch: pytest.MonkeyPatch): # 清除 override,让 get_current_user 走真实认证 app.dependency_overrides.pop(get_current_user, None) # mock get_user_store 返回空存储(模拟 Redis 无该用户,降级用 JWT payload) monkeypatch.setattr(auth_core, "get_user_store", lambda: NullUserStore()) token, _ = create_access_token("alice", "admin") client = TestClient(app) resp = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 body = resp.json() assert body["code"] == 0 assert body["data"]["username"] == "alice" assert body["data"]["role"] == "admin" def test_me_without_token(self, monkeypatch: pytest.MonkeyPatch): app.dependency_overrides.pop(get_current_user, None) monkeypatch.setattr(auth_core, "get_user_store", lambda: NullUserStore()) client = TestClient(app) resp = client.get("/api/v1/auth/me") body = resp.json() assert body["code"] == ERR_UNAUTHORIZED assert body["data"] is None def test_me_with_invalid_token(self, monkeypatch: pytest.MonkeyPatch): app.dependency_overrides.pop(get_current_user, None) monkeypatch.setattr(auth_core, "get_user_store", lambda: NullUserStore()) client = TestClient(app) resp = client.get("/api/v1/auth/me", headers={"Authorization": "Bearer not-a-jwt"}) body = resp.json() assert body["code"] == ERR_TOKEN_INVALID assert body["data"] is None class TestRequireAdmin: """require_admin:admin 通过、非 admin 抛 FORBIDDEN(直接测函数,不经 API)""" async def test_admin_passes(self): admin = AuthUser(username="alice", role="admin", created_at=_now()) result = await require_admin(user=admin) assert result.role == "admin" async def test_non_admin_raises_forbidden(self): normal = AuthUser(username="bob", role="user", created_at=_now()) with pytest.raises(ApiError) as exc: await require_admin(user=normal) assert exc.value.code == ERR_FORBIDDEN class TestAuthCoreFunctions: """核心函数:token 往返 / 密码哈希""" def test_create_and_decode_token_roundtrip(self): token, expires_in = create_access_token("alice", "admin") assert expires_in == settings.jwt_expire_minutes * 60 payload = decode_token(token) assert payload["sub"] == "alice" assert payload["role"] == "admin" assert "iat" in payload and "exp" in payload def test_decode_invalid_token_raises(self): with pytest.raises(ApiError) as exc: decode_token("not-a-jwt") assert exc.value.code == ERR_TOKEN_INVALID def test_hash_password_not_plaintext(self): hashed = hash_password("my-secret") assert hashed != "my-secret" assert hashed.startswith("$2") # bcrypt 哈希前缀 def test_verify_password_correct(self): hashed = hash_password("my-secret") assert verify_password("my-secret", hashed) is True def test_verify_password_wrong(self): hashed = hash_password("my-secret") assert verify_password("wrong-password", hashed) is False def test_verify_password_garbage_hash_returns_false(self): # 非法 hash 会被 bcrypt 拒绝,verify_password 捕获异常返回 False assert verify_password("any", "not-a-valid-hash") is False