import pytest import asyncio import uuid import time # Added for performance tests from datetime import datetime, timedelta, timezone from typing import AsyncGenerator # Added to fix NameError from unittest.mock import AsyncMock, MagicMock, patch from fastapi.testclient import TestClient from fastapi import status from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlalchemy import text import redis.asyncio as redis # Import the authentication service modules from app.main import app from app.models.users import User, RefreshToken from app.schemas.auth import UserRegistration, UserLogin, TokenResponse from app.services.auth_service import AuthService from app.core.security import SecurityManager from app.core.config import settings from app.core.database import get_db from shared.database.base import Base # ================================================================ # TEST CONFIGURATION AND FIXTURES # ================================================================ # Test database configuration - Use in-memory SQLite for speed TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @pytest.fixture(scope="function") async def test_engine(): """Create a test database engine for each test function""" engine = create_async_engine( TEST_DATABASE_URL, echo=False, # Set to True for SQL debugging future=True, pool_pre_ping=True ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield engine async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await engine.dispose() @pytest.fixture(scope="function") async def test_db(test_engine) -> AsyncGenerator[AsyncSession, None]: """Create a test database session for each test function""" async_session = sessionmaker( test_engine, class_=AsyncSession, expire_on_commit=False ) async with async_session() as session: yield session await session.rollback() # Rollback after each test to ensure a clean state @pytest.fixture(scope="function") def client(test_db): """Create a test client with database dependency override""" try: from app.main import app from app.core.database import get_db def override_get_db(): # test_db is already an AsyncSession yielded by the fixture yield test_db app.dependency_overrides[get_db] = override_get_db with TestClient(app) as test_client: yield test_client # Clean up overrides app.dependency_overrides.clear() except ImportError as e: pytest.skip(f"Cannot import app modules: {e}. Ensure app.main and app.core.database are accessible.") @pytest.fixture(scope="function") async def test_user(test_db): """Create a test user in the database""" user_data = { "email": "existing@bakery.es", "password": "TestPassword123!", "full_name": "Existing User" } user = await AuthService.create_user( email=user_data["email"], password=user_data["password"], full_name=user_data["full_name"], db=test_db ) return user @pytest.fixture(scope="function") async def test_redis_client(): """Create a test Redis client""" mock_redis = AsyncMock(spec=redis.Redis) yield mock_redis await mock_redis.close() # ================================================================\ # HELPER FUNCTIONS # ================================================================\ # Moved generate_random_user_data to conftest.py as it's a shared helper. # It's imported implicitly via conftest.py if setup correctly. # If not, add explicit import: from conftest import generate_random_user_data async def register_and_login_user(client: TestClient): """Helper to register and login a user, returning user data and tokens.""" user_data = generate_random_user_data() # Ensure this function is accessible # Register register_response = client.post("/auth/register", json=user_data) assert register_response.status_code == status.HTTP_200_OK # Login login_data = { "email": user_data["email"], "password": user_data["password"] } login_response = client.post("/auth/login", json=login_data) assert login_response.status_code == status.HTTP_200_OK return { "user_data": user_data, # Return original user_data for password access "user_response": register_response.json(), # Return user info from register "tokens": login_response.json(), "headers": {"Authorization": f"Bearer {login_response.json()['access_token']}"} } # ================================================================\ # TEST SUITE # ================================================================\ class TestAuthService: """Comprehensive unit tests for AuthService""" @pytest.mark.asyncio async def test_create_user_success(self, test_db): """Test successful user creation""" user = await AuthService.create_user( "test_new@bakery.es", "Password123!", "New User", test_db ) assert user is not None assert user.email == "test_new@bakery.es" assert SecurityManager.verify_password("Password123!", user.hashed_password) assert user.is_active is True @pytest.mark.asyncio async def test_create_user_duplicate_email(self, test_user, test_db): """Test creating a user with a duplicate email""" # test_user is already awaited by pytest-asyncio with pytest.raises(ValueError, match="Email already registered"): await AuthService.create_user( test_user.email, "AnotherPassword!", "Duplicate User", test_db ) @pytest.mark.asyncio async def test_authenticate_user_success(self, test_user, test_db): """Test successful user authentication""" # test_user is already awaited by pytest-asyncio authenticated_user = await AuthService.authenticate_user( test_user.email, "TestPassword123!", test_db ) assert authenticated_user is not None assert authenticated_user.id == test_user.id assert authenticated_user.email == test_user.email @pytest.mark.asyncio async def test_authenticate_user_wrong_password(self, test_user, test_db): """Test authentication with wrong password""" # test_user is already awaited by pytest-asyncio authenticated_user = await AuthService.authenticate_user( test_user.email, "WrongPassword", test_db ) assert authenticated_user is None @pytest.mark.asyncio async def test_authenticate_user_nonexistent(self, test_db): """Test authentication of a nonexistent user""" authenticated_user = await AuthService.authenticate_user( "nonexistent@bakery.es", "AnyPassword", test_db ) assert authenticated_user is None @pytest.mark.asyncio async def test_authenticate_user_inactive(self, test_user, test_db): """Test authentication of an inactive user""" # test_user is already awaited by pytest-asyncio test_user.is_active = False await test_db.commit() # Commit the change to the database authenticated_user = await AuthService.authenticate_user( test_user.email, "TestPassword123!", test_db ) assert authenticated_user is None test_user.is_active = True # Reset for other tests if needed await test_db.commit() # Commit the reset @pytest.mark.asyncio async def test_login_user_success(self, test_user, test_db): """Test successful user login, including token generation""" tokens = await AuthService.login_user( # Assuming AuthService.login_user is a class method test_user.email, "TestPassword123!", test_db ) assert tokens.access_token is not None assert tokens.refresh_token is not None # Verify access token claims decoded_access_token = SecurityManager.verify_token(tokens.access_token) assert decoded_access_token["sub"] == str(test_user.id) assert decoded_access_token["email"] == test_user.email assert decoded_access_token["full_name"] == test_user.full_name # Verify refresh token is stored (mocked or actual db check) refresh_token_db = await test_db.execute( text("SELECT * FROM refresh_tokens WHERE user_id = :user_id"), {"user_id": test_user.id} ) refresh_token_db_obj = refresh_token_db.scalar_one_or_none() assert refresh_token_db_obj is not None assert SecurityManager.verify_password(tokens.refresh_token, refresh_token_db_obj.hashed_token) @pytest.mark.asyncio async def test_login_user_invalid_credentials(self, test_db): """Test login with invalid credentials""" with pytest.raises(ValueError, match="Invalid credentials"): await AuthService.login_user("nonexistent@bakery.es", "WrongPassword", test_db) # Assuming AuthService.login_user is a class method @pytest.mark.asyncio async def test_refresh_access_token_success(self, test_user, test_db): """Test successful access token refresh""" initial_tokens = await AuthService.login_user(test_user.email, "TestPassword123!", test_db) # Assuming AuthService.login_user is a class method new_tokens = await AuthService.refresh_access_token(initial_tokens.refresh_token, test_db) # Assuming AuthService.refresh_access_token is a class method assert new_tokens.access_token is not None assert new_tokens.refresh_token == initial_tokens.refresh_token # Refresh token typically remains the same decoded_access_token = SecurityManager.verify_token(new_tokens.access_token) assert decoded_access_token["sub"] == str(test_user.id) assert decoded_access_token["email"] == test_user.email @pytest.mark.asyncio async def test_refresh_access_token_invalid(self, test_db): """Test refresh with an invalid refresh token""" with pytest.raises(ValueError, match="Invalid refresh token"): await AuthService.refresh_access_token("invalid_refresh_token", test_db) # Assuming AuthService.refresh_access_token is a class method @pytest.mark.asyncio async def test_logout_user_success(self, test_user, test_db): """Test successful user logout""" initial_tokens = await AuthService.login_user(test_user.email, "TestPassword123!", test_db) # Assuming AuthService.login_user is a class method assert await AuthService.logout_user(initial_tokens.refresh_token, test_db) is True # Assuming AuthService.logout_user is a class method refresh_token_db = await test_db.execute( text("SELECT * FROM refresh_tokens WHERE user_id = :user_id"), {"user_id": test_user.id} ) assert refresh_token_db.scalar_one_or_none() is None # Token should be deleted @pytest.mark.asyncio async def test_logout_user_invalid(self, test_db): """Test logout with an invalid refresh token""" with pytest.raises(ValueError, match="Invalid refresh token"): await AuthService.logout_user("nonexistent_refresh_token", test_db) # Assuming AuthService.logout_user is a class method class TestAuthenticationAPI: """Integration tests for authentication API endpoints""" @pytest.mark.api async def test_register_success(self, client): """Test successful user registration via API""" user_data = generate_random_user_data() response = client.post("/auth/register", json=user_data) assert response.status_code == status.HTTP_200_OK assert "message" in response.json() assert "User registered successfully" in response.json()["message"] # Optionally, verify user exists in DB @pytest.mark.api async def test_register_weak_password(self, client): """Test registration with a weak password""" user_data = generate_random_user_data() user_data["password"] = "short" # Weak password response = client.post("/auth/register", json=user_data) assert response.status_code == status.HTTP_400_BAD_REQUEST assert "password" in response.json()["detail"].lower() @pytest.mark.api async def test_register_invalid_email(self, client): """Test registration with an invalid email format""" user_data = generate_random_user_data() user_data["email"] = "invalid-email" response = client.post("/auth/register", json=user_data) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert "email" in response.json()["detail"][0]["loc"] @pytest.mark.api async def test_register_missing_fields(self, client): """Test registration with missing required fields""" user_data = {"email": "test@bakery.es", "password": "TestPassword123!"} # Missing full_name response = client.post("/auth/register", json=user_data) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert "full_name" in response.json()["detail"][0]["loc"] @pytest.mark.api async def test_register_duplicate_email(self, client): """Test registration with an email that is already registered""" user_data = generate_random_user_data() client.post("/auth/register", json=user_data) # First registration response = client.post("/auth/register", json=user_data) # Duplicate registration assert response.status_code == status.HTTP_400_BAD_REQUEST assert "Email already registered" in response.json()["detail"] @pytest.mark.api async def test_login_success(self, client): """Test successful user login via API""" user_data = generate_random_user_data() client.post("/auth/register", json=user_data) # Register first login_response = client.post("/auth/login", json={ "email": user_data["email"], "password": user_data["password"] }) assert login_response.status_code == status.HTTP_200_OK assert "access_token" in login_response.json() assert "refresh_token" in login_response.json() @pytest.mark.api async def test_login_wrong_password(self, client): """Test login with wrong password via API""" user_data = generate_random_user_data() client.post("/auth/register", json=user_data) login_response = client.post("/auth/login", json={ "email": user_data["email"], "password": "WrongPassword" }) assert login_response.status_code == status.HTTP_401_UNAUTHORIZED assert "Invalid credentials" in login_response.json()["detail"] @pytest.mark.api async def test_login_nonexistent_user(self, client): """Test login for a nonexistent user via API""" login_response = client.post("/auth/login", json={ "email": "nonexistent@bakery.es", "password": "AnyPassword123!" }) assert login_response.status_code == status.HTTP_401_UNAUTHORIZED assert "Invalid credentials" in login_response.json()["detail"] @pytest.mark.api async def test_login_missing_fields(self, client): """Test login with missing fields""" response = client.post("/auth/login", json={"email": "test@bakery.es"}) # Missing password assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert "password" in response.json()["detail"][0]["loc"] @pytest.mark.api async def test_login_invalid_email_format(self, client): """Test login with invalid email format""" user_data = generate_random_user_data() client.post("/auth/register", json=user_data) response = client.post("/auth/login", json={ "email": "invalid-email-format", "password": user_data["password"] }) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert "email" in response.json()["detail"][0]["loc"] @pytest.mark.api async def test_token_refresh(self, client): """Test refreshing an access token via API""" auth_data = await register_and_login_user(client) refresh_token = auth_data["tokens"]["refresh_token"] refresh_response = client.post("/auth/refresh", json={"refresh_token": refresh_token}) assert refresh_response.status_code == status.HTTP_200_OK assert "access_token" in refresh_response.json() assert "refresh_token" in refresh_response.json() # Refresh token usually doesn't change on refresh assert refresh_response.json()["refresh_token"] == refresh_token @pytest.mark.api async def test_logout(self, client): """Test user logout via API""" auth_data = await register_and_login_user(client) refresh_token = auth_data["tokens"]["refresh_token"] logout_response = client.post("/auth/logout", json={"refresh_token": refresh_token}) assert logout_response.status_code == status.HTTP_200_OK assert "message" in logout_response.json() assert "User logged out successfully" in logout_response.json()["message"] # Attempt to refresh with the logged-out token refresh_response_after_logout = client.post("/auth/refresh", json={"refresh_token": refresh_token}) assert refresh_response_after_logout.status_code == status.HTTP_401_UNAUTHORIZED assert "Invalid refresh token" in refresh_response_after_logout.json()["detail"] class TestAuthenticationFlow: """End-to-end tests for complete authentication flows""" @pytest.mark.integration async def test_complete_registration_login_flow(self, client): """Test a complete flow from registration to login""" user_data = generate_random_user_data() # 1. Register register_response = client.post("/auth/register", json=user_data) assert register_response.status_code == status.HTTP_200_OK assert "User registered successfully" in register_response.json()["message"] # 2. Login login_data = {"email": user_data["email"], "password": user_data["password"]} login_response = client.post("/auth/login", json=login_data) assert login_response.status_code == status.HTTP_200_OK assert "access_token" in login_response.json() assert "refresh_token" in login_response.json() access_token = login_response.json()["access_token"] refresh_token = login_response.json()["refresh_token"] # 3. Access protected endpoint (e.g., /users/me) me_response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) assert me_response.status_code == status.HTTP_200_OK assert me_response.json()["email"] == user_data["email"] # 4. Refresh token refresh_response = client.post("/auth/refresh", json={"refresh_token": refresh_token}) assert refresh_response.status_code == status.HTTP_200_OK new_access_token = refresh_response.json()["access_token"] assert new_access_token != access_token # New token should be different # 5. Access protected endpoint with new token me_response_new = client.get("/users/me", headers={"Authorization": f"Bearer {new_access_token}"}) assert me_response_new.status_code == status.HTTP_200_OK @pytest.mark.integration async def test_token_refresh_flow(self, client): """Test the entire token refresh process""" auth_data = await register_and_login_user(client) initial_access_token = auth_data["tokens"]["access_token"] refresh_token = auth_data["tokens"]["refresh_token"] # Simulate time passing to make access token potentially expire (optional, depends on token short expiry) # For actual testing, you might use a mocked time or shorter token expiry in test config # Try to refresh refresh_response = client.post("/auth/refresh", json={"refresh_token": refresh_token}) assert refresh_response.status_code == status.HTTP_200_OK new_access_token = refresh_response.json()["access_token"] assert new_access_token is not None assert new_access_token != initial_access_token # Verify old access token is invalid and new one is valid old_token_access = client.get("/users/me", headers={"Authorization": f"Bearer {initial_access_token}"}) # This might be 401 if access token expired, or 200 if it hasn't, depending on validity period # For a robust test, you'd make access tokens very short lived for this test # assert old_token_access.status_code == status.HTTP_401_UNAUTHORIZED # If expired new_token_access = client.get("/users/me", headers={"Authorization": f"Bearer {new_access_token}"}) assert new_token_access.status_code == status.HTTP_200_OK assert "email" in new_token_access.json() @pytest.mark.integration async def test_logout_flow(self, client): """Test the logout process invalidating tokens""" auth_data = await register_and_login_user(client) access_token = auth_data["tokens"]["access_token"] refresh_token = auth_data["tokens"]["refresh_token"] # Access protected endpoint before logout me_response_before = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) assert me_response_before.status_code == status.HTTP_200_OK # Logout logout_response = client.post("/auth/logout", json={"refresh_token": refresh_token}) assert logout_response.status_code == status.HTTP_200_OK # Try to access protected endpoint with the (now invalidated) access token me_response_after = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) assert me_response_after.status_code == status.HTTP_401_UNAUTHORIZED # Try to refresh with the (now invalidated) refresh token refresh_response_after = client.post("/auth/refresh", json={"refresh_token": refresh_token}) assert refresh_response_after.status_code == status.HTTP_401_UNAUTHORIZED class TestErrorHandling: """Test error handling scenarios""" @pytest.mark.api async def test_database_error_during_registration(self, client): """Test handling of database errors during registration API call""" user_data = generate_random_user_data() # Patch get_db to return a mock session that raises an error on commit mock_db = AsyncMock(spec=AsyncSession) mock_db.commit.side_effect = Exception("Simulated DB commit error") mock_db.add.return_value = None mock_db.refresh.return_value = None mock_db.rollback.return_value = None # Ensure rollback is mocked as well with patch('app.core.database.get_db', autospec=True) as mock_get_db: # Use a mock for the database session yielded by get_db mock_get_db.return_value.__aenter__.return_value = mock_db mock_get_db.return_value.__aexit__.return_value = False # Don't suppress exceptions response = client.post("/auth/register", json=user_data) assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert "detail" in response.json() assert "database error" in response.json()["detail"].lower() @pytest.mark.api async def test_database_error_during_authentication(self, client): """Test handling of database errors during authentication API call""" # First, register a user normally so AuthService has something to authenticate against user_data = generate_random_user_data() register_response = client.post("/auth/register", json=user_data) assert register_response.status_code == status.HTTP_200_OK mock_db = AsyncMock(spec=AsyncSession) # Mocking execute and scalar_one_or_none for user lookup mock_execute_result = AsyncMock() mock_execute_result.scalar_one_or_none.side_effect = Exception("Simulated DB scalar error") mock_db.execute.return_value = mock_execute_result mock_db.rollback.return_value = None # Ensure rollback is mocked with patch('app.core.database.get_db', autospec=True) as mock_get_db: # Use a mock for the database session yielded by get_db mock_get_db.return_value.__aenter__.return_value = mock_db mock_get_db.return_value.__aexit__.return_value = False # Don't suppress exceptions response = client.post("/auth/login", json={ "email": user_data["email"], "password": user_data["password"] }) assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert "detail" in response.json() assert "database error" in response.json()["detail"].lower() @pytest.mark.api async def test_malformed_json_request(self, client): """Test API handling of malformed JSON requests""" response = client.post("/auth/register", content="this is not json", headers={"Content-Type": "application/json"}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert "detail" in response.json() @pytest.mark.api async def test_empty_request_body(self, client): """Test API handling of empty request body for POST""" response = client.post("/auth/register", json={}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert "detail" in response.json() assert any("field required" in err["msg"] for err in response.json()["detail"]) class TestSecurity: """Tests for security aspects like password hashing, email validation, etc.""" @pytest.mark.security async def test_password_hashing_verification(self): """Test password hashing and verification""" password = "MyStrongPassword123!" hashed_password = SecurityManager.hash_password(password) assert hashed_password != password # Should not be plain text assert SecurityManager.verify_password(password, hashed_password) assert not SecurityManager.verify_password("WrongPassword", hashed_password) @pytest.mark.security async def test_password_requirements_enforced(self, client): """Test that password requirements are enforced by the API""" common_user_data = generate_random_user_data() test_cases = [ ("Too short", "short", status.HTTP_400_BAD_REQUEST), ("No uppercase", "nouppercase123!", status.HTTP_400_BAD_REQUEST), ("No lowercase", "NOUPPERCASE123!", status.HTTP_400_BAD_REQUEST), ("No digit", "NoDigit!", status.HTTP_400_BAD_REQUEST), ("No special char", "NoSpecialChar123", status.HTTP_400_BAD_REQUEST), ("Valid password", "StrongPwd123!", status.HTTP_200_OK), ] for desc, pwd, expected_status in test_cases: user_data = common_user_data.copy() user_data["password"] = pwd response = client.post("/auth/register", json=user_data) assert response.status_code == expected_status, f"Failed for {desc} (password: {pwd})" if expected_status != status.HTTP_200_OK: assert "password" in response.json()["detail"].lower() @pytest.mark.security async def test_email_validation(self, client): """Test that email validation is properly performed by the API""" common_user_data = generate_random_user_data() test_cases = [ ("valid@email.com", True, status.HTTP_200_OK), ("user.name+tag@domain.co.uk", True, status.HTTP_200_OK), # Valid special characters ("invalid-email", False, status.HTTP_422_UNPROCESSABLE_ENTITY), ("user@.com", False, status.HTTP_422_UNPROCESSABLE_ENTITY), ("@domain.com", False, status.HTTP_422_UNPROCESSABLE_ENTITY), ("user@domain", False, status.HTTP_422_UNPROCESSABLE_ENTITY), ("user@domain..com", False, status.HTTP_422_UNPROCESSABLE_ENTITY), ] for email, is_valid, expected_status in test_cases: user_data = common_user_data.copy() user_data["email"] = email response = client.post("/auth/register", json=user_data) assert response.status_code == expected_status, f"Failed for email: {email}" if is_valid: assert response.json()["email"] == email else: assert "email" in response.json()["detail"][0]["loc"] @pytest.mark.security async def test_sql_injection_prevention(self, client, test_db): """Test API's resistance to SQL injection attempts""" malicious_email = "test@bakery.es' OR 1=1; --" user_data = generate_random_user_data() user_data["email"] = malicious_email response = client.post("/auth/register", json=user_data) # We expect a validation error (422) or bad request (400) not a successful registration assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY] if response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY: assert "email" in response.json()["detail"][0]["loc"] # Pydantic validation error else: # e.g. status.HTTP_400_BAD_REQUEST from custom validation assert "invalid" in response.json()["detail"].lower() or "malformed" in response.json()["detail"].lower() # Try with login response = client.post("/auth/login", json={ "email": malicious_email, "password": "AnyPassword123!" }) assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY, status.HTTP_401_UNAUTHORIZED] class TestRateLimiting: """Test rate limiting functionality (if implemented)""" # Requires an actual rate-limiting implementation in the FastAPI app # This mock is for demonstration and assumes a simple per-IP rate limit # FastAPI-Limiter or similar would be needed for a real implementation @pytest.mark.api @pytest.mark.slow # This test can be slow due to potential sleeps or multiple requests async def test_multiple_registration_attempts(self, client): """Test rate limiting on registration endpoint""" # This test needs actual rate limiting middleware in the app for a meaningful result. # Without it, it will just succeed for all requests. # Assuming a hypothetical rate limit of 5 requests per minute from a single IP. num_attempts = 10 success_count = 0 rate_limited_count = 0 for _ in range(num_attempts): user_data = generate_random_user_data() response = client.post("/auth/register", json=user_data) if response.status_code == status.HTTP_200_OK: success_count += 1 elif response.status_code == status.HTTP_429_TOO_MANY_REQUESTS: rate_limited_count += 1 else: # Other errors are unexpected. Adjust this assertion if other valid error codes occur. pytest.fail(f"Unexpected status code: {response.status_code} - {response.json()} for attempt {_}") # Optional: Add a small delay if the rate limit resets quickly # await asyncio.sleep(0.1) # If rate limiting is NOT implemented or is very loose, all may succeed (up to unique email constraint). # If implemented, some should be 429. # This assertion needs to be flexible enough for environments where rate limiting might not be active. # If all requests succeed, it implies rate limiting is not active or is very lenient. # If rate limiting is expected, you'd assert rate_limited_count > 0. # For now, just ensure no 404s and some registrations went through. assert success_count > 0 # At least one registration should work # Assert that if there are too many attempts, either some fail due to duplicate email, or some are rate-limited. # The main point is not getting 404. # If rate limiting is not implemented, client.post will return 200 (if unique email) or 400 (duplicate email) assert (success_count + rate_limited_count) == num_attempts or \ (success_count + rate_limited_count < num_attempts and any(r.status_code == status.HTTP_400_BAD_REQUEST for r in responses)) class TestPerformance: """Basic performance tests for critical endpoints""" @pytest.mark.performance @pytest.mark.slow async def test_registration_performance(self, client): """Measure performance of user registration""" num_users = 50 # Number of registrations to test start_time = time.time() tasks = [] for _ in range(num_users): user_data = generate_random_user_data(f"perf_{_}") tasks.append(asyncio.create_task( asyncio.to_thread(client.post, "/auth/register", json=user_data) )) responses = await asyncio.gather(*tasks) end_time = time.time() for response in responses: assert response.status_code == status.HTTP_200_OK, f"Registration failed with status {response.status_code}: {response.json()}" duration = end_time - start_time print(f"\nRegistered {num_users} users in {duration:.2f} seconds ({num_users/duration:.2f} req/s)") # Assert against a reasonable threshold assert duration < 5.0 # Example: 50 users in under 5 seconds @pytest.mark.performance @pytest.mark.slow async def test_login_performance(self, client): """Measure performance of user login""" num_logins = 50 # Pre-register users users_to_login_data = [] # Store full user data including password for _ in range(num_logins): user_data = generate_random_user_data(f"login_perf_{_}") client.post("/auth/register", json=user_data) # Register user users_to_login_data.append(user_data) # Store for login start_time = time.time() tasks = [] for user_data in users_to_login_data: login_data = {"email": user_data["email"], "password": user_data["password"]} tasks.append(asyncio.create_task( asyncio.to_thread(client.post, "/auth/login", json=login_data) )) responses = await asyncio.gather(*tasks) end_time = time.time() for response in responses: assert response.status_code == status.HTTP_200_OK, f"Login failed with status {response.status_code}: {response.json()}" duration = end_time - start_time print(f"\nLogged in {num_logins} users in {duration:.2f} seconds ({num_logins/duration:.2f} req/s)") assert duration < 5.0 # Example: 50 logins in under 5 seconds class TestEdgeCases: """Tests for various edge cases and unusual inputs""" @pytest.mark.api async def test_very_long_email(self, client): """Test registration with a very long email address""" # Max email length is typically 254 characters (RFC 3696) long_local_part = "a" * 60 # Max 64 for local part long_domain_part = "b" * 180 # Max 255 for domain (sum of labels + dots) long_email = f"{long_local_part}@{long_domain_part}.com" # Roughly 250+ chars user_data = generate_random_user_data() user_data["email"] = long_email response = client.post("/auth/register", json=user_data) # Depending on validation/DB schema, could be 200 (valid), 400 (too long), or 422 (validation) assert response.status_code in [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY] if response.status_code == status.HTTP_200_OK: assert response.json()["email"] == long_email @pytest.mark.api async def test_very_long_full_name(self, client): """Test registration with a very long full name""" long_name = "A" * 500 # Assume a reasonable max length like 255 or 500 user_data = generate_random_user_data() user_data["full_name"] = long_name response = client.post("/auth/register", json=user_data) assert response.status_code in [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY] if response.status_code == status.HTTP_200_OK: assert response.json()["full_name"] == long_name @pytest.mark.api async def test_very_long_password(self, client): """Test registration with a very long password""" long_password = "P" + "a" * 200 + "1!" # Very long, but valid chars user_data = generate_random_user_data() user_data["password"] = long_password response = client.post("/auth/register", json=user_data) assert response.status_code in [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY] @pytest.mark.api async def test_unicode_characters_in_name(self, client): """Test registration with unicode characters in full name""" unicode_name = "परीक्षा उपयोगकर्ता नाम 😀" # Example unicode characters user_data = generate_random_user_data() user_data["full_name"] = unicode_name response = client.post("/auth/register", json=user_data) assert response.status_code == status.HTTP_200_OK assert response.json()["full_name"] == unicode_name @pytest.mark.api async def test_special_characters_in_email(self, client): """Test registration with email containing special but valid characters""" # Test valid email with '+' alias and '.' test_cases = [ "test+alias@bakery.es", "first.last@bakery.es", ] for email in test_cases: user_data = generate_random_user_data() user_data["email"] = email response = client.post("/auth/register", json=user_data) assert response.status_code == status.HTTP_200_OK, f"Failed for email: {email}" assert response.json()["email"] == email @pytest.mark.api async def test_empty_strings(self, client): """Test registration with empty strings for required fields""" user_data = { "email": "", "password": "TestPassword123!", "full_name": "" } response = client.post("/auth/register", json=user_data) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert any("email" in err["loc"] and "value has no fewer than 1 characters" in err["msg"] for err in response.json()["detail"]) assert any("full_name" in err["loc"] and "value has no fewer than 1 characters" in err["msg"] for err in response.json()["detail"]) @pytest.mark.api async def test_null_values(self, client): """Test registration with null values for required fields""" user_data = { "email": None, "password": "TestPassword123!", "full_name": None } response = client.post("/auth/register", json=user_data) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert any("email" in err["loc"] and "none is not an allowed value" in err["msg"].lower() for err in response.json()["detail"]) assert any("full_name" in err["loc"] and "none is not an allowed value" in err["msg"].lower() for err in response.json()["detail"]) @pytest.mark.api async def test_whitespace_only_fields(self, client): """Test registration with whitespace-only strings for required fields""" user_data = { "email": " ", "password": "TestPassword123!", "full_name": " " } response = client.post("/auth/register", json=user_data) # Expected behavior: 422 for validation, or 400 if stripped and then invalid assert response.status_code in [status.HTTP_422_UNPROCESSABLE_ENTITY, status.HTTP_400_BAD_REQUEST] if response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY: assert any("email" in err["loc"] for err in response.json()["detail"]) or \ any("full_name" in err["loc"] for err in response.json()["detail"]) @pytest.mark.api async def test_case_sensitivity_email(self, client): """Test case sensitivity of email during login""" user_data = generate_random_user_data() original_email = user_data["email"] client.post("/auth/register", json=user_data) # Register with original casing # Try logging in with different casing lower_email = original_email.lower() upper_email = original_email.upper() if original_email != lower_email: # Only test if casing actually changes response_lower = client.post("/auth/login", json={"email": lower_email, "password": user_data["password"]}) assert response_lower.status_code == status.HTTP_200_OK, "Login failed with lowercase email" if original_email != upper_email: response_upper = client.post("/auth/login", json={"email": upper_email, "password": user_data["password"]}) # FastAPI's email validator might convert to lowercase; database might be case-insensitive # This assertion depends on your specific implementation of email uniqueness/lookup # Often, emails are normalized to lowercase for uniqueness checks. # Assuming normalization or case-insensitivity: assert response_upper.status_code == status.HTTP_200_OK, "Login failed with uppercase email" class TestConcurrency: """Tests for concurrent operations""" @pytest.mark.integration @pytest.mark.slow async def test_concurrent_registration_same_email(self, client): """Test concurrent registration attempts with the same email""" shared_user_data = generate_random_user_data() num_attempts = 5 async def register_task(): return await asyncio.to_thread(client.post, "/auth/register", json=shared_user_data) tasks = [register_task() for _ in range(num_attempts)] responses = await asyncio.gather(*tasks) success_count = 0 duplicate_count = 0 for response in responses: if response.status_code == status.HTTP_200_OK: success_count += 1 elif response.status_code == status.HTTP_400_BAD_REQUEST and "Email already registered" in response.json().get("detail", ""): duplicate_count += 1 else: pytest.fail(f"Unexpected status code for concurrent registration: {response.status_code} - {response.json()}") assert success_count == 1, f"Expected exactly one successful registration, got {success_count}" assert duplicate_count == num_attempts - 1, f"Expected {num_attempts - 1} duplicate errors, got {duplicate_count}" @pytest.mark.integration @pytest.mark.slow async def test_concurrent_login_attempts(self, client): """Test concurrent login attempts for a single user""" auth_data = await register_and_login_user(client) user_email = auth_data["user_data"]["email"] # Get email from original user_data user_password = auth_data["user_data"]["password"] # Get password from original user_data login_data = {"email": user_email, "password": user_password} num_attempts = 10 async def login_task(): return await asyncio.to_thread(client.post, "/auth/login", json=login_data) tasks = [login_task() for _ in range(num_attempts)] responses = await asyncio.gather(*tasks) for response in responses: assert response.status_code == status.HTTP_200_OK assert "access_token" in response.json() assert "refresh_token" in response.json() class TestDataIntegrity: """Tests to ensure data integrity""" @pytest.mark.integration async def test_user_data_integrity_after_creation(self, test_user, test_db): """Verify user data fields after creation""" # test_user is already awaited by pytest-asyncio fetched_user = await test_db.execute(text("SELECT * FROM users WHERE id = :id"), {"id": test_user.id}) user_row = fetched_user.scalar_one_or_none() assert user_row is not None assert user_row.email == test_user.email assert user_row.full_name == test_user.full_name assert user_row.hashed_password == test_user.hashed_password assert user_row.is_active is True assert user_row.created_at is not None assert user_row.updated_at is not None assert user_row.last_login is None # Should be None until first login @pytest.mark.integration async def test_last_login_update(self, test_user, test_db): """Test that last_login timestamp is updated on successful login""" # Ensure last_login is None initially assert test_user.last_login is None # test_user is already awaited await AuthService.login_user(test_user.email, "TestPassword123!", test_db) # Re-fetch user to get updated last_login updated_user_result = await test_db.execute(text("SELECT * FROM users WHERE id = :id"), {"id": test_user.id}) fetched_user_obj = updated_user_result.scalar_one_or_none() assert fetched_user_obj is not None assert fetched_user_obj.last_login is not None # Assert last_login is recent (e.g., within the last minute) assert datetime.now(timezone.utc) - fetched_user_obj.last_login < timedelta(minutes=1) @pytest.mark.integration async def test_database_rollback_on_error(self, test_db): """Test that database operations are rolled back on error""" initial_user_count_query = await test_db.execute(text("SELECT COUNT(*) FROM users")) initial_user_count = initial_user_count_query.scalar_one() mock_db_for_rollback = AsyncMock(spec=AsyncSession) mock_db_for_rollback.commit.side_effect = Exception("Simulated rollback error") mock_db_for_rollback.add.return_value = None mock_db_for_rollback.refresh.return_value = None mock_db_for_rollback.rollback.return_value = None # Ensure rollback is mocked as well # Patching get_db to return our mock session for the duration of this test with patch('app.core.database.get_db', autospec=True) as mock_get_db: mock_get_db.return_value.__aenter__.return_value = mock_db_for_rollback mock_get_db.return_value.__aexit__.return_value = False # Don't suppress exceptions try: # Attempt to create a user, which should trigger the commit error and then rollback await AuthService.create_user( "rollback_test@bakery.es", "Password123!", "Rollback User", mock_db_for_rollback ) pytest.fail("Expected an exception during user creation, but none was raised.") except Exception as e: assert "Simulated rollback error" in str(e) # After the error, the session state should effectively be rolled back # We need a fresh session to verify the count if the original was mocked # No need for new session if test_db fixture ensures rollback. # However, if testing a scenario where a separate session is opened and fails: fresh_user_count_query = await test_db.execute(text("SELECT COUNT(*) FROM users")) current_user_count = fresh_user_count_query.scalar_one() assert current_user_count == initial_user_count, "Database state was not rolled back after error." class TestTokenManagement: """Tests for JWT token generation and validation""" @pytest.mark.security async def test_token_expiration_validation(self): """Test that expired tokens are not considered valid""" # Create a token that expires very soon # Adjusted to pass arguments explicitly as expected by SecurityManager.create_access_token expired_token = SecurityManager.create_access_token( user_id="123", email="test@exp.com", full_name="Expired User", # Added full_name as it's likely part of claims expires_delta=timedelta(seconds=-1) # Already expired ) with pytest.raises(ValueError, match="Token has expired"): SecurityManager.verify_token(expired_token) # Test a valid token valid_token = SecurityManager.create_access_token( user_id="456", email="test@valid.com", full_name="Valid User", expires_delta=timedelta(minutes=5) ) decoded_valid = SecurityManager.verify_token(valid_token) assert decoded_valid["email"] == "test@valid.com" @pytest.mark.security async def test_token_contains_correct_claims(self, test_user): """Test that generated tokens contain the correct user claims""" # test_user is already awaited by pytest-asyncio token = SecurityManager.create_access_token( user_id=str(test_user.id), email=test_user.email, full_name=test_user.full_name, expires_delta=timedelta(minutes=15) ) decoded_token = SecurityManager.verify_token(token) assert decoded_token is not None assert decoded_token["sub"] == str(test_user.id) assert decoded_token["email"] == test_user.email assert decoded_token["full_name"] == test_user.full_name assert "exp" in decoded_token assert "iat" in decoded_token @pytest.mark.security async def test_token_tampering_detection(self): """Test that tampered tokens are rejected""" # Adjusted to pass arguments explicitly as expected by SecurityManager.create_access_token original_token = SecurityManager.create_access_token( user_id="123", email="test@tamper.com", full_name="Tamper User", # Added full_name expires_delta=timedelta(minutes=15) ) # Simple tampering: change a character # JWTs are base64 encoded and have 3 parts: header.payload.signature # Tampering the payload will invalidate the signature. parts = original_token.split('.') if len(parts) == 3: tampered_payload = parts[1] + "X" # Simple modification tampered_token = f"{parts[0]}.{tampered_payload}.{parts[2]}" else: pytest.fail("Original token format unexpected for tampering test.") with pytest.raises(ValueError, match="Could not validate credentials|Invalid token signature"): SecurityManager.verify_token(tampered_token) @pytest.mark.security async def test_token_with_invalid_signature(self): """Test that tokens with invalid signatures are rejected""" # Create a token with a known structure but wrong secret token_parts = [ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", # Header (alg: HS256, typ: JWT) # Payload with sub, email, iat, exp (arbitrary values) "eyJzdWIiOiIxMjM0NTY3ODkwIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2NzIyMzkwMjIsInZnbiI6IkFub255bW91cyBVc2VyIn0", "invalid_signature_here" # Invalid signature ] invalid_signature_token = ".".join(token_parts) with pytest.raises(ValueError, match="Could not validate credentials|Invalid token signature"): SecurityManager.verify_token(invalid_signature_token) class TestCompleteAuthenticationFlows: """End-to-end tests for full user lifecycle scenarios""" @pytest.mark.integration @pytest.mark.slow async def test_full_user_lifecycle(self, client, test_db): """Test the complete user lifecycle: register, login, access, refresh, logout, re-login""" user_data = generate_random_user_data() # 1. Register register_response = client.post("/auth/register", json=user_data) assert register_response.status_code == status.HTTP_200_OK # 2. Login login_response = client.post("/auth/login", json={"email": user_data["email"], "password": user_data["password"]}) assert login_response.status_code == status.HTTP_200_OK access_token = login_response.json()["access_token"] refresh_token = login_response.json()["refresh_token"] # 3. Access protected endpoint me_response = client.get("/users/me", headers={"Authorization": f"Bearer {access_token}"}) assert me_response.status_code == status.HTTP_200_OK assert me_response.json()["email"] == user_data["email"] # 4. Refresh token refresh_response = client.post("/auth/refresh", json={"refresh_token": refresh_token}) assert refresh_response.status_code == status.HTTP_200_OK new_access_token = refresh_response.json()["access_token"] assert new_access_token != access_token # 5. Access protected endpoint with new token me_response_new = client.get("/users/me", headers={"Authorization": f"Bearer {new_access_token}"}) assert me_response_new.status_code == status.HTTP_200_OK # 6. Logout logout_response = client.post("/auth/logout", json={"refresh_token": refresh_token}) assert logout_response.status_code == status.HTTP_200_OK # 7. Verify tokens are invalidated me_response_after_logout = client.get("/users/me", headers={"Authorization": f"Bearer {new_access_token}"}) assert me_response_after_logout.status_code == status.HTTP_401_UNAUTHORIZED refresh_response_after_logout = client.post("/auth/refresh", json={"refresh_token": refresh_token}) assert refresh_response_after_logout.status_code == status.HTTP_401_UNAUTHORIZED # 8. Re-login with same credentials re_login_response = client.post("/auth/login", json={"email": user_data["email"], "password": user_data["password"]}) assert re_login_response.status_code == status.HTTP_200_OK re_access_token = re_login_response.json()["access_token"] # 9. Access protected endpoint with re-logged in token me_response_re_login = client.get("/users/me", headers={"Authorization": f"Bearer {re_access_token}"}) assert me_response_re_login.status_code == status.HTTP_200_OK assert me_response_re_login.json()["email"] == user_data["email"] @pytest.mark.integration async def test_multiple_sessions_same_user(self, client, test_db): """Test a user logging in from multiple sessions concurrently""" user_data = generate_random_user_data() client.post("/auth/register", json=user_data) num_sessions = 3 login_tasks = [] for _ in range(num_sessions): login_tasks.append(asyncio.create_task( asyncio.to_thread(client.post, "/auth/login", json={"email": user_data["email"], "password": user_data["password"]}) )) login_responses = await asyncio.gather(*login_tasks) all_access_tokens = [] all_refresh_tokens = [] for response in login_responses: assert response.status_code == status.HTTP_200_OK assert "access_token" in response.json() assert "refresh_token" in response.json() all_access_tokens.append(response.json()["access_token"]) all_refresh_tokens.append(response.json()["refresh_token"]) assert len(all_access_tokens) == num_sessions assert len(all_refresh_tokens) == num_sessions # Verify all access tokens are valid access_check_tasks = [] for token in all_access_tokens: access_check_tasks.append(asyncio.create_task( asyncio.to_thread(client.get, "/users/me", headers={"Authorization": f"Bearer {token}"}) )) access_check_responses = await asyncio.gather(*access_check_tasks) for response in access_check_responses: assert response.status_code == status.HTTP_200_OK assert response.json()["email"] == user_data["email"] # Logout one session and ensure others remain active logout_response = client.post("/auth/logout", json={"refresh_token": all_refresh_tokens[0]}) assert logout_response.status_code == status.HTTP_200_OK # The first session's access token should now be invalid single_logout_access_check = client.get("/users/me", headers={"Authorization": f"Bearer {all_access_tokens[0]}"}) assert single_logout_access_check.status_code == status.HTTP_401_UNAUTHORIZED # Other sessions should still be valid remaining_access_check_tasks = [] for token in all_access_tokens[1:]: remaining_access_check_tasks.append(asyncio.create_task( asyncio.to_thread(client.get, "/users/me", headers={"Authorization": f"Bearer {token}"}) )) remaining_access_check_responses = await asyncio.gather(*remaining_access_check_tasks) for response in remaining_access_check_responses: assert response.status_code == status.HTTP_200_OK assert response.json()["email"] == user_data["email"] # ================================================================\ # PYTEST CONFIGURATION # ================================================================\ # pytest configuration for the test file pytest_plugins = ["pytest_asyncio"] # Markers for different test categories pytestmark = [ pytest.mark.asyncio, pytest.mark.auth, ] # Test configuration def pytest_configure(config): """Configure pytest markers""" config.addinivalue_line( "markers", "auth: marks tests as authentication tests" ) config.addinivalue_line( "markers", "integration: marks tests as integration tests" ) config.addinivalue_line( "markers", "performance: marks tests as performance tests" ) config.addinivalue_line( "markers", "security: marks tests as security tests" )