Convert password reset and auth dependencies tests to async

- Refactored all `password reset` and `auth dependency` tests to utilize async patterns for compatibility with async database sessions.
- Enhanced test fixtures with `pytest-asyncio` to support asynchronous database operations.
- Improved user handling with async context management for `test_user` and `async_mock_user`.
- Introduced `await` syntax for route calls, token generation, and database transactions in test cases.
This commit is contained in:
Felipe Cardoso
2025-10-31 22:31:01 +01:00
parent 8a7a3b9521
commit 92a8699479
32 changed files with 708 additions and 437 deletions

0
backend/tests/api/dependencies/__init__.py Normal file → Executable file
View File

242
backend/tests/api/dependencies/test_auth_dependencies.py Normal file → Executable file
View File

@@ -1,5 +1,6 @@
# tests/api/dependencies/test_auth_dependencies.py # tests/api/dependencies/test_auth_dependencies.py
import pytest import pytest
import pytest_asyncio
import uuid import uuid
from unittest.mock import patch from unittest.mock import patch
from fastapi import HTTPException from fastapi import HTTPException
@@ -10,7 +11,8 @@ from app.api.dependencies.auth import (
get_current_superuser, get_current_superuser,
get_optional_current_user get_optional_current_user
) )
from app.core.auth import TokenExpiredError, TokenInvalidError from app.core.auth import TokenExpiredError, TokenInvalidError, get_password_hash
from app.models.user import User
@pytest.fixture @pytest.fixture
@@ -19,79 +21,119 @@ def mock_token():
return "mock.jwt.token" return "mock.jwt.token"
@pytest_asyncio.fixture
async def async_mock_user(async_test_db):
"""Async fixture to create and return a mock User instance."""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
mock_user = User(
id=uuid.uuid4(),
email="mockuser@example.com",
password_hash=get_password_hash("mockhashedpassword"),
first_name="Mock",
last_name="User",
phone_number="1234567890",
is_active=True,
is_superuser=False,
preferences=None,
)
session.add(mock_user)
await session.commit()
await session.refresh(mock_user)
return mock_user
class TestGetCurrentUser: class TestGetCurrentUser:
"""Tests for get_current_user dependency""" """Tests for get_current_user dependency"""
def test_get_current_user_success(self, db_session, mock_user, mock_token): @pytest.mark.asyncio
async def test_get_current_user_success(self, async_test_db, async_mock_user, mock_token):
"""Test successfully getting the current user""" """Test successfully getting the current user"""
# Mock get_token_data to return user_id that matches our mock_user test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.return_value.user_id = mock_user.id # Mock get_token_data to return user_id that matches our mock_user
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency # Call the dependency
user = get_current_user(db=db_session, token=mock_token) user = await get_current_user(db=session, token=mock_token)
# Verify the correct user was returned # Verify the correct user was returned
assert user.id == mock_user.id assert user.id == async_mock_user.id
assert user.email == mock_user.email assert user.email == async_mock_user.email
def test_get_current_user_nonexistent(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_current_user_nonexistent(self, async_test_db, mock_token):
"""Test when the token contains a user ID that doesn't exist""" """Test when the token contains a user ID that doesn't exist"""
# Mock get_token_data to return a non-existent user ID test_engine, AsyncTestingSessionLocal = async_test_db
nonexistent_id = uuid.UUID("11111111-1111-1111-1111-111111111111") async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to return a non-existent user ID
nonexistent_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = nonexistent_id mock_get_data.return_value.user_id = nonexistent_id
# Should raise HTTPException with 404 status # Should raise HTTPException with 404 status
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token) await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 404 assert exc_info.value.status_code == 404
assert "User not found" in exc_info.value.detail assert "User not found" in exc_info.value.detail
def test_get_current_user_inactive(self, db_session, mock_user, mock_token): @pytest.mark.asyncio
async def test_get_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
"""Test when the user is inactive""" """Test when the user is inactive"""
# Make the user inactive test_engine, AsyncTestingSessionLocal = async_test_db
mock_user.is_active = False async with AsyncTestingSessionLocal() as session:
db_session.commit() # Get the user in this session and make it inactive
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == async_mock_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
# Mock get_token_data # Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id mock_get_data.return_value.user_id = async_mock_user.id
# Should raise HTTPException with 403 status # Should raise HTTPException with 403 status
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token) await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 403 assert exc_info.value.status_code == 403
assert "Inactive user" in exc_info.value.detail assert "Inactive user" in exc_info.value.detail
def test_get_current_user_expired_token(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_current_user_expired_token(self, async_test_db, mock_token):
"""Test with an expired token""" """Test with an expired token"""
# Mock get_token_data to raise TokenExpiredError test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.side_effect = TokenExpiredError("Token expired") # Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Should raise HTTPException with 401 status # Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token) await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
assert "Token expired" in exc_info.value.detail assert "Token expired" in exc_info.value.detail
def test_get_current_user_invalid_token(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_current_user_invalid_token(self, async_test_db, mock_token):
"""Test with an invalid token""" """Test with an invalid token"""
# Mock get_token_data to raise TokenInvalidError test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.side_effect = TokenInvalidError("Invalid token") # Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Should raise HTTPException with 401 status # Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token) await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
assert "Could not validate credentials" in exc_info.value.detail assert "Could not validate credentials" in exc_info.value.detail
class TestGetCurrentActiveUser: class TestGetCurrentActiveUser:
@@ -151,63 +193,81 @@ class TestGetCurrentSuperuser:
class TestGetOptionalCurrentUser: class TestGetOptionalCurrentUser:
"""Tests for get_optional_current_user dependency""" """Tests for get_optional_current_user dependency"""
def test_get_optional_current_user_with_token(self, db_session, mock_user, mock_token): @pytest.mark.asyncio
async def test_get_optional_current_user_with_token(self, async_test_db, async_mock_user, mock_token):
"""Test getting optional user with a valid token""" """Test getting optional user with a valid token"""
# Mock get_token_data test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.return_value.user_id = mock_user.id # Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency # Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token) user = await get_optional_current_user(db=session, token=mock_token)
# Should return the correct user # Should return the correct user
assert user is not None assert user is not None
assert user.id == mock_user.id assert user.id == async_mock_user.id
def test_get_optional_current_user_no_token(self, db_session): @pytest.mark.asyncio
async def test_get_optional_current_user_no_token(self, async_test_db):
"""Test getting optional user with no token""" """Test getting optional user with no token"""
# Call the dependency with no token test_engine, AsyncTestingSessionLocal = async_test_db
user = get_optional_current_user(db=db_session, token=None) async with AsyncTestingSessionLocal() as session:
# Call the dependency with no token
user = await get_optional_current_user(db=session, token=None)
# Should return None # Should return None
assert user is None assert user is None
def test_get_optional_current_user_invalid_token(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_optional_current_user_invalid_token(self, async_test_db, mock_token):
"""Test getting optional user with an invalid token""" """Test getting optional user with an invalid token"""
# Mock get_token_data to raise TokenInvalidError test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.side_effect = TokenInvalidError("Invalid token") # Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Call the dependency # Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token) user = await get_optional_current_user(db=session, token=mock_token)
# Should return None, not raise an exception # Should return None, not raise an exception
assert user is None assert user is None
def test_get_optional_current_user_expired_token(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_optional_current_user_expired_token(self, async_test_db, mock_token):
"""Test getting optional user with an expired token""" """Test getting optional user with an expired token"""
# Mock get_token_data to raise TokenExpiredError test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.side_effect = TokenExpiredError("Token expired") # Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Call the dependency # Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token) user = await get_optional_current_user(db=session, token=mock_token)
# Should return None, not raise an exception # Should return None, not raise an exception
assert user is None assert user is None
def test_get_optional_current_user_inactive(self, db_session, mock_user, mock_token): @pytest.mark.asyncio
async def test_get_optional_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
"""Test getting optional user when user is inactive""" """Test getting optional user when user is inactive"""
# Make the user inactive test_engine, AsyncTestingSessionLocal = async_test_db
mock_user.is_active = False async with AsyncTestingSessionLocal() as session:
db_session.commit() # Get the user in this session and make it inactive
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == async_mock_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
# Mock get_token_data # Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency # Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token) user = await get_optional_current_user(db=session, token=mock_token)
# Should return None for inactive users # Should return None for inactive users
assert user is None assert user is None

0
backend/tests/api/routes/__init__.py Normal file → Executable file
View File

0
backend/tests/api/routes/test_auth.py Normal file → Executable file
View File

0
backend/tests/api/routes/test_health.py Normal file → Executable file
View File

0
backend/tests/api/routes/test_rate_limiting.py Normal file → Executable file
View File

0
backend/tests/api/routes/test_users.py Normal file → Executable file
View File

246
backend/tests/api/test_auth_dependencies.py Normal file → Executable file
View File

@@ -1,6 +1,8 @@
# tests/api/dependencies/test_auth_dependencies.py # tests/api/dependencies/test_auth_dependencies.py
import pytest import pytest
from unittest.mock import patch, MagicMock import pytest_asyncio
import uuid
from unittest.mock import patch
from fastapi import HTTPException from fastapi import HTTPException
from app.api.dependencies.auth import ( from app.api.dependencies.auth import (
@@ -9,87 +11,129 @@ from app.api.dependencies.auth import (
get_current_superuser, get_current_superuser,
get_optional_current_user get_optional_current_user
) )
from app.core.auth import TokenExpiredError, TokenInvalidError from app.core.auth import TokenExpiredError, TokenInvalidError, get_password_hash
from app.models.user import User
@pytest.fixture @pytest.fixture
def mock_token(): def mock_token():
"""Fixture providing a mock JWT token"""
return "mock.jwt.token" return "mock.jwt.token"
@pytest_asyncio.fixture
async def async_mock_user(async_test_db):
"""Async fixture to create and return a mock User instance."""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
mock_user = User(
id=uuid.uuid4(),
email="mockuser@example.com",
password_hash=get_password_hash("mockhashedpassword"),
first_name="Mock",
last_name="User",
phone_number="1234567890",
is_active=True,
is_superuser=False,
preferences=None,
)
session.add(mock_user)
await session.commit()
await session.refresh(mock_user)
return mock_user
class TestGetCurrentUser: class TestGetCurrentUser:
"""Tests for get_current_user dependency""" """Tests for get_current_user dependency"""
def test_get_current_user_success(self, db_session, mock_user, mock_token): @pytest.mark.asyncio
async def test_get_current_user_success(self, async_test_db, async_mock_user, mock_token):
"""Test successfully getting the current user""" """Test successfully getting the current user"""
# Mock get_token_data to return user_id that matches our mock_user test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.return_value.user_id = mock_user.id # Mock get_token_data to return user_id that matches our mock_user
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency # Call the dependency
user = get_current_user(db=db_session, token=mock_token) user = await get_current_user(db=session, token=mock_token)
# Verify the correct user was returned # Verify the correct user was returned
assert user.id == mock_user.id assert user.id == async_mock_user.id
assert user.email == mock_user.email assert user.email == async_mock_user.email
def test_get_current_user_nonexistent(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_current_user_nonexistent(self, async_test_db, mock_token):
"""Test when the token contains a user ID that doesn't exist""" """Test when the token contains a user ID that doesn't exist"""
# Mock get_token_data to return a non-existent user ID test_engine, AsyncTestingSessionLocal = async_test_db
# Use a real UUID object instead of a string async with AsyncTestingSessionLocal() as session:
import uuid # Mock get_token_data to return a non-existent user ID
nonexistent_id = uuid.UUID("11111111-1111-1111-1111-111111111111") nonexistent_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = nonexistent_id # Using UUID object, not string mock_get_data.return_value.user_id = nonexistent_id
# Should raise HTTPException with 404 status # Should raise HTTPException with 404 status
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token) await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 404 assert exc_info.value.status_code == 404
assert "User not found" in exc_info.value.detail
def test_get_current_user_inactive(self, db_session, mock_user, mock_token): @pytest.mark.asyncio
async def test_get_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
"""Test when the user is inactive""" """Test when the user is inactive"""
# Make the user inactive test_engine, AsyncTestingSessionLocal = async_test_db
mock_user.is_active = False async with AsyncTestingSessionLocal() as session:
db_session.commit() # Get the user in this session and make it inactive
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == async_mock_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
# Mock get_token_data # Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id mock_get_data.return_value.user_id = async_mock_user.id
# Should raise HTTPException with 403 status # Should raise HTTPException with 403 status
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token) await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 403 assert exc_info.value.status_code == 403
assert "Inactive user" in exc_info.value.detail
def test_get_current_user_expired_token(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_current_user_expired_token(self, async_test_db, mock_token):
"""Test with an expired token""" """Test with an expired token"""
# Mock get_token_data to raise TokenExpiredError test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.side_effect = TokenExpiredError("Token expired") # Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Should raise HTTPException with 401 status # Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token) await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
assert "Token expired" in exc_info.value.detail assert "Token expired" in exc_info.value.detail
def test_get_current_user_invalid_token(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_current_user_invalid_token(self, async_test_db, mock_token):
"""Test with an invalid token""" """Test with an invalid token"""
# Mock get_token_data to raise TokenInvalidError test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.side_effect = TokenInvalidError("Invalid token") # Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Should raise HTTPException with 401 status # Should raise HTTPException with 401 status
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
get_current_user(db=db_session, token=mock_token) await get_current_user(db=session, token=mock_token)
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
assert "Could not validate credentials" in exc_info.value.detail assert "Could not validate credentials" in exc_info.value.detail
class TestGetCurrentActiveUser: class TestGetCurrentActiveUser:
@@ -149,63 +193,81 @@ class TestGetCurrentSuperuser:
class TestGetOptionalCurrentUser: class TestGetOptionalCurrentUser:
"""Tests for get_optional_current_user dependency""" """Tests for get_optional_current_user dependency"""
def test_get_optional_current_user_with_token(self, db_session, mock_user, mock_token): @pytest.mark.asyncio
async def test_get_optional_current_user_with_token(self, async_test_db, async_mock_user, mock_token):
"""Test getting optional user with a valid token""" """Test getting optional user with a valid token"""
# Mock get_token_data test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.return_value.user_id = mock_user.id # Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency # Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token) user = await get_optional_current_user(db=session, token=mock_token)
# Should return the correct user # Should return the correct user
assert user is not None assert user is not None
assert user.id == mock_user.id assert user.id == async_mock_user.id
def test_get_optional_current_user_no_token(self, db_session): @pytest.mark.asyncio
async def test_get_optional_current_user_no_token(self, async_test_db):
"""Test getting optional user with no token""" """Test getting optional user with no token"""
# Call the dependency with no token test_engine, AsyncTestingSessionLocal = async_test_db
user = get_optional_current_user(db=db_session, token=None) async with AsyncTestingSessionLocal() as session:
# Call the dependency with no token
user = await get_optional_current_user(db=session, token=None)
# Should return None # Should return None
assert user is None assert user is None
def test_get_optional_current_user_invalid_token(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_optional_current_user_invalid_token(self, async_test_db, mock_token):
"""Test getting optional user with an invalid token""" """Test getting optional user with an invalid token"""
# Mock get_token_data to raise TokenInvalidError test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.side_effect = TokenInvalidError("Invalid token") # Mock get_token_data to raise TokenInvalidError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenInvalidError("Invalid token")
# Call the dependency # Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token) user = await get_optional_current_user(db=session, token=mock_token)
# Should return None, not raise an exception # Should return None, not raise an exception
assert user is None assert user is None
def test_get_optional_current_user_expired_token(self, db_session, mock_token): @pytest.mark.asyncio
async def test_get_optional_current_user_expired_token(self, async_test_db, mock_token):
"""Test getting optional user with an expired token""" """Test getting optional user with an expired token"""
# Mock get_token_data to raise TokenExpiredError test_engine, AsyncTestingSessionLocal = async_test_db
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: async with AsyncTestingSessionLocal() as session:
mock_get_data.side_effect = TokenExpiredError("Token expired") # Mock get_token_data to raise TokenExpiredError
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.side_effect = TokenExpiredError("Token expired")
# Call the dependency # Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token) user = await get_optional_current_user(db=session, token=mock_token)
# Should return None, not raise an exception # Should return None, not raise an exception
assert user is None assert user is None
def test_get_optional_current_user_inactive(self, db_session, mock_user, mock_token): @pytest.mark.asyncio
async def test_get_optional_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
"""Test getting optional user when user is inactive""" """Test getting optional user when user is inactive"""
# Make the user inactive test_engine, AsyncTestingSessionLocal = async_test_db
mock_user.is_active = False async with AsyncTestingSessionLocal() as session:
db_session.commit() # Get the user in this session and make it inactive
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == async_mock_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
# Mock get_token_data # Mock get_token_data
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data: with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
mock_get_data.return_value.user_id = mock_user.id mock_get_data.return_value.user_id = async_mock_user.id
# Call the dependency # Call the dependency
user = get_optional_current_user(db=db_session, token=mock_token) user = await get_optional_current_user(db=session, token=mock_token)
# Should return None for inactive users # Should return None for inactive users
assert user is None assert user is None

161
backend/tests/api/test_auth_endpoints.py Normal file → Executable file
View File

@@ -3,8 +3,10 @@
Tests for authentication endpoints. Tests for authentication endpoints.
""" """
import pytest import pytest
import pytest_asyncio
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from fastapi import status from fastapi import status
from sqlalchemy import select
from app.models.user import User from app.models.user import User
from app.schemas.users import UserCreate from app.schemas.users import UserCreate
@@ -21,9 +23,10 @@ def disable_rate_limit():
class TestRegisterEndpoint: class TestRegisterEndpoint:
"""Tests for POST /auth/register endpoint.""" """Tests for POST /auth/register endpoint."""
def test_register_success(self, client, test_db): @pytest.mark.asyncio
async def test_register_success(self, client):
"""Test successful user registration.""" """Test successful user registration."""
response = client.post( response = await client.post(
"/api/v1/auth/register", "/api/v1/auth/register",
json={ json={
"email": "newuser@example.com", "email": "newuser@example.com",
@@ -39,12 +42,13 @@ class TestRegisterEndpoint:
assert data["first_name"] == "New" assert data["first_name"] == "New"
assert "password" not in data assert "password" not in data
def test_register_duplicate_email(self, client, test_user): @pytest.mark.asyncio
async def test_register_duplicate_email(self, client, async_test_user):
"""Test registering with existing email.""" """Test registering with existing email."""
response = client.post( response = await client.post(
"/api/v1/auth/register", "/api/v1/auth/register",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": "SecurePassword123", "password": "SecurePassword123",
"first_name": "Duplicate", "first_name": "Duplicate",
"last_name": "User" "last_name": "User"
@@ -55,9 +59,10 @@ class TestRegisterEndpoint:
data = response.json() data = response.json()
assert data["success"] is False assert data["success"] is False
def test_register_weak_password(self, client): @pytest.mark.asyncio
async def test_register_weak_password(self, client):
"""Test registration with weak password.""" """Test registration with weak password."""
response = client.post( response = await client.post(
"/api/v1/auth/register", "/api/v1/auth/register",
json={ json={
"email": "weakpass@example.com", "email": "weakpass@example.com",
@@ -69,12 +74,13 @@ class TestRegisterEndpoint:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_register_unexpected_error(self, client, test_db): @pytest.mark.asyncio
async def test_register_unexpected_error(self, client):
"""Test registration with unexpected error.""" """Test registration with unexpected error."""
with patch('app.services.auth_service.AuthService.create_user') as mock_create: with patch('app.services.auth_service.AuthService.create_user') as mock_create:
mock_create.side_effect = Exception("Unexpected error") mock_create.side_effect = Exception("Unexpected error")
response = client.post( response = await client.post(
"/api/v1/auth/register", "/api/v1/auth/register",
json={ json={
"email": "error@example.com", "email": "error@example.com",
@@ -90,12 +96,13 @@ class TestRegisterEndpoint:
class TestLoginEndpoint: class TestLoginEndpoint:
"""Tests for POST /auth/login endpoint.""" """Tests for POST /auth/login endpoint."""
def test_login_success(self, client, test_user): @pytest.mark.asyncio
async def test_login_success(self, client, async_test_user):
"""Test successful login.""" """Test successful login."""
response = client.post( response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": "TestPassword123" "password": "TestPassword123"
} }
) )
@@ -106,21 +113,23 @@ class TestLoginEndpoint:
assert "refresh_token" in data assert "refresh_token" in data
assert data["token_type"] == "bearer" assert data["token_type"] == "bearer"
def test_login_wrong_password(self, client, test_user): @pytest.mark.asyncio
async def test_login_wrong_password(self, client, async_test_user):
"""Test login with wrong password.""" """Test login with wrong password."""
response = client.post( response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": "WrongPassword123" "password": "WrongPassword123"
} }
) )
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_login_nonexistent_user(self, client): @pytest.mark.asyncio
async def test_login_nonexistent_user(self, client):
"""Test login with non-existent email.""" """Test login with non-existent email."""
response = client.post( response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": "nonexistent@example.com", "email": "nonexistent@example.com",
@@ -130,31 +139,37 @@ class TestLoginEndpoint:
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_login_inactive_user(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_login_inactive_user(self, client, async_test_user, async_test_db):
"""Test login with inactive user.""" """Test login with inactive user."""
test_user.is_active = False test_engine, AsyncTestingSessionLocal = async_test_db
test_db.add(test_user) async with AsyncTestingSessionLocal() as session:
test_db.commit() # Get the user in this session and make it inactive
result = await session.execute(select(User).where(User.id == async_test_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
response = client.post( response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": "TestPassword123" "password": "TestPassword123"
} }
) )
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_login_unexpected_error(self, client, test_user): @pytest.mark.asyncio
async def test_login_unexpected_error(self, client, async_test_user):
"""Test login with unexpected error.""" """Test login with unexpected error."""
with patch('app.services.auth_service.AuthService.authenticate_user') as mock_auth: with patch('app.services.auth_service.AuthService.authenticate_user') as mock_auth:
mock_auth.side_effect = Exception("Database error") mock_auth.side_effect = Exception("Database error")
response = client.post( response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": "TestPassword123" "password": "TestPassword123"
} }
) )
@@ -165,12 +180,13 @@ class TestLoginEndpoint:
class TestOAuthLoginEndpoint: class TestOAuthLoginEndpoint:
"""Tests for POST /auth/login/oauth endpoint.""" """Tests for POST /auth/login/oauth endpoint."""
def test_oauth_login_success(self, client, test_user): @pytest.mark.asyncio
async def test_oauth_login_success(self, client, async_test_user):
"""Test successful OAuth login.""" """Test successful OAuth login."""
response = client.post( response = await client.post(
"/api/v1/auth/login/oauth", "/api/v1/auth/login/oauth",
data={ data={
"username": test_user.email, "username": async_test_user.email,
"password": "TestPassword123" "password": "TestPassword123"
} }
) )
@@ -180,43 +196,50 @@ class TestOAuthLoginEndpoint:
assert "access_token" in data assert "access_token" in data
assert "refresh_token" in data assert "refresh_token" in data
def test_oauth_login_wrong_credentials(self, client, test_user): @pytest.mark.asyncio
async def test_oauth_login_wrong_credentials(self, client, async_test_user):
"""Test OAuth login with wrong credentials.""" """Test OAuth login with wrong credentials."""
response = client.post( response = await client.post(
"/api/v1/auth/login/oauth", "/api/v1/auth/login/oauth",
data={ data={
"username": test_user.email, "username": async_test_user.email,
"password": "WrongPassword" "password": "WrongPassword"
} }
) )
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_oauth_login_inactive_user(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_oauth_login_inactive_user(self, client, async_test_user, async_test_db):
"""Test OAuth login with inactive user.""" """Test OAuth login with inactive user."""
test_user.is_active = False test_engine, AsyncTestingSessionLocal = async_test_db
test_db.add(test_user) async with AsyncTestingSessionLocal() as session:
test_db.commit() # Get the user in this session and make it inactive
result = await session.execute(select(User).where(User.id == async_test_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
response = client.post( response = await client.post(
"/api/v1/auth/login/oauth", "/api/v1/auth/login/oauth",
data={ data={
"username": test_user.email, "username": async_test_user.email,
"password": "TestPassword123" "password": "TestPassword123"
} }
) )
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_oauth_login_unexpected_error(self, client, test_user): @pytest.mark.asyncio
async def test_oauth_login_unexpected_error(self, client, async_test_user):
"""Test OAuth login with unexpected error.""" """Test OAuth login with unexpected error."""
with patch('app.services.auth_service.AuthService.authenticate_user') as mock_auth: with patch('app.services.auth_service.AuthService.authenticate_user') as mock_auth:
mock_auth.side_effect = Exception("Unexpected error") mock_auth.side_effect = Exception("Unexpected error")
response = client.post( response = await client.post(
"/api/v1/auth/login/oauth", "/api/v1/auth/login/oauth",
data={ data={
"username": test_user.email, "username": async_test_user.email,
"password": "TestPassword123" "password": "TestPassword123"
} }
) )
@@ -227,20 +250,21 @@ class TestOAuthLoginEndpoint:
class TestRefreshTokenEndpoint: class TestRefreshTokenEndpoint:
"""Tests for POST /auth/refresh endpoint.""" """Tests for POST /auth/refresh endpoint."""
def test_refresh_token_success(self, client, test_user): @pytest.mark.asyncio
async def test_refresh_token_success(self, client, async_test_user):
"""Test successful token refresh.""" """Test successful token refresh."""
# First, login to get a refresh token # First, login to get a refresh token
login_response = client.post( login_response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": "TestPassword123" "password": "TestPassword123"
} }
) )
refresh_token = login_response.json()["refresh_token"] refresh_token = login_response.json()["refresh_token"]
# Now refresh the token # Now refresh the token
response = client.post( response = await client.post(
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
json={"refresh_token": refresh_token} json={"refresh_token": refresh_token}
) )
@@ -250,36 +274,39 @@ class TestRefreshTokenEndpoint:
assert "access_token" in data assert "access_token" in data
assert "refresh_token" in data assert "refresh_token" in data
def test_refresh_token_expired(self, client): @pytest.mark.asyncio
async def test_refresh_token_expired(self, client):
"""Test refresh with expired token.""" """Test refresh with expired token."""
from app.core.auth import TokenExpiredError from app.core.auth import TokenExpiredError
with patch('app.services.auth_service.AuthService.refresh_tokens') as mock_refresh: with patch('app.services.auth_service.AuthService.refresh_tokens') as mock_refresh:
mock_refresh.side_effect = TokenExpiredError("Token expired") mock_refresh.side_effect = TokenExpiredError("Token expired")
response = client.post( response = await client.post(
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
json={"refresh_token": "some_token"} json={"refresh_token": "some_token"}
) )
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_refresh_token_invalid(self, client): @pytest.mark.asyncio
async def test_refresh_token_invalid(self, client):
"""Test refresh with invalid token.""" """Test refresh with invalid token."""
response = client.post( response = await client.post(
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
json={"refresh_token": "invalid_token"} json={"refresh_token": "invalid_token"}
) )
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_refresh_token_unexpected_error(self, client, test_user): @pytest.mark.asyncio
async def test_refresh_token_unexpected_error(self, client, async_test_user):
"""Test refresh with unexpected error.""" """Test refresh with unexpected error."""
# Get a valid refresh token first # Get a valid refresh token first
login_response = client.post( login_response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": "TestPassword123" "password": "TestPassword123"
} }
) )
@@ -288,7 +315,7 @@ class TestRefreshTokenEndpoint:
with patch('app.services.auth_service.AuthService.refresh_tokens') as mock_refresh: with patch('app.services.auth_service.AuthService.refresh_tokens') as mock_refresh:
mock_refresh.side_effect = Exception("Unexpected error") mock_refresh.side_effect = Exception("Unexpected error")
response = client.post( response = await client.post(
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
json={"refresh_token": refresh_token} json={"refresh_token": refresh_token}
) )
@@ -299,48 +326,52 @@ class TestRefreshTokenEndpoint:
class TestGetCurrentUserEndpoint: class TestGetCurrentUserEndpoint:
"""Tests for GET /auth/me endpoint.""" """Tests for GET /auth/me endpoint."""
def test_get_current_user_success(self, client, test_user): @pytest.mark.asyncio
async def test_get_current_user_success(self, client, async_test_user):
"""Test getting current user info.""" """Test getting current user info."""
# First, login to get an access token # First, login to get an access token
login_response = client.post( login_response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": "TestPassword123" "password": "TestPassword123"
} }
) )
access_token = login_response.json()["access_token"] access_token = login_response.json()["access_token"]
# Get current user info # Get current user info
response = client.get( response = await client.get(
"/api/v1/auth/me", "/api/v1/auth/me",
headers={"Authorization": f"Bearer {access_token}"} headers={"Authorization": f"Bearer {access_token}"}
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
data = response.json() data = response.json()
assert data["email"] == test_user.email assert data["email"] == async_test_user.email
assert data["first_name"] == test_user.first_name assert data["first_name"] == async_test_user.first_name
def test_get_current_user_no_token(self, client): @pytest.mark.asyncio
async def test_get_current_user_no_token(self, client):
"""Test getting current user without token.""" """Test getting current user without token."""
response = client.get("/api/v1/auth/me") response = await client.get("/api/v1/auth/me")
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_current_user_invalid_token(self, client): @pytest.mark.asyncio
async def test_get_current_user_invalid_token(self, client):
"""Test getting current user with invalid token.""" """Test getting current user with invalid token."""
response = client.get( response = await client.get(
"/api/v1/auth/me", "/api/v1/auth/me",
headers={"Authorization": "Bearer invalid_token"} headers={"Authorization": "Bearer invalid_token"}
) )
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_current_user_expired_token(self, client): @pytest.mark.asyncio
async def test_get_current_user_expired_token(self, client):
"""Test getting current user with expired token.""" """Test getting current user with expired token."""
# Use a clearly invalid/malformed token # Use a clearly invalid/malformed token
response = client.get( response = await client.get(
"/api/v1/auth/me", "/api/v1/auth/me",
headers={"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid"} headers={"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid"}
) )

152
backend/tests/api/test_auth_password_reset.py Normal file → Executable file
View File

@@ -3,11 +3,14 @@
Tests for password reset endpoints. Tests for password reset endpoints.
""" """
import pytest import pytest
import pytest_asyncio
from unittest.mock import patch, AsyncMock, MagicMock from unittest.mock import patch, AsyncMock, MagicMock
from fastapi import status from fastapi import status
from sqlalchemy import select
from app.schemas.users import PasswordResetRequest, PasswordResetConfirm from app.schemas.users import PasswordResetRequest, PasswordResetConfirm
from app.utils.security import create_password_reset_token from app.utils.security import create_password_reset_token
from app.models.user import User
# Disable rate limiting for tests # Disable rate limiting for tests
@@ -22,14 +25,14 @@ class TestPasswordResetRequest:
"""Tests for POST /auth/password-reset/request endpoint.""" """Tests for POST /auth/password-reset/request endpoint."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_password_reset_request_valid_email(self, client, test_user): async def test_password_reset_request_valid_email(self, client, async_test_user):
"""Test password reset request with valid email.""" """Test password reset request with valid email."""
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send: with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
mock_send.return_value = True mock_send.return_value = True
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/request", "/api/v1/auth/password-reset/request",
json={"email": test_user.email} json={"email": async_test_user.email}
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -40,15 +43,15 @@ class TestPasswordResetRequest:
# Verify email was sent # Verify email was sent
mock_send.assert_called_once() mock_send.assert_called_once()
call_args = mock_send.call_args call_args = mock_send.call_args
assert call_args.kwargs["to_email"] == test_user.email assert call_args.kwargs["to_email"] == async_test_user.email
assert call_args.kwargs["user_name"] == test_user.first_name assert call_args.kwargs["user_name"] == async_test_user.first_name
assert "reset_token" in call_args.kwargs assert "reset_token" in call_args.kwargs
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_password_reset_request_nonexistent_email(self, client): async def test_password_reset_request_nonexistent_email(self, client):
"""Test password reset request with non-existent email.""" """Test password reset request with non-existent email."""
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send: with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/request", "/api/v1/auth/password-reset/request",
json={"email": "nonexistent@example.com"} json={"email": "nonexistent@example.com"}
) )
@@ -62,17 +65,20 @@ class TestPasswordResetRequest:
mock_send.assert_not_called() mock_send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_password_reset_request_inactive_user(self, client, test_db, test_user): async def test_password_reset_request_inactive_user(self, client, async_test_db, async_test_user):
"""Test password reset request with inactive user.""" """Test password reset request with inactive user."""
# Deactivate user # Deactivate user
test_user.is_active = False test_engine, AsyncTestingSessionLocal = async_test_db
test_db.add(test_user) async with AsyncTestingSessionLocal() as session:
test_db.commit() result = await session.execute(select(User).where(User.id == async_test_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send: with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/request", "/api/v1/auth/password-reset/request",
json={"email": test_user.email} json={"email": async_test_user.email}
) )
# Should still return success to prevent email enumeration # Should still return success to prevent email enumeration
@@ -86,7 +92,7 @@ class TestPasswordResetRequest:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_password_reset_request_invalid_email_format(self, client): async def test_password_reset_request_invalid_email_format(self, client):
"""Test password reset request with invalid email format.""" """Test password reset request with invalid email format."""
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/request", "/api/v1/auth/password-reset/request",
json={"email": "not-an-email"} json={"email": "not-an-email"}
) )
@@ -96,7 +102,7 @@ class TestPasswordResetRequest:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_password_reset_request_missing_email(self, client): async def test_password_reset_request_missing_email(self, client):
"""Test password reset request without email.""" """Test password reset request without email."""
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/request", "/api/v1/auth/password-reset/request",
json={} json={}
) )
@@ -104,14 +110,14 @@ class TestPasswordResetRequest:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_password_reset_request_email_service_error(self, client, test_user): async def test_password_reset_request_email_service_error(self, client, async_test_user):
"""Test password reset when email service fails.""" """Test password reset when email service fails."""
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send: with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
mock_send.side_effect = Exception("SMTP Error") mock_send.side_effect = Exception("SMTP Error")
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/request", "/api/v1/auth/password-reset/request",
json={"email": test_user.email} json={"email": async_test_user.email}
) )
# Should still return success even if email fails # Should still return success even if email fails
@@ -120,16 +126,16 @@ class TestPasswordResetRequest:
assert data["success"] is True assert data["success"] is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_password_reset_request_rate_limiting(self, client, test_user): async def test_password_reset_request_rate_limiting(self, client, async_test_user):
"""Test that password reset requests are rate limited.""" """Test that password reset requests are rate limited."""
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send: with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
mock_send.return_value = True mock_send.return_value = True
# Make multiple requests quickly (3/minute limit) # Make multiple requests quickly (3/minute limit)
for _ in range(3): for _ in range(3):
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/request", "/api/v1/auth/password-reset/request",
json={"email": test_user.email} json={"email": async_test_user.email}
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -137,13 +143,14 @@ class TestPasswordResetRequest:
class TestPasswordResetConfirm: class TestPasswordResetConfirm:
"""Tests for POST /auth/password-reset/confirm endpoint.""" """Tests for POST /auth/password-reset/confirm endpoint."""
def test_password_reset_confirm_valid_token(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_password_reset_confirm_valid_token(self, client, async_test_user, async_test_db):
"""Test password reset confirmation with valid token.""" """Test password reset confirmation with valid token."""
# Generate valid token # Generate valid token
token = create_password_reset_token(test_user.email) token = create_password_reset_token(async_test_user.email)
new_password = "NewSecure123" new_password = "NewSecure123"
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={ json={
"token": token, "token": token,
@@ -157,21 +164,25 @@ class TestPasswordResetConfirm:
assert "successfully" in data["message"].lower() assert "successfully" in data["message"].lower()
# Verify user can login with new password # Verify user can login with new password
test_db.refresh(test_user) test_engine, AsyncTestingSessionLocal = async_test_db
from app.core.auth import verify_password async with AsyncTestingSessionLocal() as session:
assert verify_password(new_password, test_user.password_hash) is True result = await session.execute(select(User).where(User.id == async_test_user.id))
updated_user = result.scalar_one_or_none()
from app.core.auth import verify_password
assert verify_password(new_password, updated_user.password_hash) is True
def test_password_reset_confirm_expired_token(self, client, test_user): @pytest.mark.asyncio
async def test_password_reset_confirm_expired_token(self, client, async_test_user):
"""Test password reset confirmation with expired token.""" """Test password reset confirmation with expired token."""
import time as time_module import time as time_module
# Create token that expires immediately # Create token that expires immediately
token = create_password_reset_token(test_user.email, expires_in=1) token = create_password_reset_token(async_test_user.email, expires_in=1)
# Wait for token to expire # Wait for token to expire
time_module.sleep(2) time_module.sleep(2)
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={ json={
"token": token, "token": token,
@@ -186,9 +197,10 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "invalid" in error_msg or "expired" in error_msg assert "invalid" in error_msg or "expired" in error_msg
def test_password_reset_confirm_invalid_token(self, client): @pytest.mark.asyncio
async def test_password_reset_confirm_invalid_token(self, client):
"""Test password reset confirmation with invalid token.""" """Test password reset confirmation with invalid token."""
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={ json={
"token": "invalid_token_xyz", "token": "invalid_token_xyz",
@@ -202,13 +214,14 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "invalid" in error_msg or "expired" in error_msg assert "invalid" in error_msg or "expired" in error_msg
def test_password_reset_confirm_tampered_token(self, client, test_user): @pytest.mark.asyncio
async def test_password_reset_confirm_tampered_token(self, client, async_test_user):
"""Test password reset confirmation with tampered token.""" """Test password reset confirmation with tampered token."""
import base64 import base64
import json import json
# Create valid token and tamper with it # Create valid token and tamper with it
token = create_password_reset_token(test_user.email) token = create_password_reset_token(async_test_user.email)
decoded = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8') decoded = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8')
token_data = json.loads(decoded) token_data = json.loads(decoded)
token_data["payload"]["email"] = "hacker@example.com" token_data["payload"]["email"] = "hacker@example.com"
@@ -216,7 +229,7 @@ class TestPasswordResetConfirm:
# Re-encode tampered token # Re-encode tampered token
tampered = base64.urlsafe_b64encode(json.dumps(token_data).encode('utf-8')).decode('utf-8') tampered = base64.urlsafe_b64encode(json.dumps(token_data).encode('utf-8')).decode('utf-8')
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={ json={
"token": tampered, "token": tampered,
@@ -226,12 +239,13 @@ class TestPasswordResetConfirm:
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_password_reset_confirm_nonexistent_user(self, client): @pytest.mark.asyncio
async def test_password_reset_confirm_nonexistent_user(self, client):
"""Test password reset confirmation for non-existent user.""" """Test password reset confirmation for non-existent user."""
# Create token for email that doesn't exist # Create token for email that doesn't exist
token = create_password_reset_token("nonexistent@example.com") token = create_password_reset_token("nonexistent@example.com")
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={ json={
"token": token, "token": token,
@@ -245,16 +259,20 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "not found" in error_msg assert "not found" in error_msg
def test_password_reset_confirm_inactive_user(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_password_reset_confirm_inactive_user(self, client, async_test_user, async_test_db):
"""Test password reset confirmation for inactive user.""" """Test password reset confirmation for inactive user."""
# Deactivate user # Deactivate user
test_user.is_active = False test_engine, AsyncTestingSessionLocal = async_test_db
test_db.add(test_user) async with AsyncTestingSessionLocal() as session:
test_db.commit() result = await session.execute(select(User).where(User.id == async_test_user.id))
user_in_session = result.scalar_one_or_none()
user_in_session.is_active = False
await session.commit()
token = create_password_reset_token(test_user.email) token = create_password_reset_token(async_test_user.email)
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={ json={
"token": token, "token": token,
@@ -268,9 +286,10 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "inactive" in error_msg assert "inactive" in error_msg
def test_password_reset_confirm_weak_password(self, client, test_user): @pytest.mark.asyncio
async def test_password_reset_confirm_weak_password(self, client, async_test_user):
"""Test password reset confirmation with weak password.""" """Test password reset confirmation with weak password."""
token = create_password_reset_token(test_user.email) token = create_password_reset_token(async_test_user.email)
# Test various weak passwords # Test various weak passwords
weak_passwords = [ weak_passwords = [
@@ -280,7 +299,7 @@ class TestPasswordResetConfirm:
] ]
for weak_password in weak_passwords: for weak_password in weak_passwords:
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={ json={
"token": token, "token": token,
@@ -290,10 +309,11 @@ class TestPasswordResetConfirm:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_password_reset_confirm_missing_fields(self, client): @pytest.mark.asyncio
async def test_password_reset_confirm_missing_fields(self, client):
"""Test password reset confirmation with missing fields.""" """Test password reset confirmation with missing fields."""
# Missing token # Missing token
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={"new_password": "NewSecure123"} json={"new_password": "NewSecure123"}
) )
@@ -301,20 +321,22 @@ class TestPasswordResetConfirm:
# Missing password # Missing password
token = create_password_reset_token("test@example.com") token = create_password_reset_token("test@example.com")
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={"token": token} json={"token": token}
) )
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_password_reset_confirm_database_error(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_password_reset_confirm_database_error(self, client, async_test_user):
"""Test password reset confirmation with database error.""" """Test password reset confirmation with database error."""
token = create_password_reset_token(test_user.email) token = create_password_reset_token(async_test_user.email)
with patch.object(test_db, 'commit') as mock_commit: # Mock the password update to raise an exception
mock_commit.side_effect = Exception("Database error") with patch('app.api.routes.auth.user_crud.update') as mock_update:
mock_update.side_effect = Exception("Database error")
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={ json={
"token": token, "token": token,
@@ -328,18 +350,19 @@ class TestPasswordResetConfirm:
error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" error_msg = data["errors"][0]["message"].lower() if "errors" in data else ""
assert "error" in error_msg or "resetting" in error_msg assert "error" in error_msg or "resetting" in error_msg
def test_password_reset_full_flow(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_password_reset_full_flow(self, client, async_test_user, async_test_db):
"""Test complete password reset flow.""" """Test complete password reset flow."""
original_password = test_user.password_hash original_password = async_test_user.password_hash
new_password = "BrandNew123" new_password = "BrandNew123"
# Step 1: Request password reset # Step 1: Request password reset
with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send: with patch('app.api.routes.auth.email_service.send_password_reset_email') as mock_send:
mock_send.return_value = True mock_send.return_value = True
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/request", "/api/v1/auth/password-reset/request",
json={"email": test_user.email} json={"email": async_test_user.email}
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -349,7 +372,7 @@ class TestPasswordResetConfirm:
reset_token = call_args.kwargs["reset_token"] reset_token = call_args.kwargs["reset_token"]
# Step 2: Confirm password reset # Step 2: Confirm password reset
response = client.post( response = await client.post(
"/api/v1/auth/password-reset/confirm", "/api/v1/auth/password-reset/confirm",
json={ json={
"token": reset_token, "token": reset_token,
@@ -360,15 +383,18 @@ class TestPasswordResetConfirm:
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
# Step 3: Verify old password doesn't work # Step 3: Verify old password doesn't work
test_db.refresh(test_user) test_engine, AsyncTestingSessionLocal = async_test_db
from app.core.auth import verify_password async with AsyncTestingSessionLocal() as session:
assert test_user.password_hash != original_password result = await session.execute(select(User).where(User.id == async_test_user.id))
updated_user = result.scalar_one_or_none()
from app.core.auth import verify_password
assert updated_user.password_hash != original_password
# Step 4: Verify new password works # Step 4: Verify new password works
response = client.post( response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": new_password "password": new_password
} }
) )

0
backend/tests/api/test_security_headers.py Normal file → Executable file
View File

0
backend/tests/api/test_session_management.py Normal file → Executable file
View File

254
backend/tests/api/test_user_routes.py Normal file → Executable file
View File

@@ -4,10 +4,13 @@ Comprehensive tests for user management endpoints.
These tests focus on finding potential bugs, not just coverage. These tests focus on finding potential bugs, not just coverage.
""" """
import pytest import pytest
import pytest_asyncio
from unittest.mock import patch from unittest.mock import patch
from fastapi import status from fastapi import status
import uuid import uuid
from sqlalchemy import select
from app.models.user import User
from app.models.user import User from app.models.user import User
from app.schemas.users import UserUpdate from app.schemas.users import UserUpdate
@@ -21,9 +24,9 @@ def disable_rate_limit():
yield yield
def get_auth_headers(client, email, password): async def get_auth_headers(client, email, password):
"""Helper to get authentication headers.""" """Helper to get authentication headers."""
response = client.post( response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"email": email, "password": password} json={"email": email, "password": password}
) )
@@ -34,11 +37,12 @@ def get_auth_headers(client, email, password):
class TestListUsers: class TestListUsers:
"""Tests for GET /users endpoint.""" """Tests for GET /users endpoint."""
def test_list_users_as_superuser(self, client, test_superuser): @pytest.mark.asyncio
async def test_list_users_as_superuser(self, client, async_test_superuser):
"""Test listing users as superuser.""" """Test listing users as superuser."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.get("/api/v1/users", headers=headers) response = await client.get("/api/v1/users", headers=headers)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
data = response.json() data = response.json()
@@ -46,15 +50,17 @@ class TestListUsers:
assert "pagination" in data assert "pagination" in data
assert isinstance(data["data"], list) assert isinstance(data["data"], list)
def test_list_users_as_regular_user(self, client, test_user): @pytest.mark.asyncio
async def test_list_users_as_regular_user(self, client, async_test_user):
"""Test that regular users cannot list users.""" """Test that regular users cannot list users."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.get("/api/v1/users", headers=headers) response = await client.get("/api/v1/users", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
def test_list_users_pagination(self, client, test_superuser, test_db): @pytest.mark.asyncio
async def test_list_users_pagination(self, client, async_test_superuser, test_db):
"""Test pagination works correctly.""" """Test pagination works correctly."""
# Create multiple users # Create multiple users
for i in range(15): for i in range(15):
@@ -68,17 +74,18 @@ class TestListUsers:
test_db.add(user) test_db.add(user)
test_db.commit() test_db.commit()
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
# Get first page # Get first page
response = client.get("/api/v1/users?page=1&limit=5", headers=headers) response = await client.get("/api/v1/users?page=1&limit=5", headers=headers)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
data = response.json() data = response.json()
assert len(data["data"]) == 5 assert len(data["data"]) == 5
assert data["pagination"]["page"] == 1 assert data["pagination"]["page"] == 1
assert data["pagination"]["total"] >= 15 assert data["pagination"]["total"] >= 15
def test_list_users_filter_active(self, client, test_superuser, test_db): @pytest.mark.asyncio
async def test_list_users_filter_active(self, client, async_test_superuser, test_db):
"""Test filtering by active status.""" """Test filtering by active status."""
# Create active and inactive users # Create active and inactive users
active_user = User( active_user = User(
@@ -98,35 +105,37 @@ class TestListUsers:
test_db.add_all([active_user, inactive_user]) test_db.add_all([active_user, inactive_user])
test_db.commit() test_db.commit()
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
# Filter for active users # Filter for active users
response = client.get("/api/v1/users?is_active=true", headers=headers) response = await client.get("/api/v1/users?is_active=true", headers=headers)
data = response.json() data = response.json()
emails = [u["email"] for u in data["data"]] emails = [u["email"] for u in data["data"]]
assert "activefilter@example.com" in emails assert "activefilter@example.com" in emails
assert "inactivefilter@example.com" not in emails assert "inactivefilter@example.com" not in emails
# Filter for inactive users # Filter for inactive users
response = client.get("/api/v1/users?is_active=false", headers=headers) response = await client.get("/api/v1/users?is_active=false", headers=headers)
data = response.json() data = response.json()
emails = [u["email"] for u in data["data"]] emails = [u["email"] for u in data["data"]]
assert "inactivefilter@example.com" in emails assert "inactivefilter@example.com" in emails
assert "activefilter@example.com" not in emails assert "activefilter@example.com" not in emails
def test_list_users_sort_by_email(self, client, test_superuser): @pytest.mark.asyncio
async def test_list_users_sort_by_email(self, client, async_test_superuser):
"""Test sorting users by email.""" """Test sorting users by email."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.get("/api/v1/users?sort_by=email&sort_order=asc", headers=headers) response = await client.get("/api/v1/users?sort_by=email&sort_order=asc", headers=headers)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
data = response.json() data = response.json()
emails = [u["email"] for u in data["data"]] emails = [u["email"] for u in data["data"]]
assert emails == sorted(emails) assert emails == sorted(emails)
def test_list_users_no_auth(self, client): @pytest.mark.asyncio
async def test_list_users_no_auth(self, client):
"""Test that unauthenticated requests are rejected.""" """Test that unauthenticated requests are rejected."""
response = client.get("/api/v1/users") response = await client.get("/api/v1/users")
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Note: Removed test_list_users_unexpected_error because mocking at CRUD level # Note: Removed test_list_users_unexpected_error because mocking at CRUD level
@@ -136,31 +145,34 @@ class TestListUsers:
class TestGetCurrentUserProfile: class TestGetCurrentUserProfile:
"""Tests for GET /users/me endpoint.""" """Tests for GET /users/me endpoint."""
def test_get_own_profile(self, client, test_user): @pytest.mark.asyncio
async def test_get_own_profile(self, client, async_test_user):
"""Test getting own profile.""" """Test getting own profile."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.get("/api/v1/users/me", headers=headers) response = await client.get("/api/v1/users/me", headers=headers)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
data = response.json() data = response.json()
assert data["email"] == test_user.email assert data["email"] == async_test_user.email
assert data["first_name"] == test_user.first_name assert data["first_name"] == async_test_user.first_name
def test_get_profile_no_auth(self, client): @pytest.mark.asyncio
async def test_get_profile_no_auth(self, client):
"""Test that unauthenticated requests are rejected.""" """Test that unauthenticated requests are rejected."""
response = client.get("/api/v1/users/me") response = await client.get("/api/v1/users/me")
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestUpdateCurrentUser: class TestUpdateCurrentUser:
"""Tests for PATCH /users/me endpoint.""" """Tests for PATCH /users/me endpoint."""
def test_update_own_profile(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_update_own_profile(self, client, async_test_user, test_db):
"""Test updating own profile.""" """Test updating own profile."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch( response = await client.patch(
"/api/v1/users/me", "/api/v1/users/me",
headers=headers, headers=headers,
json={"first_name": "Updated", "last_name": "Name"} json={"first_name": "Updated", "last_name": "Name"}
@@ -172,14 +184,15 @@ class TestUpdateCurrentUser:
assert data["last_name"] == "Name" assert data["last_name"] == "Name"
# Verify in database # Verify in database
test_db.refresh(test_user) test_db.refresh(async_test_user)
assert test_user.first_name == "Updated" assert async_test_user.first_name == "Updated"
def test_update_profile_phone_number(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_update_profile_phone_number(self, client, async_test_user, test_db):
"""Test updating phone number with validation.""" """Test updating phone number with validation."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch( response = await client.patch(
"/api/v1/users/me", "/api/v1/users/me",
headers=headers, headers=headers,
json={"phone_number": "+19876543210"} json={"phone_number": "+19876543210"}
@@ -189,11 +202,12 @@ class TestUpdateCurrentUser:
data = response.json() data = response.json()
assert data["phone_number"] == "+19876543210" assert data["phone_number"] == "+19876543210"
def test_update_profile_invalid_phone(self, client, test_user): @pytest.mark.asyncio
async def test_update_profile_invalid_phone(self, client, async_test_user):
"""Test that invalid phone numbers are rejected.""" """Test that invalid phone numbers are rejected."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch( response = await client.patch(
"/api/v1/users/me", "/api/v1/users/me",
headers=headers, headers=headers,
json={"phone_number": "invalid"} json={"phone_number": "invalid"}
@@ -201,13 +215,14 @@ class TestUpdateCurrentUser:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_cannot_elevate_to_superuser(self, client, test_user): @pytest.mark.asyncio
async def test_cannot_elevate_to_superuser(self, client, async_test_user):
"""Test that users cannot make themselves superuser.""" """Test that users cannot make themselves superuser."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
# Note: is_superuser is not in UserUpdate schema, but the endpoint checks for it # Note: is_superuser is not in UserUpdate schema, but the endpoint checks for it
# This tests that even if someone tries to send it, it's rejected # This tests that even if someone tries to send it, it's rejected
response = client.patch( response = await client.patch(
"/api/v1/users/me", "/api/v1/users/me",
headers=headers, headers=headers,
json={"first_name": "Test", "is_superuser": True} json={"first_name": "Test", "is_superuser": True}
@@ -220,9 +235,10 @@ class TestUpdateCurrentUser:
# Verify user is still not a superuser # Verify user is still not a superuser
assert data["is_superuser"] is False assert data["is_superuser"] is False
def test_update_profile_no_auth(self, client): @pytest.mark.asyncio
async def test_update_profile_no_auth(self, client):
"""Test that unauthenticated requests are rejected.""" """Test that unauthenticated requests are rejected."""
response = client.patch( response = await client.patch(
"/api/v1/users/me", "/api/v1/users/me",
json={"first_name": "Hacker"} json={"first_name": "Hacker"}
) )
@@ -234,17 +250,19 @@ class TestUpdateCurrentUser:
class TestGetUserById: class TestGetUserById:
"""Tests for GET /users/{user_id} endpoint.""" """Tests for GET /users/{user_id} endpoint."""
def test_get_own_profile_by_id(self, client, test_user): @pytest.mark.asyncio
async def test_get_own_profile_by_id(self, client, async_test_user):
"""Test getting own profile by ID.""" """Test getting own profile by ID."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.get(f"/api/v1/users/{test_user.id}", headers=headers) response = await client.get(f"/api/v1/users/{async_test_user.id}", headers=headers)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
data = response.json() data = response.json()
assert data["email"] == test_user.email assert data["email"] == async_test_user.email
def test_get_other_user_as_regular_user(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_get_other_user_as_regular_user(self, client, async_test_user, test_db):
"""Test that regular users cannot view other profiles.""" """Test that regular users cannot view other profiles."""
# Create another user # Create another user
other_user = User( other_user = User(
@@ -258,36 +276,39 @@ class TestGetUserById:
test_db.commit() test_db.commit()
test_db.refresh(other_user) test_db.refresh(other_user)
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.get(f"/api/v1/users/{other_user.id}", headers=headers) response = await client.get(f"/api/v1/users/{other_user.id}", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
def test_get_other_user_as_superuser(self, client, test_superuser, test_user): @pytest.mark.asyncio
async def test_get_other_user_as_superuser(self, client, async_test_superuser, async_test_user):
"""Test that superusers can view other profiles.""" """Test that superusers can view other profiles."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.get(f"/api/v1/users/{test_user.id}", headers=headers) response = await client.get(f"/api/v1/users/{async_test_user.id}", headers=headers)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
data = response.json() data = response.json()
assert data["email"] == test_user.email assert data["email"] == async_test_user.email
def test_get_nonexistent_user(self, client, test_superuser): @pytest.mark.asyncio
async def test_get_nonexistent_user(self, client, async_test_superuser):
"""Test getting non-existent user.""" """Test getting non-existent user."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
fake_id = uuid.uuid4() fake_id = uuid.uuid4()
response = client.get(f"/api/v1/users/{fake_id}", headers=headers) response = await client.get(f"/api/v1/users/{fake_id}", headers=headers)
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
def test_get_user_invalid_uuid(self, client, test_superuser): @pytest.mark.asyncio
async def test_get_user_invalid_uuid(self, client, async_test_superuser):
"""Test getting user with invalid UUID format.""" """Test getting user with invalid UUID format."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.get("/api/v1/users/not-a-uuid", headers=headers) response = await client.get("/api/v1/users/not-a-uuid", headers=headers)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -295,12 +316,13 @@ class TestGetUserById:
class TestUpdateUserById: class TestUpdateUserById:
"""Tests for PATCH /users/{user_id} endpoint.""" """Tests for PATCH /users/{user_id} endpoint."""
def test_update_own_profile_by_id(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_update_own_profile_by_id(self, client, async_test_user, test_db):
"""Test updating own profile by ID.""" """Test updating own profile by ID."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch( response = await client.patch(
f"/api/v1/users/{test_user.id}", f"/api/v1/users/{async_test_user.id}",
headers=headers, headers=headers,
json={"first_name": "SelfUpdated"} json={"first_name": "SelfUpdated"}
) )
@@ -309,7 +331,8 @@ class TestUpdateUserById:
data = response.json() data = response.json()
assert data["first_name"] == "SelfUpdated" assert data["first_name"] == "SelfUpdated"
def test_update_other_user_as_regular_user(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_update_other_user_as_regular_user(self, client, async_test_user, test_db):
"""Test that regular users cannot update other profiles.""" """Test that regular users cannot update other profiles."""
# Create another user # Create another user
other_user = User( other_user = User(
@@ -323,9 +346,9 @@ class TestUpdateUserById:
test_db.commit() test_db.commit()
test_db.refresh(other_user) test_db.refresh(other_user)
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch( response = await client.patch(
f"/api/v1/users/{other_user.id}", f"/api/v1/users/{other_user.id}",
headers=headers, headers=headers,
json={"first_name": "Hacked"} json={"first_name": "Hacked"}
@@ -337,12 +360,13 @@ class TestUpdateUserById:
test_db.refresh(other_user) test_db.refresh(other_user)
assert other_user.first_name == "Other" assert other_user.first_name == "Other"
def test_update_other_user_as_superuser(self, client, test_superuser, test_user, test_db): @pytest.mark.asyncio
async def test_update_other_user_as_superuser(self, client, async_test_superuser, async_test_user, test_db):
"""Test that superusers can update other profiles.""" """Test that superusers can update other profiles."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.patch( response = await client.patch(
f"/api/v1/users/{test_user.id}", f"/api/v1/users/{async_test_user.id}",
headers=headers, headers=headers,
json={"first_name": "AdminUpdated"} json={"first_name": "AdminUpdated"}
) )
@@ -351,14 +375,15 @@ class TestUpdateUserById:
data = response.json() data = response.json()
assert data["first_name"] == "AdminUpdated" assert data["first_name"] == "AdminUpdated"
def test_regular_user_cannot_modify_superuser_status(self, client, test_user): @pytest.mark.asyncio
async def test_regular_user_cannot_modify_superuser_status(self, client, async_test_user):
"""Test that regular users cannot change superuser status even if they try.""" """Test that regular users cannot change superuser status even if they try."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
# is_superuser not in UserUpdate schema, so it gets ignored by Pydantic # is_superuser not in UserUpdate schema, so it gets ignored by Pydantic
# Just verify the user stays the same # Just verify the user stays the same
response = client.patch( response = await client.patch(
f"/api/v1/users/{test_user.id}", f"/api/v1/users/{async_test_user.id}",
headers=headers, headers=headers,
json={"first_name": "Test"} json={"first_name": "Test"}
) )
@@ -367,12 +392,13 @@ class TestUpdateUserById:
data = response.json() data = response.json()
assert data["is_superuser"] is False assert data["is_superuser"] is False
def test_superuser_can_update_users(self, client, test_superuser, test_user, test_db): @pytest.mark.asyncio
async def test_superuser_can_update_users(self, client, async_test_superuser, async_test_user, test_db):
"""Test that superusers can update other users.""" """Test that superusers can update other users."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.patch( response = await client.patch(
f"/api/v1/users/{test_user.id}", f"/api/v1/users/{async_test_user.id}",
headers=headers, headers=headers,
json={"first_name": "AdminChanged", "is_active": False} json={"first_name": "AdminChanged", "is_active": False}
) )
@@ -382,12 +408,13 @@ class TestUpdateUserById:
assert data["first_name"] == "AdminChanged" assert data["first_name"] == "AdminChanged"
assert data["is_active"] is False assert data["is_active"] is False
def test_update_nonexistent_user(self, client, test_superuser): @pytest.mark.asyncio
async def test_update_nonexistent_user(self, client, async_test_superuser):
"""Test updating non-existent user.""" """Test updating non-existent user."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
fake_id = uuid.uuid4() fake_id = uuid.uuid4()
response = client.patch( response = await client.patch(
f"/api/v1/users/{fake_id}", f"/api/v1/users/{fake_id}",
headers=headers, headers=headers,
json={"first_name": "Ghost"} json={"first_name": "Ghost"}
@@ -401,11 +428,12 @@ class TestUpdateUserById:
class TestChangePassword: class TestChangePassword:
"""Tests for PATCH /users/me/password endpoint.""" """Tests for PATCH /users/me/password endpoint."""
def test_change_password_success(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_change_password_success(self, client, async_test_user, test_db):
"""Test successful password change.""" """Test successful password change."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch( response = await client.patch(
"/api/v1/users/me/password", "/api/v1/users/me/password",
headers=headers, headers=headers,
json={ json={
@@ -419,20 +447,21 @@ class TestChangePassword:
assert data["success"] is True assert data["success"] is True
# Verify can login with new password # Verify can login with new password
login_response = client.post( login_response = await client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={ json={
"email": test_user.email, "email": async_test_user.email,
"password": "NewPassword123" "password": "NewPassword123"
} }
) )
assert login_response.status_code == status.HTTP_200_OK assert login_response.status_code == status.HTTP_200_OK
def test_change_password_wrong_current(self, client, test_user): @pytest.mark.asyncio
async def test_change_password_wrong_current(self, client, async_test_user):
"""Test that wrong current password is rejected.""" """Test that wrong current password is rejected."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch( response = await client.patch(
"/api/v1/users/me/password", "/api/v1/users/me/password",
headers=headers, headers=headers,
json={ json={
@@ -443,11 +472,12 @@ class TestChangePassword:
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
def test_change_password_weak_new_password(self, client, test_user): @pytest.mark.asyncio
async def test_change_password_weak_new_password(self, client, async_test_user):
"""Test that weak new passwords are rejected.""" """Test that weak new passwords are rejected."""
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.patch( response = await client.patch(
"/api/v1/users/me/password", "/api/v1/users/me/password",
headers=headers, headers=headers,
json={ json={
@@ -458,9 +488,10 @@ class TestChangePassword:
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_change_password_no_auth(self, client): @pytest.mark.asyncio
async def test_change_password_no_auth(self, client):
"""Test that unauthenticated requests are rejected.""" """Test that unauthenticated requests are rejected."""
response = client.patch( response = await client.patch(
"/api/v1/users/me/password", "/api/v1/users/me/password",
json={ json={
"current_password": "TestPassword123", "current_password": "TestPassword123",
@@ -475,7 +506,8 @@ class TestChangePassword:
class TestDeleteUser: class TestDeleteUser:
"""Tests for DELETE /users/{user_id} endpoint.""" """Tests for DELETE /users/{user_id} endpoint."""
def test_delete_user_as_superuser(self, client, test_superuser, test_db): @pytest.mark.asyncio
async def test_delete_user_as_superuser(self, client, async_test_superuser, test_db):
"""Test deleting a user as superuser.""" """Test deleting a user as superuser."""
# Create a user to delete # Create a user to delete
user_to_delete = User( user_to_delete = User(
@@ -489,9 +521,9 @@ class TestDeleteUser:
test_db.commit() test_db.commit()
test_db.refresh(user_to_delete) test_db.refresh(user_to_delete)
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.delete(f"/api/v1/users/{user_to_delete.id}", headers=headers) response = await client.delete(f"/api/v1/users/{user_to_delete.id}", headers=headers)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
data = response.json() data = response.json()
@@ -501,15 +533,17 @@ class TestDeleteUser:
test_db.refresh(user_to_delete) test_db.refresh(user_to_delete)
assert user_to_delete.deleted_at is not None assert user_to_delete.deleted_at is not None
def test_cannot_delete_self(self, client, test_superuser): @pytest.mark.asyncio
async def test_cannot_delete_self(self, client, async_test_superuser):
"""Test that users cannot delete their own account.""" """Test that users cannot delete their own account."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
response = client.delete(f"/api/v1/users/{test_superuser.id}", headers=headers) response = await client.delete(f"/api/v1/users/{async_test_superuser.id}", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
def test_delete_user_as_regular_user(self, client, test_user, test_db): @pytest.mark.asyncio
async def test_delete_user_as_regular_user(self, client, async_test_user, test_db):
"""Test that regular users cannot delete users.""" """Test that regular users cannot delete users."""
# Create another user # Create another user
other_user = User( other_user = User(
@@ -523,24 +557,26 @@ class TestDeleteUser:
test_db.commit() test_db.commit()
test_db.refresh(other_user) test_db.refresh(other_user)
headers = get_auth_headers(client, test_user.email, "TestPassword123") headers = await get_auth_headers(client, async_test_user.email, "TestPassword123")
response = client.delete(f"/api/v1/users/{other_user.id}", headers=headers) response = await client.delete(f"/api/v1/users/{other_user.id}", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
def test_delete_nonexistent_user(self, client, test_superuser): @pytest.mark.asyncio
async def test_delete_nonexistent_user(self, client, async_test_superuser):
"""Test deleting non-existent user.""" """Test deleting non-existent user."""
headers = get_auth_headers(client, test_superuser.email, "SuperPassword123") headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123")
fake_id = uuid.uuid4() fake_id = uuid.uuid4()
response = client.delete(f"/api/v1/users/{fake_id}", headers=headers) response = await client.delete(f"/api/v1/users/{fake_id}", headers=headers)
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_user_no_auth(self, client, test_user): @pytest.mark.asyncio
async def test_delete_user_no_auth(self, client, async_test_user):
"""Test that unauthenticated requests are rejected.""" """Test that unauthenticated requests are rejected."""
response = client.delete(f"/api/v1/users/{test_user.id}") response = await client.delete(f"/api/v1/users/{async_test_user.id}")
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Note: Removed test_delete_user_unexpected_error - see comment above # Note: Removed test_delete_user_unexpected_error - see comment above

90
backend/tests/conftest.py Normal file → Executable file
View File

@@ -4,14 +4,15 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
import pytest import pytest
from fastapi.testclient import TestClient import pytest_asyncio
from httpx import AsyncClient
# Set IS_TEST environment variable BEFORE importing app # Set IS_TEST environment variable BEFORE importing app
# This prevents the scheduler from starting during tests # This prevents the scheduler from starting during tests
os.environ["IS_TEST"] = "True" os.environ["IS_TEST"] = "True"
from app.main import app from app.main import app
from app.core.database import get_db from app.core.database_async import get_async_db
from app.models.user import User from app.models.user import User
from app.core.auth import get_password_hash from app.core.auth import get_password_hash
from app.utils.test_utils import setup_test_db, teardown_test_db, setup_async_test_db, teardown_async_test_db from app.utils.test_utils import setup_test_db, teardown_test_db, setup_async_test_db, teardown_async_test_db
@@ -35,7 +36,7 @@ def db_session():
teardown_test_db(test_engine) teardown_test_db(test_engine)
@pytest.fixture(scope="function") # Define a fixture @pytest_asyncio.fixture(scope="function") # Define a fixture
async def async_test_db(): async def async_test_db():
"""Fixture provides new testing engine and session for each test run to improve isolation.""" """Fixture provides new testing engine and session for each test run to improve isolation."""
@@ -92,22 +93,25 @@ def test_db():
teardown_test_db(test_engine) teardown_test_db(test_engine)
@pytest.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
def client(test_db): async def client(async_test_db):
""" """
Create a FastAPI test client with a test database. Create a FastAPI async test client with a test database.
This overrides the get_db dependency to use the test database. This overrides the get_async_db dependency to use the test database.
""" """
def override_get_db(): test_engine, AsyncTestingSessionLocal = async_test_db
try:
yield test_db
finally:
pass
app.dependency_overrides[get_db] = override_get_db async def override_get_async_db():
async with AsyncTestingSessionLocal() as session:
try:
yield session
finally:
pass
with TestClient(app) as test_client: app.dependency_overrides[get_async_db] = override_get_async_db
async with AsyncClient(app=app, base_url="http://test") as test_client:
yield test_client yield test_client
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -116,7 +120,7 @@ def client(test_db):
@pytest.fixture @pytest.fixture
def test_user(test_db): def test_user(test_db):
""" """
Create a test user in the database. Create a test user in the database (sync version for legacy tests).
Password: TestPassword123 Password: TestPassword123
""" """
@@ -140,7 +144,7 @@ def test_user(test_db):
@pytest.fixture @pytest.fixture
def test_superuser(test_db): def test_superuser(test_db):
""" """
Create a test superuser in the database. Create a test superuser in the database (sync version for legacy tests).
Password: SuperPassword123 Password: SuperPassword123
""" """
@@ -158,4 +162,56 @@ def test_superuser(test_db):
test_db.add(user) test_db.add(user)
test_db.commit() test_db.commit()
test_db.refresh(user) test_db.refresh(user)
return user return user
@pytest_asyncio.fixture
async def async_test_user(async_test_db):
"""
Create a test user in the database (async version).
Password: TestPassword123
"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid.uuid4(),
email="testuser@example.com",
password_hash=get_password_hash("TestPassword123"),
first_name="Test",
last_name="User",
phone_number="+1234567890",
is_active=True,
is_superuser=False,
preferences=None,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest_asyncio.fixture
async def async_test_superuser(async_test_db):
"""
Create a test superuser in the database (async version).
Password: SuperPassword123
"""
test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid.uuid4(),
email="superuser@example.com",
password_hash=get_password_hash("SuperPassword123"),
first_name="Super",
last_name="User",
phone_number="+9876543210",
is_active=True,
is_superuser=True,
preferences=None,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user

0
backend/tests/core/__init__.py Normal file → Executable file
View File

0
backend/tests/core/test_auth.py Normal file → Executable file
View File

0
backend/tests/core/test_config.py Normal file → Executable file
View File

0
backend/tests/crud/__init__.py Normal file → Executable file
View File

0
backend/tests/crud/test_crud_base.py Normal file → Executable file
View File

0
backend/tests/crud/test_crud_error_paths.py Normal file → Executable file
View File

0
backend/tests/crud/test_soft_delete.py Normal file → Executable file
View File

0
backend/tests/crud/test_user.py Normal file → Executable file
View File

0
backend/tests/models/__init__.py Normal file → Executable file
View File

0
backend/tests/models/test_user.py Normal file → Executable file
View File

0
backend/tests/schemas/__init__.py Normal file → Executable file
View File

0
backend/tests/schemas/test_user_schemas.py Normal file → Executable file
View File

0
backend/tests/services/__init__.py Normal file → Executable file
View File

0
backend/tests/services/test_auth_service.py Normal file → Executable file
View File

0
backend/tests/services/test_email_service.py Normal file → Executable file
View File

0
backend/tests/test_init_db.py Normal file → Executable file
View File

0
backend/tests/utils/__init__.py Normal file → Executable file
View File

0
backend/tests/utils/test_security.py Normal file → Executable file
View File