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

View File

@@ -2,10 +2,11 @@
from fastapi import APIRouter
from app.api.v1 import main
from app.api.v1 import auth, main
# V1 API router with v1 prefix
api_router = APIRouter(prefix="/v1")
# Include all route modules
api_router.include_router(main.router, tags=["main"])
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])

198
app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,198 @@
"""Authentication endpoints."""
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from app.core.config import settings
from app.core.dependencies import get_auth_service, get_current_active_user
from app.core.logging import get_logger
from app.models.user import User
from app.schemas.auth import (
UserLoginRequest,
UserRegisterRequest,
UserResponse,
)
from app.services.auth import AuthService
router = APIRouter()
logger = get_logger(__name__)
@router.post(
"/register",
status_code=status.HTTP_201_CREATED,
)
async def register(
request: UserRegisterRequest,
response: Response,
auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> UserResponse:
"""Register a new user account."""
try:
auth_response = await auth_service.register(request)
# Create and store refresh token - need to get User object from service
user = await auth_service.get_current_user(auth_response.user.id)
refresh_token = await auth_service.create_and_store_refresh_token(user)
# Set HTTP-only cookies for both tokens
response.set_cookie(
key="access_token",
value=auth_response.token.access_token,
max_age=auth_response.token.expires_in,
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
max_age=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
# Return only user data, tokens are now in cookies
return auth_response.user
except HTTPException:
raise
except Exception as e:
logger.exception("Registration failed for email: %s", request.email)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Registration failed",
) from e
@router.post("/login")
async def login(
request: UserLoginRequest,
response: Response,
auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> UserResponse:
"""Authenticate a user and return access token."""
try:
auth_response = await auth_service.login(request)
# Create and store refresh token - need to get User object from service
user = await auth_service.get_current_user(auth_response.user.id)
refresh_token = await auth_service.create_and_store_refresh_token(user)
# Set HTTP-only cookies for both tokens
response.set_cookie(
key="access_token",
value=auth_response.token.access_token,
max_age=auth_response.token.expires_in,
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
max_age=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
# Return only user data, tokens are now in cookies
return auth_response.user
except HTTPException:
raise
except Exception as e:
logger.exception("Login failed for email: %s", request.email)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed",
) from e
@router.get("/me")
async def get_current_user_info(
current_user: Annotated[User, Depends(get_current_active_user)],
auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> UserResponse:
"""Get current user information."""
try:
return await auth_service.create_user_response(current_user)
except Exception as e:
logger.exception("Failed to get current user info")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve user information",
) from e
@router.post("/refresh")
async def refresh_token(
response: Response,
refresh_token: Annotated[str | None, Cookie()],
auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> dict[str, str]:
"""Refresh access token using refresh token."""
try:
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No refresh token provided",
)
# Get new access token
token_response = await auth_service.refresh_access_token(refresh_token)
# Set new access token cookie
response.set_cookie(
key="access_token",
value=token_response.access_token,
max_age=token_response.expires_in,
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
return {"message": "Token refreshed successfully"}
except HTTPException:
raise
except Exception as e:
logger.exception("Token refresh failed")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Token refresh failed",
) from e
@router.post("/logout")
async def logout(
response: Response,
current_user: Annotated[User, Depends(get_current_active_user)],
auth_service: Annotated[AuthService, Depends(get_auth_service)],
) -> dict[str, str]:
"""Logout endpoint - clears cookies and revokes refresh token."""
try:
# Revoke refresh token from database
await auth_service.revoke_refresh_token(current_user)
# Clear both cookies
response.delete_cookie(
key="access_token",
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
response.delete_cookie(
key="refresh_token",
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
)
return {"message": "Successfully logged out"}
except Exception as e:
logger.exception("Logout failed")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Logout failed",
) from e

View File

@@ -13,4 +13,4 @@ logger = get_logger(__name__)
def health() -> dict[str, str]:
"""Health check endpoint."""
logger.info("Health check endpoint accessed")
return {"status": "healthy"}
return {"status": "healthy"}