Add tests for authentication and utilities, and update dependencies

- Created a new test package for services and added tests for AuthService.
- Implemented tests for user registration, login, and token creation.
- Added a new test package for utilities and included tests for password and JWT utilities.
- Updated `uv.lock` to include new dependencies: bcrypt, email-validator, pyjwt, and pytest-asyncio.
This commit is contained in:
JSC
2025-07-25 17:48:43 +02:00
parent af20bc8724
commit e456d34897
23 changed files with 2381 additions and 8 deletions

268
app/services/auth.py Normal file
View File

@@ -0,0 +1,268 @@
"""Authentication service."""
import hashlib
from datetime import UTC, datetime, timedelta
from fastapi import HTTPException, status
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.config import settings
from app.core.logging import get_logger
from app.models.user import User
from app.repositories.user import UserRepository
from app.schemas.auth import (
AuthResponse,
TokenResponse,
UserLoginRequest,
UserRegisterRequest,
UserResponse,
)
from app.utils.auth import JWTUtils, PasswordUtils
logger = get_logger(__name__)
class AuthService:
"""Service for authentication operations."""
def __init__(self, session: AsyncSession) -> None:
"""Initialize the auth service."""
self.session = session
self.user_repo = UserRepository(session)
async def register(self, request: UserRegisterRequest) -> AuthResponse:
"""Register a new user."""
logger.info("Attempting to register user with email: %s", request.email)
# Check if email already exists
if await self.user_repo.email_exists(request.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email address is already registered",
)
# Hash the password
hashed_password = PasswordUtils.hash_password(request.password)
# Create user data
user_data = {
"email": request.email,
"name": request.name,
"password_hash": hashed_password,
"role": "user",
"is_active": True,
}
# Create the user
user = await self.user_repo.create(user_data)
# Generate access token
token = self._create_access_token(user)
# Create response
user_response = await self.create_user_response(user)
logger.info("Successfully registered user: %s", user.email)
return AuthResponse(user=user_response, token=token)
async def login(self, request: UserLoginRequest) -> AuthResponse:
"""Authenticate a user login."""
logger.info("Attempting to login user with email: %s", request.email)
# Get user by email
user = await self.user_repo.get_by_email(request.email)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is deactivated",
)
# Verify password
if not user.password_hash or not PasswordUtils.verify_password(
request.password,
user.password_hash,
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# Generate access token
token = self._create_access_token(user)
# Create response
user_response = await self.create_user_response(user)
logger.info("Successfully authenticated user: %s", user.email)
return AuthResponse(user=user_response, token=token)
async def get_current_user(self, user_id: int) -> User:
"""Get the current authenticated user."""
user = await self.user_repo.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is deactivated",
)
return user
def _create_access_token(self, user: User) -> TokenResponse:
"""Create an access token for a user."""
access_token_expires = timedelta(
minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
)
token_data = {
"sub": str(user.id),
"email": user.email,
"role": user.role,
}
access_token = JWTUtils.create_access_token(
data=token_data,
expires_delta=access_token_expires,
)
return TokenResponse(
access_token=access_token,
token_type="bearer", # noqa: S106 # This is OAuth2 standard, not a password
expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
async def create_and_store_refresh_token(self, user: User) -> str:
"""Create and store a refresh token for a user."""
refresh_token_expires = timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
token_data = {
"sub": str(user.id),
"email": user.email,
}
refresh_token = JWTUtils.create_refresh_token(
data=token_data,
expires_delta=refresh_token_expires,
)
# Hash the refresh token for storage
refresh_token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
# Store hash and expiration in database
user.refresh_token_hash = refresh_token_hash
user.refresh_token_expires_at = datetime.now(UTC) + refresh_token_expires
self.session.add(user)
await self.session.commit()
return refresh_token
async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
"""Create a new access token using a refresh token."""
try:
# Decode the refresh token
payload = JWTUtils.decode_refresh_token(refresh_token)
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
user_id = int(user_id_str)
# Get the user
user = await self.user_repo.get_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Check if refresh token hash matches stored hash
refresh_token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
if (
not user.refresh_token_hash
or user.refresh_token_hash != refresh_token_hash
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Check if refresh token is expired
if user.refresh_token_expires_at and datetime.now(
UTC
) > user.refresh_token_expires_at.replace(tzinfo=UTC):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token has expired",
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is deactivated",
)
# Create new access token
return self._create_access_token(user)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to refresh access token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
) from e
async def revoke_refresh_token(self, user: User) -> None:
"""Revoke a user's refresh token."""
user.refresh_token_hash = None
user.refresh_token_expires_at = None
self.session.add(user)
await self.session.commit()
logger.info("Refresh token revoked for user: %s", user.email)
async def create_user_response(self, user: User) -> UserResponse:
"""Create a user response from a user model."""
# Always refresh to ensure the plan relationship is loaded
await self.session.refresh(user, ["plan"])
# Ensure user has an ID (should always be true for persisted users)
if user.id is None:
msg = "User must have an ID to create response"
raise ValueError(msg)
return UserResponse(
id=user.id,
email=user.email,
name=user.name,
picture=user.picture,
role=user.role,
credits=user.credits,
is_active=user.is_active,
plan={
"id": user.plan.id,
"code": user.plan.code,
"name": user.plan.name,
"description": user.plan.description,
"credits": user.plan.credits,
"max_credits": user.plan.max_credits,
},
created_at=user.created_at,
updated_at=user.updated_at,
)