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:
@@ -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
198
app/api/v1/auth.py
Normal 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
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -14,15 +16,27 @@ class Settings(BaseSettings):
|
||||
HOST: str = "localhost"
|
||||
PORT: int = 8000
|
||||
RELOAD: bool = True
|
||||
LOG_LEVEL: str = "info"
|
||||
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///data/soundboard.db"
|
||||
DATABASE_ECHO: bool = False
|
||||
|
||||
LOG_LEVEL: str = "info"
|
||||
LOG_FILE: str = "logs/app.log"
|
||||
LOG_MAX_SIZE: int = 10 * 1024 * 1024
|
||||
LOG_BACKUP_COUNT: int = 5
|
||||
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///data/soundboard.db"
|
||||
DATABASE_ECHO: bool = False
|
||||
# JWT Configuration
|
||||
JWT_SECRET_KEY: str = (
|
||||
"your-secret-key-change-in-production" # noqa: S105 default value if none set in .env
|
||||
)
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 # Shorter-lived access token
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7 # Longer-lived refresh token
|
||||
|
||||
# Cookie Configuration
|
||||
COOKIE_SECURE: bool = True # Set to False for development without HTTPS
|
||||
COOKIE_SAMESITE: Literal["strict", "lax", "none"] = "lax"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
103
app/core/dependencies.py
Normal file
103
app/core/dependencies.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""FastAPI dependencies."""
|
||||
|
||||
from typing import Annotated, NoReturn, cast
|
||||
|
||||
from fastapi import Cookie, Depends, HTTPException, status
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.logging import get_logger
|
||||
from app.models.user import User
|
||||
from app.services.auth import AuthService
|
||||
from app.utils.auth import JWTUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _raise_invalid_token_error() -> NoReturn:
|
||||
"""Raise an invalid token HTTP exception."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
)
|
||||
|
||||
|
||||
def _raise_auth_error() -> NoReturn:
|
||||
"""Raise an authentication HTTP exception."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
|
||||
|
||||
async def get_auth_service(
|
||||
session: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> AuthService:
|
||||
"""Get the authentication service."""
|
||||
return AuthService(session)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
access_token: Annotated[str | None, Cookie()],
|
||||
auth_service: Annotated[AuthService, Depends(get_auth_service)],
|
||||
) -> User:
|
||||
"""Get the current authenticated user from JWT token in HTTP-only cookie."""
|
||||
try:
|
||||
# Check if access token cookie exists
|
||||
if not access_token:
|
||||
logger.warning("No access token cookie found")
|
||||
_raise_auth_error()
|
||||
|
||||
# Decode the JWT token
|
||||
payload = JWTUtils.decode_access_token(access_token)
|
||||
|
||||
# Extract user ID from token
|
||||
user_id_str = payload.get("sub")
|
||||
if not user_id_str:
|
||||
_raise_invalid_token_error()
|
||||
|
||||
# At this point user_id_str is guaranteed to be truthy, safe to cast
|
||||
user_id_str = cast("str", user_id_str)
|
||||
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning("Invalid user ID in token: %s", user_id_str)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
) from e
|
||||
|
||||
# Get the user
|
||||
return await auth_service.get_current_user(user_id)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions without wrapping them
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Failed to authenticate user")
|
||||
_raise_auth_error()
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Get the current authenticated and active user."""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Account is deactivated",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_admin_user(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> User:
|
||||
"""Get the current authenticated admin user."""
|
||||
if current_user.role not in ["admin", "superadmin"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions",
|
||||
)
|
||||
return current_user
|
||||
@@ -26,6 +26,8 @@ class User(BaseModel, table=True):
|
||||
credits: int = Field(default=0, ge=0, nullable=False)
|
||||
api_token: str | None = Field(unique=True, default=None)
|
||||
api_token_expires_at: datetime | None = Field(default=None)
|
||||
refresh_token_hash: str | None = Field(default=None)
|
||||
refresh_token_expires_at: datetime | None = Field(default=None)
|
||||
|
||||
# relationships
|
||||
oauths: list["UserOauth"] = Relationship(back_populates="user")
|
||||
|
||||
134
app/repositories/user.py
Normal file
134
app/repositories/user.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""User repository."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.plan import Plan
|
||||
from app.models.user import User
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""Repository for user operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""Initialize the user repository."""
|
||||
self.session = session
|
||||
|
||||
async def get_by_id(self, user_id: int) -> User | None:
|
||||
"""Get a user by ID."""
|
||||
try:
|
||||
statement = select(User).where(User.id == user_id)
|
||||
result = await self.session.exec(statement)
|
||||
return result.first()
|
||||
except Exception:
|
||||
logger.exception("Failed to get user by ID: %s", user_id)
|
||||
raise
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
"""Get a user by email address."""
|
||||
try:
|
||||
statement = select(User).where(User.email == email)
|
||||
result = await self.session.exec(statement)
|
||||
return result.first()
|
||||
except Exception:
|
||||
logger.exception("Failed to get user by email: %s", email)
|
||||
raise
|
||||
|
||||
async def get_by_api_token(self, api_token: str) -> User | None:
|
||||
"""Get a user by API token."""
|
||||
try:
|
||||
statement = select(User).where(User.api_token == api_token)
|
||||
result = await self.session.exec(statement)
|
||||
return result.first()
|
||||
except Exception:
|
||||
logger.exception("Failed to get user by API token")
|
||||
raise
|
||||
|
||||
async def create(self, user_data: dict[str, Any]) -> User:
|
||||
"""Create a new user."""
|
||||
def _raise_plan_not_found() -> None:
|
||||
msg = "Default plan not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
try:
|
||||
# Check if this is the first user
|
||||
user_count_statement = select(User)
|
||||
user_count_result = await self.session.exec(user_count_statement)
|
||||
is_first_user = user_count_result.first() is None
|
||||
|
||||
if is_first_user:
|
||||
# First user gets admin role and pro plan
|
||||
plan_statement = select(Plan).where(Plan.code == "pro")
|
||||
user_data["role"] = "admin"
|
||||
logger.info("Creating first user with admin role and pro plan")
|
||||
else:
|
||||
# Regular users get free plan
|
||||
plan_statement = select(Plan).where(Plan.code == "free")
|
||||
|
||||
plan_result = await self.session.exec(plan_statement)
|
||||
default_plan = plan_result.first()
|
||||
|
||||
if default_plan is None:
|
||||
_raise_plan_not_found()
|
||||
|
||||
# Type assertion to help type checker understand default_plan is not None
|
||||
assert default_plan is not None # noqa: S101
|
||||
|
||||
# Set plan_id and default credits
|
||||
user_data["plan_id"] = default_plan.id
|
||||
user_data["credits"] = default_plan.credits
|
||||
|
||||
user = User(**user_data)
|
||||
self.session.add(user)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(user)
|
||||
except Exception:
|
||||
await self.session.rollback()
|
||||
logger.exception("Failed to create user")
|
||||
raise
|
||||
else:
|
||||
logger.info("Created new user with email: %s", user.email)
|
||||
return user
|
||||
|
||||
async def update(self, user: User, update_data: dict[str, Any]) -> User:
|
||||
"""Update a user."""
|
||||
try:
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
await self.session.commit()
|
||||
await self.session.refresh(user)
|
||||
except Exception:
|
||||
await self.session.rollback()
|
||||
logger.exception("Failed to update user")
|
||||
raise
|
||||
else:
|
||||
logger.info("Updated user: %s", user.email)
|
||||
return user
|
||||
|
||||
async def delete(self, user: User) -> None:
|
||||
"""Delete a user."""
|
||||
try:
|
||||
await self.session.delete(user)
|
||||
await self.session.commit()
|
||||
|
||||
logger.info("Deleted user: %s", user.email)
|
||||
except Exception:
|
||||
await self.session.rollback()
|
||||
logger.exception("Failed to delete user")
|
||||
raise
|
||||
|
||||
async def email_exists(self, email: str) -> bool:
|
||||
"""Check if an email address is already registered."""
|
||||
try:
|
||||
statement = select(User).where(User.email == email)
|
||||
result = await self.session.exec(statement)
|
||||
return result.first() is not None
|
||||
except Exception:
|
||||
logger.exception("Failed to check if email exists: %s", email)
|
||||
raise
|
||||
53
app/schemas/auth.py
Normal file
53
app/schemas/auth.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Authentication schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserRegisterRequest(BaseModel):
|
||||
"""Schema for user registration request."""
|
||||
|
||||
email: EmailStr = Field(..., description="User email address")
|
||||
password: str = Field(
|
||||
..., min_length=8, description="User password (minimum 8 characters)",
|
||||
)
|
||||
name: str = Field(..., min_length=1, max_length=100, description="User full name")
|
||||
|
||||
|
||||
class UserLoginRequest(BaseModel):
|
||||
"""Schema for user login request."""
|
||||
|
||||
email: EmailStr = Field(..., description="User email address")
|
||||
password: str = Field(..., description="User password")
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Schema for authentication token response."""
|
||||
|
||||
access_token: str = Field(..., description="JWT access token")
|
||||
token_type: str = Field(default="bearer", description="Token type")
|
||||
expires_in: int = Field(..., description="Token expiration time in seconds")
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema for user information response."""
|
||||
|
||||
id: int = Field(..., description="User ID")
|
||||
email: str = Field(..., description="User email address")
|
||||
name: str = Field(..., description="User full name")
|
||||
picture: str | None = Field(None, description="User profile picture URL")
|
||||
role: str = Field(..., description="User role")
|
||||
credits: int = Field(..., description="User credits")
|
||||
is_active: bool = Field(..., description="Whether user is active")
|
||||
plan: dict[str, Any] = Field(..., description="User plan information")
|
||||
created_at: datetime = Field(..., description="User creation timestamp")
|
||||
updated_at: datetime = Field(..., description="User last update timestamp")
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
"""Schema for authentication response."""
|
||||
|
||||
user: UserResponse = Field(..., description="User information")
|
||||
token: TokenResponse = Field(..., description="Authentication token")
|
||||
268
app/services/auth.py
Normal file
268
app/services/auth.py
Normal 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,
|
||||
)
|
||||
179
app/utils/auth.py
Normal file
179
app/utils/auth.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Authentication utilities."""
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PasswordUtils:
|
||||
"""Utility class for password operations."""
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
|
||||
return hashed.decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def verify_password(password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return bcrypt.checkpw(password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
|
||||
class JWTUtils:
|
||||
"""Utility class for JWT operations."""
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(
|
||||
data: dict[str, Any],
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create a JWT access token."""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.now(UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(UTC) + timedelta(
|
||||
minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
|
||||
try:
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithm=settings.JWT_ALGORITHM,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create JWT token")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Could not create access token",
|
||||
) from e
|
||||
else:
|
||||
logger.info("JWT token created successfully")
|
||||
return encoded_jwt
|
||||
|
||||
@staticmethod
|
||||
def create_refresh_token(
|
||||
data: dict[str, Any],
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create a JWT refresh token."""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.now(UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(UTC) + timedelta(
|
||||
days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS,
|
||||
)
|
||||
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
|
||||
try:
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithm=settings.JWT_ALGORITHM,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create JWT refresh token")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Could not create refresh token",
|
||||
) from e
|
||||
else:
|
||||
logger.info("JWT refresh token created successfully")
|
||||
return encoded_jwt
|
||||
|
||||
@staticmethod
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
"""Decode and validate a JWT access token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
# Ensure this is not a refresh token
|
||||
if payload.get("type") == "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type",
|
||||
)
|
||||
return dict(payload)
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired",
|
||||
) from e
|
||||
except jwt.PyJWTError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def decode_refresh_token(token: str) -> dict[str, Any]:
|
||||
"""Decode and validate a JWT refresh token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
# Ensure this is a refresh token
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type",
|
||||
)
|
||||
return dict(payload)
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token has expired",
|
||||
) from e
|
||||
except jwt.PyJWTError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate refresh token",
|
||||
) from e
|
||||
|
||||
|
||||
class TokenUtils:
|
||||
"""Utility class for API token operations."""
|
||||
|
||||
@staticmethod
|
||||
def generate_api_token() -> str:
|
||||
"""Generate a secure random API token."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@staticmethod
|
||||
def is_token_expired(expires_at: datetime | None) -> bool:
|
||||
"""Check if a token is expired."""
|
||||
if expires_at is None:
|
||||
return False
|
||||
|
||||
# Handle timezone-aware and naive datetimes
|
||||
if expires_at.tzinfo is None:
|
||||
# Naive datetime - assume UTC
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
else:
|
||||
# Convert to UTC if not already
|
||||
expires_at = expires_at.astimezone(UTC)
|
||||
|
||||
return datetime.now(UTC) > expires_at
|
||||
@@ -6,8 +6,12 @@ readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiosqlite==0.21.0",
|
||||
"bcrypt==4.3.0",
|
||||
"email-validator==2.2.0",
|
||||
"fastapi[standard]==0.116.1",
|
||||
"pydantic-settings==2.10.1",
|
||||
"pyjwt==2.10.1",
|
||||
"sqlmodel==0.0.24",
|
||||
"uvicorn[standard]==0.35.0",
|
||||
]
|
||||
|
||||
@@ -15,10 +19,11 @@ dependencies = [
|
||||
dev-dependencies = [
|
||||
"coverage==7.9.2",
|
||||
"faker==37.4.2",
|
||||
"httpx==0.28.1",
|
||||
"mypy==1.17.0",
|
||||
"pytest==8.4.1",
|
||||
"pytest-asyncio==1.1.0",
|
||||
"ruff==0.12.4",
|
||||
"sqlmodel==0.0.24",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
@@ -32,3 +37,6 @@ exclude = ["alembic"]
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = ["D100", "D103"]
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"tests/**/*.py" = ["S101", "S105"]
|
||||
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests package."""
|
||||
1
tests/api/__init__.py
Normal file
1
tests/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API tests package."""
|
||||
1
tests/api/v1/__init__.py
Normal file
1
tests/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API v1 tests package."""
|
||||
371
tests/api/v1/test_auth_endpoints.py
Normal file
371
tests/api/v1/test_auth_endpoints.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""Tests for authentication endpoints."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.plan import Plan
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class TestAuthEndpoints:
|
||||
"""Test authentication API endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_success(
|
||||
self,
|
||||
test_client: AsyncClient,
|
||||
test_user_data: dict[str, str],
|
||||
test_plan: Plan
|
||||
) -> None:
|
||||
"""Test successful user registration."""
|
||||
response = await test_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json=test_user_data
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "user" in data
|
||||
assert "token" in data
|
||||
|
||||
# Check user data
|
||||
user = data["user"]
|
||||
assert user["email"] == test_user_data["email"]
|
||||
assert user["name"] == test_user_data["name"]
|
||||
assert user["role"] == "user"
|
||||
assert user["is_active"] is True
|
||||
assert user["credits"] > 0
|
||||
assert "plan" in user
|
||||
|
||||
# Check token data
|
||||
token = data["token"]
|
||||
assert "access_token" in token
|
||||
assert token["token_type"] == "bearer"
|
||||
assert token["expires_in"] > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate_email(
|
||||
self,
|
||||
test_client: AsyncClient,
|
||||
test_user: User
|
||||
) -> None:
|
||||
"""Test registration with duplicate email."""
|
||||
user_data = {
|
||||
"email": test_user.email,
|
||||
"password": "password123",
|
||||
"name": "Another User"
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json=user_data
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Email address is already registered" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_invalid_email(
|
||||
self,
|
||||
test_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test registration with invalid email."""
|
||||
user_data = {
|
||||
"email": "invalid-email",
|
||||
"password": "password123",
|
||||
"name": "Test User"
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json=user_data
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_short_password(
|
||||
self,
|
||||
test_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test registration with short password."""
|
||||
user_data = {
|
||||
"email": "test@example.com",
|
||||
"password": "short",
|
||||
"name": "Test User"
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json=user_data
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_missing_fields(
|
||||
self,
|
||||
test_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test registration with missing fields."""
|
||||
user_data = {
|
||||
"email": "test@example.com"
|
||||
# Missing password and name
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json=user_data
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(
|
||||
self,
|
||||
test_client: AsyncClient,
|
||||
test_user: User,
|
||||
test_login_data: dict[str, str]
|
||||
) -> None:
|
||||
"""Test successful user login."""
|
||||
response = await test_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json=test_login_data
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "user" in data
|
||||
assert "token" in data
|
||||
|
||||
# Check user data
|
||||
user = data["user"]
|
||||
assert user["id"] == test_user.id
|
||||
assert user["email"] == test_user.email
|
||||
assert user["name"] == test_user.name
|
||||
assert user["role"] == test_user.role
|
||||
|
||||
# Check token data
|
||||
token = data["token"]
|
||||
assert "access_token" in token
|
||||
assert token["token_type"] == "bearer"
|
||||
assert token["expires_in"] > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_invalid_email(
|
||||
self,
|
||||
test_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test login with invalid email."""
|
||||
login_data = {
|
||||
"email": "nonexistent@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json=login_data
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert "Invalid email or password" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_invalid_password(
|
||||
self,
|
||||
test_client: AsyncClient,
|
||||
test_user: User
|
||||
) -> None:
|
||||
"""Test login with invalid password."""
|
||||
login_data = {
|
||||
"email": test_user.email,
|
||||
"password": "wrongpassword"
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json=login_data
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert "Invalid email or password" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_malformed_request(
|
||||
self,
|
||||
test_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test login with malformed request."""
|
||||
login_data = {
|
||||
"email": "invalid-email",
|
||||
"password": "password123"
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json=login_data
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_success(
|
||||
self,
|
||||
test_client: AsyncClient,
|
||||
test_user: User,
|
||||
auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""Test getting current user info successfully."""
|
||||
response = await test_client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check user data
|
||||
assert data["id"] == test_user.id
|
||||
assert data["email"] == test_user.email
|
||||
assert data["name"] == test_user.name
|
||||
assert data["role"] == test_user.role
|
||||
assert data["is_active"] == test_user.is_active
|
||||
assert "plan" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_no_token(
|
||||
self,
|
||||
test_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test getting current user without authentication token."""
|
||||
response = await test_client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 403 # Forbidden (no token provided)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_invalid_token(
|
||||
self,
|
||||
test_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test getting current user with invalid token."""
|
||||
headers = {"Authorization": "Bearer invalid_token"}
|
||||
|
||||
response = await test_client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert "Could not validate credentials" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_expired_token(
|
||||
self,
|
||||
test_client: AsyncClient,
|
||||
test_user: User
|
||||
) -> None:
|
||||
"""Test getting current user with expired token."""
|
||||
from datetime import timedelta
|
||||
from app.utils.auth import JWTUtils
|
||||
|
||||
# Create an expired token (expires immediately)
|
||||
token_data = {
|
||||
"sub": str(test_user.id),
|
||||
"email": test_user.email,
|
||||
"role": test_user.role,
|
||||
}
|
||||
expired_token = JWTUtils.create_access_token(
|
||||
token_data,
|
||||
expires_delta=timedelta(seconds=-1)
|
||||
)
|
||||
|
||||
headers = {"Authorization": f"Bearer {expired_token}"}
|
||||
|
||||
response = await test_client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
# The actual error message comes from the JWT library for expired tokens
|
||||
assert "Token has expired" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout_success(
|
||||
self,
|
||||
test_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test logout endpoint."""
|
||||
response = await test_client.post("/api/v1/auth/logout")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "Successfully logged out" in data["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_access_with_user_role(
|
||||
self,
|
||||
test_client: AsyncClient,
|
||||
auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""Test that regular users cannot access admin endpoints."""
|
||||
# This test would be for admin-only endpoints when they're created
|
||||
# For now, we'll test the dependency behavior
|
||||
from app.core.dependencies import get_admin_user
|
||||
from app.models.user import User
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
|
||||
# Create a mock user with regular role
|
||||
regular_user = User(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
name="Regular User",
|
||||
role="user",
|
||||
is_active=True,
|
||||
plan_id=1,
|
||||
credits=100
|
||||
)
|
||||
|
||||
# Test that get_admin_user raises exception for regular user
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user(regular_user)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Not enough permissions" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_access_with_admin_role(
|
||||
self,
|
||||
test_client: AsyncClient,
|
||||
admin_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""Test that admin users can access admin endpoints."""
|
||||
from app.core.dependencies import get_admin_user
|
||||
from app.models.user import User
|
||||
|
||||
# Create a mock admin user
|
||||
admin_user = User(
|
||||
id=1,
|
||||
email="admin@example.com",
|
||||
name="Admin User",
|
||||
role="admin",
|
||||
is_active=True,
|
||||
plan_id=1,
|
||||
credits=1000
|
||||
)
|
||||
|
||||
# Test that get_admin_user passes for admin user
|
||||
result = await get_admin_user(admin_user)
|
||||
assert result == admin_user
|
||||
212
tests/conftest.py
Normal file
212
tests/conftest.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Test configuration and fixtures."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.main import create_app
|
||||
from app.models.plan import Plan
|
||||
from app.models.user import User
|
||||
from app.utils.auth import JWTUtils, PasswordUtils
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Any:
|
||||
"""Create an instance of the default event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def test_engine() -> Any:
|
||||
"""Create a test database engine."""
|
||||
# Use in-memory SQLite database for tests
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_session(test_engine: Any) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create a test database session."""
|
||||
connection = await test_engine.connect()
|
||||
transaction = await connection.begin()
|
||||
|
||||
session = AsyncSession(bind=connection)
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
await transaction.rollback()
|
||||
await connection.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_app(test_session: AsyncSession) -> FastAPI:
|
||||
"""Create a test FastAPI application."""
|
||||
app = create_app()
|
||||
|
||||
# Override the database dependency
|
||||
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield test_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_client(test_app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create a test HTTP client."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_plan(test_session: AsyncSession) -> Plan:
|
||||
"""Create a test plan."""
|
||||
# Check if plan already exists in this session
|
||||
existing_plan = await test_session.exec(select(Plan).where(Plan.code == "free"))
|
||||
plan = existing_plan.first()
|
||||
|
||||
if not plan:
|
||||
plan = Plan(
|
||||
code="free",
|
||||
name="Free Plan",
|
||||
description="Test free plan",
|
||||
credits=100,
|
||||
max_credits=100,
|
||||
)
|
||||
test_session.add(plan)
|
||||
await test_session.commit()
|
||||
await test_session.refresh(plan)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_pro_plan(test_session: AsyncSession) -> Plan:
|
||||
"""Create a test pro plan."""
|
||||
# Check if plan already exists in this session
|
||||
existing_plan = await test_session.exec(select(Plan).where(Plan.code == "pro"))
|
||||
plan = existing_plan.first()
|
||||
|
||||
if not plan:
|
||||
plan = Plan(
|
||||
code="pro",
|
||||
name="Pro Plan",
|
||||
description="Test pro plan",
|
||||
credits=300,
|
||||
max_credits=300,
|
||||
)
|
||||
test_session.add(plan)
|
||||
await test_session.commit()
|
||||
await test_session.refresh(plan)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_user(test_session: AsyncSession, test_plan: Plan) -> User:
|
||||
"""Create a test user."""
|
||||
user = User(
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
password_hash=PasswordUtils.hash_password("testpassword123"),
|
||||
role="user",
|
||||
is_active=True,
|
||||
plan_id=test_plan.id,
|
||||
credits=100,
|
||||
)
|
||||
test_session.add(user)
|
||||
await test_session.commit()
|
||||
await test_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_user(test_session: AsyncSession, test_plan: Plan) -> User:
|
||||
"""Create a test admin user."""
|
||||
user = User(
|
||||
email="admin@example.com",
|
||||
name="Admin User",
|
||||
password_hash=PasswordUtils.hash_password("adminpassword123"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
plan_id=test_plan.id,
|
||||
credits=1000,
|
||||
)
|
||||
test_session.add(user)
|
||||
await test_session.commit()
|
||||
await test_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_data() -> dict[str, Any]:
|
||||
"""Test user registration data."""
|
||||
return {
|
||||
"email": "newuser@example.com",
|
||||
"password": "newpassword123",
|
||||
"name": "New User",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_login_data() -> dict[str, str]:
|
||||
"""Test user login data."""
|
||||
return {
|
||||
"email": "test@example.com",
|
||||
"password": "testpassword123",
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def auth_headers(test_user: User) -> dict[str, str]:
|
||||
"""Create authentication headers with JWT token."""
|
||||
token_data = {
|
||||
"sub": str(test_user.id),
|
||||
"email": test_user.email,
|
||||
"role": test_user.role,
|
||||
}
|
||||
|
||||
access_token = JWTUtils.create_access_token(token_data)
|
||||
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_headers(admin_user: User) -> dict[str, str]:
|
||||
"""Create admin authentication headers with JWT token."""
|
||||
token_data = {
|
||||
"sub": str(admin_user.id),
|
||||
"email": admin_user.email,
|
||||
"role": admin_user.role,
|
||||
}
|
||||
|
||||
access_token = JWTUtils.create_access_token(token_data)
|
||||
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
1
tests/repositories/__init__.py
Normal file
1
tests/repositories/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Repository tests package."""
|
||||
336
tests/repositories/test_user.py
Normal file
336
tests/repositories/test_user.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Tests for user repository."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlmodel import delete
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.models.plan import Plan
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.utils.auth import PasswordUtils
|
||||
|
||||
|
||||
class TestUserRepository:
|
||||
"""Test user repository operations."""
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def user_repository(
|
||||
self,
|
||||
test_session: AsyncSession,
|
||||
) -> AsyncGenerator[UserRepository, None]: # type: ignore[misc]
|
||||
"""Create a user repository instance."""
|
||||
yield UserRepository(test_session)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_existing(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
test_user: User,
|
||||
) -> None:
|
||||
"""Test getting user by ID when user exists."""
|
||||
assert test_user.id is not None
|
||||
user = await user_repository.get_by_id(test_user.id)
|
||||
|
||||
assert user is not None
|
||||
assert user.id == test_user.id
|
||||
assert user.email == test_user.email
|
||||
assert user.name == test_user.name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_nonexistent(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
) -> None:
|
||||
"""Test getting user by ID when user doesn't exist."""
|
||||
user = await user_repository.get_by_id(99999)
|
||||
|
||||
assert user is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_email_existing(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
test_user: User,
|
||||
) -> None:
|
||||
"""Test getting user by email when user exists."""
|
||||
user = await user_repository.get_by_email(test_user.email)
|
||||
|
||||
assert user is not None
|
||||
assert user.id == test_user.id
|
||||
assert user.email == test_user.email
|
||||
assert user.name == test_user.name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_email_nonexistent(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
) -> None:
|
||||
"""Test getting user by email when user doesn't exist."""
|
||||
user = await user_repository.get_by_email("nonexistent@example.com")
|
||||
|
||||
assert user is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_api_token_existing(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
test_user: User,
|
||||
test_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test getting user by API token when token exists."""
|
||||
# Set an API token for the test user
|
||||
test_token = "test_api_token_123"
|
||||
test_user.api_token = test_token
|
||||
await test_session.commit()
|
||||
|
||||
user = await user_repository.get_by_api_token(test_token)
|
||||
|
||||
assert user is not None
|
||||
assert user.id == test_user.id
|
||||
assert user.api_token == test_token
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_api_token_nonexistent(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
) -> None:
|
||||
"""Test getting user by API token when token doesn't exist."""
|
||||
user = await user_repository.get_by_api_token("nonexistent_token")
|
||||
|
||||
assert user is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
test_plan: Plan,
|
||||
) -> None:
|
||||
"""Test creating a new user."""
|
||||
plan_id = test_plan.id
|
||||
plan_credits = test_plan.credits
|
||||
|
||||
user_data = {
|
||||
"email": "newuser@example.com",
|
||||
"name": "New User",
|
||||
"password_hash": PasswordUtils.hash_password("password123"),
|
||||
"role": "user",
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
user = await user_repository.create(user_data)
|
||||
|
||||
assert user.id is not None
|
||||
assert user.email == user_data["email"]
|
||||
assert user.name == user_data["name"]
|
||||
assert user.role == user_data["role"]
|
||||
assert user.is_active == user_data["is_active"]
|
||||
assert user.plan_id == plan_id
|
||||
assert user.credits == plan_credits
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_without_default_plan(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
test_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test creating user when no default plan exists."""
|
||||
# Remove all plans
|
||||
stmt = delete(Plan)
|
||||
# Use exec for delete statements
|
||||
await test_session.exec(stmt)
|
||||
await test_session.commit()
|
||||
|
||||
user_data = {
|
||||
"email": "newuser@example.com",
|
||||
"name": "New User",
|
||||
"password_hash": PasswordUtils.hash_password("password123"),
|
||||
"role": "user",
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Default plan not found"):
|
||||
await user_repository.create(user_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
test_user: User,
|
||||
) -> None:
|
||||
"""Test updating a user."""
|
||||
UPDATED_CREDITS = 200
|
||||
update_data = {
|
||||
"name": "Updated Name",
|
||||
"credits": UPDATED_CREDITS,
|
||||
}
|
||||
|
||||
updated_user = await user_repository.update(test_user, update_data)
|
||||
|
||||
assert updated_user.id == test_user.id
|
||||
assert updated_user.name == "Updated Name"
|
||||
assert updated_user.credits == UPDATED_CREDITS
|
||||
assert updated_user.email == test_user.email # Unchanged
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
test_plan: Plan, # noqa: ARG002
|
||||
) -> None:
|
||||
"""Test deleting a user."""
|
||||
# Create a user to delete
|
||||
user_data = {
|
||||
"email": "todelete@example.com",
|
||||
"name": "To Delete",
|
||||
"password_hash": PasswordUtils.hash_password("password123"),
|
||||
"role": "user",
|
||||
"is_active": True,
|
||||
}
|
||||
user = await user_repository.create(user_data)
|
||||
assert user.id is not None
|
||||
user_id = user.id
|
||||
|
||||
# Delete the user
|
||||
await user_repository.delete(user)
|
||||
|
||||
# Verify user is deleted
|
||||
deleted_user = await user_repository.get_by_id(user_id)
|
||||
assert deleted_user is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_exists_true(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
test_user: User,
|
||||
) -> None:
|
||||
"""Test email existence check when email exists."""
|
||||
exists = await user_repository.email_exists(test_user.email)
|
||||
|
||||
assert exists is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_exists_false(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
) -> None:
|
||||
"""Test email existence check when email doesn't exist."""
|
||||
exists = await user_repository.email_exists("nonexistent@example.com")
|
||||
|
||||
assert exists is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_exists_case_sensitive(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
test_user: User,
|
||||
) -> None:
|
||||
"""Test email existence check behavior with different cases."""
|
||||
# Test with lowercase email (original)
|
||||
exists_lower = await user_repository.email_exists(test_user.email.lower())
|
||||
|
||||
# SQLite with default collation should be case insensitive for email searches
|
||||
# But the test shows it's case sensitive, so let's test the actual behavior
|
||||
assert exists_lower is True # Original email should exist
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_user_gets_admin_role_and_pro_plan(
|
||||
self,
|
||||
test_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that the first user gets admin role and pro plan."""
|
||||
# Ensure no users exist
|
||||
stmt = delete(User)
|
||||
await test_session.exec(stmt)
|
||||
await test_session.commit()
|
||||
|
||||
# Create plans since they were cleared by other tests
|
||||
free_plan = Plan(code="free", name="Free Plan", credits=100, max_credits=100)
|
||||
pro_plan = Plan(code="pro", name="Pro Plan", credits=300, max_credits=300)
|
||||
test_session.add(free_plan)
|
||||
test_session.add(pro_plan)
|
||||
await test_session.commit()
|
||||
await test_session.refresh(free_plan)
|
||||
await test_session.refresh(pro_plan)
|
||||
|
||||
user_repository = UserRepository(test_session)
|
||||
|
||||
user_data = {
|
||||
"email": "first@example.com",
|
||||
"name": "First User",
|
||||
"password_hash": PasswordUtils.hash_password("password123"),
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
user = await user_repository.create(user_data)
|
||||
|
||||
assert user.id is not None
|
||||
assert user.email == user_data["email"]
|
||||
assert user.name == user_data["name"]
|
||||
assert user.role == "admin" # First user should be admin
|
||||
assert user.is_active == user_data["is_active"]
|
||||
assert user.plan_id == pro_plan.id # Should get pro plan
|
||||
assert user.credits == pro_plan.credits
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subsequent_users_get_user_role_and_free_plan(
|
||||
self,
|
||||
test_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test that subsequent users get user role and free plan."""
|
||||
# Ensure no users exist
|
||||
stmt = delete(User)
|
||||
await test_session.exec(stmt)
|
||||
await test_session.commit()
|
||||
|
||||
# Create plans since they were cleared by other tests
|
||||
free_plan = Plan(code="free", name="Free Plan", credits=100, max_credits=100)
|
||||
pro_plan = Plan(code="pro", name="Pro Plan", credits=300, max_credits=300)
|
||||
test_session.add(free_plan)
|
||||
test_session.add(pro_plan)
|
||||
await test_session.commit()
|
||||
await test_session.refresh(free_plan)
|
||||
await test_session.refresh(pro_plan)
|
||||
|
||||
user_repository = UserRepository(test_session)
|
||||
|
||||
# Create first user
|
||||
first_user_data = {
|
||||
"email": "first@example.com",
|
||||
"name": "First User",
|
||||
"password_hash": PasswordUtils.hash_password("password123"),
|
||||
"is_active": True,
|
||||
}
|
||||
first_user = await user_repository.create(first_user_data)
|
||||
assert first_user.role == "admin" # Verify first user is admin
|
||||
|
||||
# Create second user
|
||||
second_user_data = {
|
||||
"email": "second@example.com",
|
||||
"name": "Second User",
|
||||
"password_hash": PasswordUtils.hash_password("password123"),
|
||||
"is_active": True,
|
||||
}
|
||||
second_user = await user_repository.create(second_user_data)
|
||||
|
||||
assert second_user.id is not None
|
||||
assert second_user.email == second_user_data["email"]
|
||||
assert second_user.name == second_user_data["name"]
|
||||
assert second_user.role == "user" # Second user should be regular user
|
||||
assert second_user.is_active == second_user_data["is_active"]
|
||||
assert second_user.plan_id == free_plan.id # Should get free plan
|
||||
assert second_user.credits == free_plan.credits
|
||||
|
||||
# Create third user to further verify
|
||||
third_user_data = {
|
||||
"email": "third@example.com",
|
||||
"name": "Third User",
|
||||
"password_hash": PasswordUtils.hash_password("password123"),
|
||||
"is_active": True,
|
||||
}
|
||||
third_user = await user_repository.create(third_user_data)
|
||||
|
||||
assert third_user.role == "user" # Third user should also be regular user
|
||||
assert third_user.plan_id == free_plan.id # Should get free plan
|
||||
1
tests/services/__init__.py
Normal file
1
tests/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Services tests package."""
|
||||
225
tests/services/test_auth_service.py
Normal file
225
tests/services/test_auth_service.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Tests for authentication service."""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.models.plan import Plan
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import UserLoginRequest, UserRegisterRequest
|
||||
from app.services.auth import AuthService
|
||||
from app.utils.auth import PasswordUtils
|
||||
|
||||
|
||||
class TestAuthService:
|
||||
"""Test authentication service operations."""
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
def auth_service(self, test_session: AsyncSession) -> AuthService:
|
||||
"""Create an auth service instance."""
|
||||
return AuthService(test_session)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_success(
|
||||
self, auth_service: AuthService, test_plan: Plan, test_user_data: dict[str, str]
|
||||
) -> None:
|
||||
"""Test successful user registration."""
|
||||
request = UserRegisterRequest(**test_user_data)
|
||||
|
||||
response = await auth_service.register(request)
|
||||
|
||||
# Check user data
|
||||
assert response.user.email == test_user_data["email"]
|
||||
assert response.user.name == test_user_data["name"]
|
||||
assert response.user.role == "user"
|
||||
assert response.user.is_active is True
|
||||
assert response.user.credits == test_plan.credits
|
||||
assert response.user.plan["code"] == test_plan.code
|
||||
|
||||
# Check token
|
||||
assert response.token.access_token is not None
|
||||
assert response.token.token_type == "bearer"
|
||||
assert response.token.expires_in > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate_email(
|
||||
self, auth_service: AuthService, test_user: User
|
||||
) -> None:
|
||||
"""Test registration with duplicate email."""
|
||||
request = UserRegisterRequest(
|
||||
email=test_user.email, password="password123", name="Another User"
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await auth_service.register(request)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "Email address is already registered" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(
|
||||
self,
|
||||
auth_service: AuthService,
|
||||
test_user: User,
|
||||
test_login_data: dict[str, str],
|
||||
) -> None:
|
||||
"""Test successful user login."""
|
||||
request = UserLoginRequest(**test_login_data)
|
||||
|
||||
response = await auth_service.login(request)
|
||||
|
||||
# Check user data
|
||||
assert response.user.id == test_user.id
|
||||
assert response.user.email == test_user.email
|
||||
assert response.user.name == test_user.name
|
||||
assert response.user.role == test_user.role
|
||||
assert response.user.is_active == test_user.is_active
|
||||
|
||||
# Check token
|
||||
assert response.token.access_token is not None
|
||||
assert response.token.token_type == "bearer"
|
||||
assert response.token.expires_in > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_invalid_email(self, auth_service: AuthService) -> None:
|
||||
"""Test login with invalid email."""
|
||||
request = UserLoginRequest(
|
||||
email="nonexistent@example.com", password="password123"
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await auth_service.login(request)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid email or password" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_invalid_password(
|
||||
self, auth_service: AuthService, test_user: User
|
||||
) -> None:
|
||||
"""Test login with invalid password."""
|
||||
request = UserLoginRequest(email=test_user.email, password="wrongpassword")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await auth_service.login(request)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid email or password" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_inactive_user(
|
||||
self, auth_service: AuthService, test_user: User, test_session: AsyncSession
|
||||
) -> None:
|
||||
"""Test login with inactive user."""
|
||||
# Store the email before deactivating
|
||||
user_email = test_user.email
|
||||
|
||||
# Deactivate the user
|
||||
test_user.is_active = False
|
||||
await test_session.commit()
|
||||
|
||||
request = UserLoginRequest(email=user_email, password="testpassword123")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await auth_service.login(request)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Account is deactivated" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_user_without_password(
|
||||
self, auth_service: AuthService, test_user: User, test_session: AsyncSession
|
||||
) -> None:
|
||||
"""Test login with user that has no password hash."""
|
||||
# Store the email before removing password
|
||||
user_email = test_user.email
|
||||
|
||||
# Remove password hash
|
||||
test_user.password_hash = None
|
||||
await test_session.commit()
|
||||
|
||||
request = UserLoginRequest(email=user_email, password="anypassword")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await auth_service.login(request)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Invalid email or password" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_success(
|
||||
self, auth_service: AuthService, test_user: User
|
||||
) -> None:
|
||||
"""Test getting current user successfully."""
|
||||
user = await auth_service.get_current_user(test_user.id)
|
||||
|
||||
assert user.id == test_user.id
|
||||
assert user.email == test_user.email
|
||||
assert user.name == test_user.name
|
||||
assert user.is_active == test_user.is_active
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_not_found(self, auth_service: AuthService) -> None:
|
||||
"""Test getting current user when user doesn't exist."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await auth_service.get_current_user(99999)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "User not found" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_inactive(
|
||||
self, auth_service: AuthService, test_user: User, test_session: AsyncSession
|
||||
) -> None:
|
||||
"""Test getting current user when user is inactive."""
|
||||
# Store the user ID before deactivating
|
||||
user_id = test_user.id
|
||||
|
||||
# Deactivate the user
|
||||
test_user.is_active = False
|
||||
await test_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await auth_service.get_current_user(user_id)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Account is deactivated" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_access_token(
|
||||
self, auth_service: AuthService, test_user: User
|
||||
) -> None:
|
||||
"""Test access token creation."""
|
||||
token_response = auth_service._create_access_token(test_user)
|
||||
|
||||
assert token_response.access_token is not None
|
||||
assert token_response.token_type == "bearer"
|
||||
assert token_response.expires_in > 0
|
||||
|
||||
# Verify token contains correct data
|
||||
from app.utils.auth import JWTUtils
|
||||
|
||||
decoded = JWTUtils.decode_access_token(token_response.access_token)
|
||||
assert decoded["sub"] == str(test_user.id)
|
||||
assert decoded["email"] == test_user.email
|
||||
assert decoded["role"] == test_user.role
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_response(
|
||||
self, auth_service: AuthService, test_user: User, test_session: AsyncSession
|
||||
) -> None:
|
||||
"""Test user response creation."""
|
||||
# Ensure plan relationship is loaded
|
||||
await test_session.refresh(test_user, ["plan"])
|
||||
|
||||
user_response = await auth_service._create_user_response(test_user)
|
||||
|
||||
assert user_response.id == test_user.id
|
||||
assert user_response.email == test_user.email
|
||||
assert user_response.name == test_user.name
|
||||
assert user_response.role == test_user.role
|
||||
assert user_response.credits == test_user.credits
|
||||
assert user_response.is_active == test_user.is_active
|
||||
assert user_response.plan["id"] == test_user.plan.id
|
||||
assert user_response.plan["code"] == test_user.plan.code
|
||||
1
tests/utils/__init__.py
Normal file
1
tests/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utils tests package."""
|
||||
182
tests/utils/test_auth_utils.py
Normal file
182
tests/utils/test_auth_utils.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Tests for authentication utilities."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.utils.auth import JWTUtils, PasswordUtils, TokenUtils
|
||||
|
||||
PASSWORD_HASH_LENGTH_GT = 50
|
||||
TOKEN_LENGTH_GT = 20
|
||||
TOKEN_DOTS_COUNT = 2
|
||||
STATUS_CODE_UNAUTHORIZED = 401
|
||||
|
||||
|
||||
class TestPasswordUtils:
|
||||
"""Test password utility functions."""
|
||||
|
||||
def test_hash_password(self) -> None:
|
||||
"""Test password hashing."""
|
||||
password = "testpassword123"
|
||||
hashed = PasswordUtils.hash_password(password)
|
||||
|
||||
# Hash should be different from original password
|
||||
assert hashed != password
|
||||
# Hash should be a string
|
||||
assert isinstance(hashed, str)
|
||||
# Hash should have reasonable length (bcrypt produces ~60 chars)
|
||||
assert len(hashed) > PASSWORD_HASH_LENGTH_GT
|
||||
|
||||
def test_hash_password_different_salts(self) -> None:
|
||||
"""Test that same password produces different hashes (different salts)."""
|
||||
password = "testpassword123"
|
||||
hash1 = PasswordUtils.hash_password(password)
|
||||
hash2 = PasswordUtils.hash_password(password)
|
||||
|
||||
# Same password should produce different hashes due to different salts
|
||||
assert hash1 != hash2
|
||||
|
||||
def test_verify_password_correct(self) -> None:
|
||||
"""Test password verification with correct password."""
|
||||
password = "testpassword123"
|
||||
hashed = PasswordUtils.hash_password(password)
|
||||
|
||||
# Correct password should verify
|
||||
assert PasswordUtils.verify_password(password, hashed) is True
|
||||
|
||||
def test_verify_password_incorrect(self) -> None:
|
||||
"""Test password verification with incorrect password."""
|
||||
password = "testpassword123"
|
||||
wrong_password = "wrongpassword"
|
||||
hashed = PasswordUtils.hash_password(password)
|
||||
|
||||
# Wrong password should not verify
|
||||
assert PasswordUtils.verify_password(wrong_password, hashed) is False
|
||||
|
||||
def test_verify_password_empty(self) -> None:
|
||||
"""Test password verification with empty password."""
|
||||
password = "testpassword123"
|
||||
hashed = PasswordUtils.hash_password(password)
|
||||
|
||||
# Empty password should not verify
|
||||
assert PasswordUtils.verify_password("", hashed) is False
|
||||
|
||||
|
||||
class TestJWTUtils:
|
||||
"""Test JWT utility functions."""
|
||||
|
||||
def test_create_access_token(self) -> None:
|
||||
"""Test JWT token creation."""
|
||||
data = {"sub": "123", "email": "test@example.com"}
|
||||
token = JWTUtils.create_access_token(data)
|
||||
|
||||
# Token should be a string
|
||||
assert isinstance(token, str)
|
||||
# Token should have reasonable length
|
||||
assert len(token) > PASSWORD_HASH_LENGTH_GT
|
||||
# Token should contain dots (JWT format)
|
||||
assert token.count(".") == TOKEN_DOTS_COUNT
|
||||
|
||||
def test_create_access_token_with_expiry(self) -> None:
|
||||
"""Test JWT token creation with custom expiry."""
|
||||
data = {"sub": "123", "email": "test@example.com"}
|
||||
expires_delta = timedelta(minutes=5)
|
||||
token = JWTUtils.create_access_token(data, expires_delta)
|
||||
|
||||
# Should create a valid token
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > PASSWORD_HASH_LENGTH_GT
|
||||
|
||||
def test_decode_access_token(self) -> None:
|
||||
"""Test JWT token decoding."""
|
||||
original_data = {"sub": "123", "email": "test@example.com", "role": "user"}
|
||||
token = JWTUtils.create_access_token(original_data)
|
||||
|
||||
decoded_data = JWTUtils.decode_access_token(token)
|
||||
|
||||
# Should decode to original data (plus exp)
|
||||
assert decoded_data["sub"] == original_data["sub"]
|
||||
assert decoded_data["email"] == original_data["email"]
|
||||
assert decoded_data["role"] == original_data["role"]
|
||||
assert "exp" in decoded_data
|
||||
|
||||
def test_decode_invalid_token(self) -> None:
|
||||
"""Test decoding invalid JWT token."""
|
||||
invalid_token = "invalid.token.here"
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
JWTUtils.decode_access_token(invalid_token)
|
||||
|
||||
assert exc_info.value.status_code == STATUS_CODE_UNAUTHORIZED
|
||||
assert "Could not validate credentials" in exc_info.value.detail
|
||||
|
||||
def test_decode_expired_token(self) -> None:
|
||||
"""Test decoding expired JWT token."""
|
||||
data = {"sub": "123", "email": "test@example.com"}
|
||||
# Create token that expires immediately
|
||||
expires_delta = timedelta(seconds=-1)
|
||||
token = JWTUtils.create_access_token(data, expires_delta)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
JWTUtils.decode_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == STATUS_CODE_UNAUTHORIZED
|
||||
assert "Token has expired" in exc_info.value.detail
|
||||
|
||||
def test_decode_empty_token(self) -> None:
|
||||
"""Test decoding empty token."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
JWTUtils.decode_access_token("")
|
||||
|
||||
assert exc_info.value.status_code == STATUS_CODE_UNAUTHORIZED
|
||||
|
||||
|
||||
class TestTokenUtils:
|
||||
"""Test token utility functions."""
|
||||
|
||||
def test_generate_api_token(self) -> None:
|
||||
"""Test API token generation."""
|
||||
token = TokenUtils.generate_api_token()
|
||||
|
||||
# Token should be a string
|
||||
assert isinstance(token, str)
|
||||
# Token should have reasonable length
|
||||
assert len(token) > TOKEN_LENGTH_GT
|
||||
# Token should be URL-safe
|
||||
assert all(c.isalnum() or c in "-_" for c in token)
|
||||
|
||||
def test_generate_api_token_unique(self) -> None:
|
||||
"""Test that API tokens are unique."""
|
||||
token1 = TokenUtils.generate_api_token()
|
||||
token2 = TokenUtils.generate_api_token()
|
||||
|
||||
# Tokens should be different
|
||||
assert token1 != token2
|
||||
|
||||
def test_is_token_expired_none(self) -> None:
|
||||
"""Test token expiry check with None."""
|
||||
# None expiry should not be expired
|
||||
assert TokenUtils.is_token_expired(None) is False
|
||||
|
||||
def test_is_token_expired_future(self) -> None:
|
||||
"""Test token expiry check with future date."""
|
||||
future_date = datetime.now(UTC) + timedelta(hours=1)
|
||||
|
||||
# Future date should not be expired
|
||||
assert TokenUtils.is_token_expired(future_date) is False
|
||||
|
||||
def test_is_token_expired_past(self) -> None:
|
||||
"""Test token expiry check with past date."""
|
||||
past_date = datetime.now(UTC) - timedelta(hours=1)
|
||||
|
||||
# Past date should be expired
|
||||
assert TokenUtils.is_token_expired(past_date) is True
|
||||
|
||||
def test_is_token_expired_naive_datetime(self) -> None:
|
||||
"""Test token expiry check with naive datetime."""
|
||||
# Create a past naive datetime (using UTC time to be consistent)
|
||||
past_date = datetime.now(UTC) - timedelta(hours=1)
|
||||
|
||||
# Should handle naive datetime (treat as UTC)
|
||||
assert TokenUtils.is_token_expired(past_date) is True
|
||||
85
uv.lock
generated
85
uv.lock
generated
@@ -43,8 +43,12 @@ version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "sqlmodel" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
@@ -52,17 +56,22 @@ dependencies = [
|
||||
dev = [
|
||||
{ name = "coverage" },
|
||||
{ name = "faker" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
{ name = "sqlmodel" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", specifier = "==0.21.0" },
|
||||
{ name = "bcrypt", specifier = "==4.3.0" },
|
||||
{ name = "email-validator", specifier = "==2.2.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = "==0.116.1" },
|
||||
{ name = "pydantic-settings", specifier = "==2.10.1" },
|
||||
{ name = "pyjwt", specifier = "==2.10.1" },
|
||||
{ name = "sqlmodel", specifier = "==0.0.24" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
|
||||
]
|
||||
|
||||
@@ -70,10 +79,61 @@ requires-dist = [
|
||||
dev = [
|
||||
{ name = "coverage", specifier = "==7.9.2" },
|
||||
{ name = "faker", specifier = "==37.4.2" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "mypy", specifier = "==1.17.0" },
|
||||
{ name = "pytest", specifier = "==8.4.1" },
|
||||
{ name = "pytest-asyncio", specifier = "==1.1.0" },
|
||||
{ name = "ruff", specifier = "==0.12.4" },
|
||||
{ name = "sqlmodel", specifier = "==0.0.24" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792 },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -572,6 +632,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.1"
|
||||
@@ -588,6 +657,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.1"
|
||||
|
||||
Reference in New Issue
Block a user