Add pyproject.toml for consolidated project configuration and replace Black, isort, and Flake8 with Ruff

- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest).
- Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting.
- Updated `requirements.txt` to include Ruff and remove replaced tools.
- Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
This commit is contained in:
2025-11-10 11:55:15 +01:00
parent a5c671c133
commit c589b565f0
86 changed files with 4572 additions and 3956 deletions

View File

@@ -1,15 +1,16 @@
# tests/api/dependencies/test_auth_dependencies.py
import pytest
import pytest_asyncio
import uuid
from unittest.mock import patch
import pytest
import pytest_asyncio
from fastapi import HTTPException
from app.api.dependencies.auth import (
get_current_user,
get_current_active_user,
get_current_superuser,
get_optional_current_user
get_current_user,
get_optional_current_user,
)
from app.core.auth import TokenExpiredError, TokenInvalidError, get_password_hash
from app.models.user import User
@@ -24,7 +25,7 @@ def mock_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
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
mock_user = User(
id=uuid.uuid4(),
@@ -47,12 +48,14 @@ class TestGetCurrentUser:
"""Tests for get_current_user dependency"""
@pytest.mark.asyncio
async def test_get_current_user_success(self, async_test_db, async_mock_user, mock_token):
async def test_get_current_user_success(
self, async_test_db, async_mock_user, mock_token
):
"""Test successfully getting the current user"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# 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:
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
@@ -65,12 +68,12 @@ class TestGetCurrentUser:
@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_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
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
# Should raise HTTPException with 404 status
@@ -81,19 +84,24 @@ class TestGetCurrentUser:
assert "User not found" in exc_info.value.detail
@pytest.mark.asyncio
async def test_get_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
async def test_get_current_user_inactive(
self, async_test_db, async_mock_user, mock_token
):
"""Test when the user is inactive"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# 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))
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
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 = async_mock_user.id
# Should raise HTTPException with 403 status
@@ -106,10 +114,10 @@ class TestGetCurrentUser:
@pytest.mark.asyncio
async def test_get_current_user_expired_token(self, async_test_db, mock_token):
"""Test with an expired token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenExpiredError
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.side_effect = TokenExpiredError("Token expired")
# Should raise HTTPException with 401 status
@@ -122,10 +130,10 @@ class TestGetCurrentUser:
@pytest.mark.asyncio
async def test_get_current_user_invalid_token(self, async_test_db, mock_token):
"""Test with an invalid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenInvalidError
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.side_effect = TokenInvalidError("Invalid token")
# Should raise HTTPException with 401 status
@@ -194,12 +202,14 @@ class TestGetOptionalCurrentUser:
"""Tests for get_optional_current_user dependency"""
@pytest.mark.asyncio
async def test_get_optional_current_user_with_token(self, async_test_db, async_mock_user, mock_token):
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_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# 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 = async_mock_user.id
# Call the dependency
@@ -212,7 +222,7 @@ class TestGetOptionalCurrentUser:
@pytest.mark.asyncio
async def test_get_optional_current_user_no_token(self, async_test_db):
"""Test getting optional user with no token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Call the dependency with no token
user = await get_optional_current_user(db=session, token=None)
@@ -221,12 +231,14 @@ class TestGetOptionalCurrentUser:
assert user is None
@pytest.mark.asyncio
async def test_get_optional_current_user_invalid_token(self, async_test_db, mock_token):
async def test_get_optional_current_user_invalid_token(
self, async_test_db, mock_token
):
"""Test getting optional user with an invalid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenInvalidError
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.side_effect = TokenInvalidError("Invalid token")
# Call the dependency
@@ -236,12 +248,14 @@ class TestGetOptionalCurrentUser:
assert user is None
@pytest.mark.asyncio
async def test_get_optional_current_user_expired_token(self, async_test_db, mock_token):
async def test_get_optional_current_user_expired_token(
self, async_test_db, mock_token
):
"""Test getting optional user with an expired token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenExpiredError
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.side_effect = TokenExpiredError("Token expired")
# Call the dependency
@@ -251,19 +265,24 @@ class TestGetOptionalCurrentUser:
assert user is None
@pytest.mark.asyncio
async def test_get_optional_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
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_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# 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))
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
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 = async_mock_user.id
# Call the dependency

View File

@@ -1,13 +1,12 @@
# tests/api/routes/test_health.py
from datetime import datetime
from unittest.mock import patch
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from fastapi import status
from fastapi.testclient import TestClient
from datetime import datetime
from sqlalchemy.exc import OperationalError
from app.main import app
from app.core.database import get_db
@pytest.fixture
@@ -121,7 +120,10 @@ class TestHealthEndpoint:
response = client.get("/health")
# Should succeed without authentication
assert response.status_code in [status.HTTP_200_OK, status.HTTP_503_SERVICE_UNAVAILABLE]
assert response.status_code in [
status.HTTP_200_OK,
status.HTTP_503_SERVICE_UNAVAILABLE,
]
def test_health_check_idempotent(self, client):
"""Test that multiple health checks return consistent results"""
@@ -142,7 +144,10 @@ class TestHealthEndpoint:
assert data1["environment"] == data2["environment"]
# Same database check status
assert data1["checks"]["database"]["status"] == data2["checks"]["database"]["status"]
assert (
data1["checks"]["database"]["status"]
== data2["checks"]["database"]["status"]
)
def test_health_check_content_type(self, client):
"""Test that health check returns JSON content type"""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
"""
Tests for authentication endpoints.
"""
import pytest
import pytest_asyncio
from fastapi import status
@@ -19,8 +20,8 @@ class TestRegisterEndpoint:
"email": "newuser@example.com",
"password": "NewPassword123!",
"first_name": "New",
"last_name": "User"
}
"last_name": "User",
},
)
assert response.status_code == status.HTTP_201_CREATED
@@ -36,8 +37,8 @@ class TestRegisterEndpoint:
"email": async_test_user.email,
"password": "TestPassword123!",
"first_name": "Test",
"last_name": "User"
}
"last_name": "User",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -51,8 +52,8 @@ class TestRegisterEndpoint:
"email": "test@example.com",
"password": "weak",
"first_name": "Test",
"last_name": "User"
}
"last_name": "User",
},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -66,10 +67,7 @@ class TestLoginEndpoint:
"""Test successful login."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
assert response.status_code == status.HTTP_200_OK
@@ -82,10 +80,7 @@ class TestLoginEndpoint:
"""Test login with invalid password."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "WrongPassword123!"
}
json={"email": "testuser@example.com", "password": "WrongPassword123!"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -95,10 +90,7 @@ class TestLoginEndpoint:
"""Test login with non-existent user."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "nonexistent@example.com",
"password": "TestPassword123!"
}
json={"email": "nonexistent@example.com", "password": "TestPassword123!"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -106,27 +98,25 @@ class TestLoginEndpoint:
@pytest.mark.asyncio
async def test_login_inactive_user(self, client, async_test_db):
"""Test login with inactive user."""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
async with SessionLocal() as session:
from app.models.user import User
from app.core.auth import get_password_hash
from app.models.user import User
inactive_user = User(
email="inactive@example.com",
password_hash=get_password_hash("TestPassword123!"),
first_name="Inactive",
last_name="User",
is_active=False
is_active=False,
)
session.add(inactive_user)
await session.commit()
response = await client.post(
"/api/v1/auth/login",
json={
"email": "inactive@example.com",
"password": "TestPassword123!"
}
json={"email": "inactive@example.com", "password": "TestPassword123!"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -140,10 +130,7 @@ class TestRefreshTokenEndpoint:
"""Get a refresh token for testing."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
return response.json()["refresh_token"]
@@ -151,8 +138,7 @@ class TestRefreshTokenEndpoint:
async def test_refresh_token_success(self, client, refresh_token):
"""Test successful token refresh."""
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token}
"/api/v1/auth/refresh", json={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_200_OK
@@ -164,8 +150,7 @@ class TestRefreshTokenEndpoint:
async def test_refresh_token_invalid(self, client):
"""Test refresh with invalid token."""
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "invalid.token.here"}
"/api/v1/auth/refresh", json={"refresh_token": "invalid.token.here"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -179,13 +164,13 @@ class TestLogoutEndpoint:
"""Get tokens for testing."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
data = response.json()
return {"access_token": data["access_token"], "refresh_token": data["refresh_token"]}
return {
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
}
@pytest.mark.asyncio
async def test_logout_success(self, client, tokens):
@@ -193,7 +178,7 @@ class TestLogoutEndpoint:
response = await client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
json={"refresh_token": tokens["refresh_token"]}
json={"refresh_token": tokens["refresh_token"]},
)
assert response.status_code == status.HTTP_200_OK
@@ -202,8 +187,7 @@ class TestLogoutEndpoint:
async def test_logout_without_auth(self, client):
"""Test logout without authentication."""
response = await client.post(
"/api/v1/auth/logout",
json={"refresh_token": "some.token"}
"/api/v1/auth/logout", json={"refresh_token": "some.token"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -215,8 +199,7 @@ class TestPasswordResetRequest:
async def test_password_reset_request_success(self, client, async_test_user):
"""Test password reset request with existing user."""
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": async_test_user.email}
"/api/v1/auth/password-reset/request", json={"email": async_test_user.email}
)
assert response.status_code == status.HTTP_200_OK
@@ -228,7 +211,7 @@ class TestPasswordResetRequest:
"""Test password reset request with non-existent email."""
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": "nonexistent@example.com"}
json={"email": "nonexistent@example.com"},
)
assert response.status_code == status.HTTP_200_OK
@@ -244,10 +227,7 @@ class TestPasswordResetConfirm:
"""Test password reset with invalid token."""
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": "invalid.token.here",
"new_password": "NewPassword123!"
}
json={"token": "invalid.token.here", "new_password": "NewPassword123!"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -261,20 +241,20 @@ class TestLogoutAll:
"""Get tokens for testing."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
data = response.json()
return {"access_token": data["access_token"], "refresh_token": data["refresh_token"]}
return {
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
}
@pytest.mark.asyncio
async def test_logout_all_success(self, client, tokens):
"""Test logout from all devices."""
response = await client.post(
"/api/v1/auth/logout-all",
headers={"Authorization": f"Bearer {tokens['access_token']}"}
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -298,10 +278,7 @@ class TestOAuthLogin:
"""Test successful OAuth login."""
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": "testuser@example.com",
"password": "TestPassword123!"
}
data={"username": "testuser@example.com", "password": "TestPassword123!"},
)
assert response.status_code == status.HTTP_200_OK
@@ -315,10 +292,7 @@ class TestOAuthLogin:
"""Test OAuth login with invalid credentials."""
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": "testuser@example.com",
"password": "WrongPassword"
}
data={"username": "testuser@example.com", "password": "WrongPassword"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View File

@@ -1,15 +1,16 @@
# tests/api/dependencies/test_auth_dependencies.py
import pytest
import pytest_asyncio
import uuid
from unittest.mock import patch
import pytest
import pytest_asyncio
from fastapi import HTTPException
from app.api.dependencies.auth import (
get_current_user,
get_current_active_user,
get_current_superuser,
get_optional_current_user
get_current_user,
get_optional_current_user,
)
from app.core.auth import TokenExpiredError, TokenInvalidError, get_password_hash
from app.models.user import User
@@ -24,7 +25,7 @@ def mock_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
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
mock_user = User(
id=uuid.uuid4(),
@@ -47,12 +48,14 @@ class TestGetCurrentUser:
"""Tests for get_current_user dependency"""
@pytest.mark.asyncio
async def test_get_current_user_success(self, async_test_db, async_mock_user, mock_token):
async def test_get_current_user_success(
self, async_test_db, async_mock_user, mock_token
):
"""Test successfully getting the current user"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# 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:
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
@@ -65,12 +68,12 @@ class TestGetCurrentUser:
@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_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
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
# Should raise HTTPException with 404 status
@@ -81,19 +84,24 @@ class TestGetCurrentUser:
assert "User not found" in exc_info.value.detail
@pytest.mark.asyncio
async def test_get_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
async def test_get_current_user_inactive(
self, async_test_db, async_mock_user, mock_token
):
"""Test when the user is inactive"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# 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))
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
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 = async_mock_user.id
# Should raise HTTPException with 403 status
@@ -106,10 +114,10 @@ class TestGetCurrentUser:
@pytest.mark.asyncio
async def test_get_current_user_expired_token(self, async_test_db, mock_token):
"""Test with an expired token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenExpiredError
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.side_effect = TokenExpiredError("Token expired")
# Should raise HTTPException with 401 status
@@ -122,10 +130,10 @@ class TestGetCurrentUser:
@pytest.mark.asyncio
async def test_get_current_user_invalid_token(self, async_test_db, mock_token):
"""Test with an invalid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenInvalidError
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.side_effect = TokenInvalidError("Invalid token")
# Should raise HTTPException with 401 status
@@ -194,12 +202,14 @@ class TestGetOptionalCurrentUser:
"""Tests for get_optional_current_user dependency"""
@pytest.mark.asyncio
async def test_get_optional_current_user_with_token(self, async_test_db, async_mock_user, mock_token):
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_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# 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 = async_mock_user.id
# Call the dependency
@@ -212,7 +222,7 @@ class TestGetOptionalCurrentUser:
@pytest.mark.asyncio
async def test_get_optional_current_user_no_token(self, async_test_db):
"""Test getting optional user with no token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Call the dependency with no token
user = await get_optional_current_user(db=session, token=None)
@@ -221,12 +231,14 @@ class TestGetOptionalCurrentUser:
assert user is None
@pytest.mark.asyncio
async def test_get_optional_current_user_invalid_token(self, async_test_db, mock_token):
async def test_get_optional_current_user_invalid_token(
self, async_test_db, mock_token
):
"""Test getting optional user with an invalid token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenInvalidError
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.side_effect = TokenInvalidError("Invalid token")
# Call the dependency
@@ -236,12 +248,14 @@ class TestGetOptionalCurrentUser:
assert user is None
@pytest.mark.asyncio
async def test_get_optional_current_user_expired_token(self, async_test_db, mock_token):
async def test_get_optional_current_user_expired_token(
self, async_test_db, mock_token
):
"""Test getting optional user with an expired token"""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Mock get_token_data to raise TokenExpiredError
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.side_effect = TokenExpiredError("Token expired")
# Call the dependency
@@ -251,19 +265,24 @@ class TestGetOptionalCurrentUser:
assert user is None
@pytest.mark.asyncio
async def test_get_optional_current_user_inactive(self, async_test_db, async_mock_user, mock_token):
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_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# 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))
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
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 = async_mock_user.id
# Call the dependency

View File

@@ -2,21 +2,21 @@
"""
Tests for authentication endpoints.
"""
from unittest.mock import patch
import pytest
import pytest_asyncio
from unittest.mock import patch, MagicMock
from fastapi import status
from sqlalchemy import select
from app.models.user import User
from app.schemas.users import UserCreate
# Disable rate limiting for tests
@pytest.fixture(autouse=True)
def disable_rate_limit():
"""Disable rate limiting for all tests in this module."""
with patch('app.api.routes.auth.limiter.enabled', False):
with patch("app.api.routes.auth.limiter.enabled", False):
yield
@@ -32,8 +32,8 @@ class TestRegisterEndpoint:
"email": "newuser@example.com",
"password": "SecurePassword123!",
"first_name": "New",
"last_name": "User"
}
"last_name": "User",
},
)
assert response.status_code == status.HTTP_201_CREATED
@@ -54,8 +54,8 @@ class TestRegisterEndpoint:
"email": async_test_user.email,
"password": "SecurePassword123!",
"first_name": "Duplicate",
"last_name": "User"
}
"last_name": "User",
},
)
# Security: Returns 400 with generic message to prevent email enumeration
@@ -73,8 +73,8 @@ class TestRegisterEndpoint:
"email": "weakpass@example.com",
"password": "weak",
"first_name": "Weak",
"last_name": "Pass"
}
"last_name": "Pass",
},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -82,7 +82,7 @@ class TestRegisterEndpoint:
@pytest.mark.asyncio
async def test_register_unexpected_error(self, client):
"""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")
response = await client.post(
@@ -91,8 +91,8 @@ class TestRegisterEndpoint:
"email": "error@example.com",
"password": "SecurePassword123!",
"first_name": "Error",
"last_name": "User"
}
"last_name": "User",
},
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -106,10 +106,7 @@ class TestLoginEndpoint:
"""Test successful login."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123!"
}
json={"email": async_test_user.email, "password": "TestPassword123!"},
)
assert response.status_code == status.HTTP_200_OK
@@ -123,10 +120,7 @@ class TestLoginEndpoint:
"""Test login with wrong password."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "WrongPassword123"
}
json={"email": async_test_user.email, "password": "WrongPassword123"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -136,10 +130,7 @@ class TestLoginEndpoint:
"""Test login with non-existent email."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "nonexistent@example.com",
"password": "Password123!"
}
json={"email": "nonexistent@example.com", "password": "Password123!"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -147,20 +138,19 @@ class TestLoginEndpoint:
@pytest.mark.asyncio
async def test_login_inactive_user(self, client, async_test_user, async_test_db):
"""Test login with inactive user."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Get the user in this session and make it inactive
result = await session.execute(select(User).where(User.id == async_test_user.id))
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 = await client.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123!"
}
json={"email": async_test_user.email, "password": "TestPassword123!"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -168,15 +158,14 @@ class TestLoginEndpoint:
@pytest.mark.asyncio
async def test_login_unexpected_error(self, client, async_test_user):
"""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")
response = await client.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123!"
}
json={"email": async_test_user.email, "password": "TestPassword123!"},
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -190,10 +179,7 @@ class TestOAuthLoginEndpoint:
"""Test successful OAuth login."""
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": async_test_user.email,
"password": "TestPassword123!"
}
data={"username": async_test_user.email, "password": "TestPassword123!"},
)
assert response.status_code == status.HTTP_200_OK
@@ -206,31 +192,29 @@ class TestOAuthLoginEndpoint:
"""Test OAuth login with wrong credentials."""
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": async_test_user.email,
"password": "WrongPassword"
}
data={"username": async_test_user.email, "password": "WrongPassword"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_oauth_login_inactive_user(self, client, async_test_user, async_test_db):
async def test_oauth_login_inactive_user(
self, client, async_test_user, async_test_db
):
"""Test OAuth login with inactive user."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
# Get the user in this session and make it inactive
result = await session.execute(select(User).where(User.id == async_test_user.id))
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 = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": async_test_user.email,
"password": "TestPassword123!"
}
data={"username": async_test_user.email, "password": "TestPassword123!"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -238,15 +222,17 @@ class TestOAuthLoginEndpoint:
@pytest.mark.asyncio
async def test_oauth_login_unexpected_error(self, client, async_test_user):
"""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")
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": async_test_user.email,
"password": "TestPassword123!"
}
"password": "TestPassword123!",
},
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -261,17 +247,13 @@ class TestRefreshTokenEndpoint:
# First, login to get a refresh token
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123!"
}
json={"email": async_test_user.email, "password": "TestPassword123!"},
)
refresh_token = login_response.json()["refresh_token"]
# Now refresh the token
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token}
"/api/v1/auth/refresh", json={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_200_OK
@@ -284,12 +266,13 @@ class TestRefreshTokenEndpoint:
"""Test refresh with expired token."""
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")
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "some_token"}
"/api/v1/auth/refresh", json={"refresh_token": "some_token"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -298,8 +281,7 @@ class TestRefreshTokenEndpoint:
async def test_refresh_token_invalid(self, client):
"""Test refresh with invalid token."""
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "invalid_token"}
"/api/v1/auth/refresh", json={"refresh_token": "invalid_token"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -310,19 +292,17 @@ class TestRefreshTokenEndpoint:
# Get a valid refresh token first
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "TestPassword123!"
}
json={"email": async_test_user.email, "password": "TestPassword123!"},
)
refresh_token = login_response.json()["refresh_token"]
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")
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token}
"/api/v1/auth/refresh", json={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR

View File

@@ -2,8 +2,10 @@
"""
Tests for auth route exception handlers and error paths.
"""
from unittest.mock import patch
import pytest
from unittest.mock import patch, AsyncMock
from fastapi import status
@@ -11,16 +13,18 @@ class TestLoginSessionCreationFailure:
"""Test login when session creation fails."""
@pytest.mark.asyncio
async def test_login_succeeds_despite_session_creation_failure(self, client, async_test_user):
async def test_login_succeeds_despite_session_creation_failure(
self, client, async_test_user
):
"""Test that login succeeds even if session creation fails."""
# Mock session creation to fail
with patch('app.api.routes.auth.session_crud.create_session', side_effect=Exception("Session creation failed")):
with patch(
"app.api.routes.auth.session_crud.create_session",
side_effect=Exception("Session creation failed"),
):
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
# Login should still succeed, just without session record
@@ -34,15 +38,20 @@ class TestOAuthLoginSessionCreationFailure:
"""Test OAuth login when session creation fails."""
@pytest.mark.asyncio
async def test_oauth_login_succeeds_despite_session_failure(self, client, async_test_user):
async def test_oauth_login_succeeds_despite_session_failure(
self, client, async_test_user
):
"""Test OAuth login succeeds even if session creation fails."""
with patch('app.api.routes.auth.session_crud.create_session', side_effect=Exception("Session failed")):
with patch(
"app.api.routes.auth.session_crud.create_session",
side_effect=Exception("Session failed"),
):
response = await client.post(
"/api/v1/auth/login/oauth",
data={
"username": "testuser@example.com",
"password": "TestPassword123!"
}
"password": "TestPassword123!",
},
)
assert response.status_code == status.HTTP_200_OK
@@ -54,23 +63,24 @@ class TestRefreshTokenSessionUpdateFailure:
"""Test refresh token when session update fails."""
@pytest.mark.asyncio
async def test_refresh_token_succeeds_despite_session_update_failure(self, client, async_test_user):
async def test_refresh_token_succeeds_despite_session_update_failure(
self, client, async_test_user
):
"""Test that token refresh succeeds even if session update fails."""
# First login to get tokens
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
tokens = response.json()
# Mock session update to fail
with patch('app.api.routes.auth.session_crud.update_refresh_token', side_effect=Exception("Update failed")):
with patch(
"app.api.routes.auth.session_crud.update_refresh_token",
side_effect=Exception("Update failed"),
):
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": tokens["refresh_token"]}
"/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]}
)
# Should still succeed - tokens are issued before update
@@ -83,15 +93,14 @@ class TestLogoutWithExpiredToken:
"""Test logout with expired/invalid token."""
@pytest.mark.asyncio
async def test_logout_with_invalid_token_still_succeeds(self, client, async_test_user):
async def test_logout_with_invalid_token_still_succeeds(
self, client, async_test_user
):
"""Test logout succeeds even with invalid refresh token."""
# Login first
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
access_token = response.json()["access_token"]
@@ -99,7 +108,7 @@ class TestLogoutWithExpiredToken:
response = await client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {access_token}"},
json={"refresh_token": "invalid.token.here"}
json={"refresh_token": "invalid.token.here"},
)
# Should succeed (idempotent)
@@ -116,19 +125,16 @@ class TestLogoutWithNonExistentSession:
"""Test logout succeeds even if session not found."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
tokens = response.json()
# Mock session lookup to return None
with patch('app.api.routes.auth.session_crud.get_by_jti', return_value=None):
with patch("app.api.routes.auth.session_crud.get_by_jti", return_value=None):
response = await client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
json={"refresh_token": tokens["refresh_token"]}
json={"refresh_token": tokens["refresh_token"]},
)
# Should succeed (idempotent)
@@ -139,23 +145,25 @@ class TestLogoutUnexpectedError:
"""Test logout with unexpected errors."""
@pytest.mark.asyncio
async def test_logout_with_unexpected_error_returns_success(self, client, async_test_user):
async def test_logout_with_unexpected_error_returns_success(
self, client, async_test_user
):
"""Test logout returns success even on unexpected errors."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
tokens = response.json()
# Mock to raise unexpected error
with patch('app.api.routes.auth.session_crud.get_by_jti', side_effect=Exception("Unexpected error")):
with patch(
"app.api.routes.auth.session_crud.get_by_jti",
side_effect=Exception("Unexpected error"),
):
response = await client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
json={"refresh_token": tokens["refresh_token"]}
json={"refresh_token": tokens["refresh_token"]},
)
# Should still return success (don't expose errors)
@@ -172,18 +180,18 @@ class TestLogoutAllUnexpectedError:
"""Test logout-all handles database errors."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
access_token = response.json()["access_token"]
# Mock to raise database error
with patch('app.api.routes.auth.session_crud.deactivate_all_user_sessions', side_effect=Exception("DB error")):
with patch(
"app.api.routes.auth.session_crud.deactivate_all_user_sessions",
side_effect=Exception("DB error"),
):
response = await client.post(
"/api/v1/auth/logout-all",
headers={"Authorization": f"Bearer {access_token}"}
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -193,7 +201,9 @@ class TestPasswordResetConfirmSessionInvalidation:
"""Test password reset invalidates sessions."""
@pytest.mark.asyncio
async def test_password_reset_continues_despite_session_invalidation_failure(self, client, async_test_user):
async def test_password_reset_continues_despite_session_invalidation_failure(
self, client, async_test_user
):
"""Test password reset succeeds even if session invalidation fails."""
# Create a valid password reset token
from app.utils.security import create_password_reset_token
@@ -201,13 +211,13 @@ class TestPasswordResetConfirmSessionInvalidation:
token = create_password_reset_token(async_test_user.email)
# Mock session invalidation to fail
with patch('app.api.routes.auth.session_crud.deactivate_all_user_sessions', side_effect=Exception("Invalidation failed")):
with patch(
"app.api.routes.auth.session_crud.deactivate_all_user_sessions",
side_effect=Exception("Invalidation failed"),
):
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": "NewPassword123!"
}
json={"token": token, "new_password": "NewPassword123!"},
)
# Should still succeed - password was reset

View File

@@ -2,22 +2,22 @@
"""
Tests for password reset endpoints.
"""
from unittest.mock import patch
import pytest
import pytest_asyncio
from unittest.mock import patch, AsyncMock, MagicMock
from fastapi import status
from sqlalchemy import select
from app.schemas.users import PasswordResetRequest, PasswordResetConfirm
from app.utils.security import create_password_reset_token
from app.models.user import User
from app.utils.security import create_password_reset_token
# Disable rate limiting for tests
@pytest.fixture(autouse=True)
def disable_rate_limit():
"""Disable rate limiting for all tests in this module."""
with patch('app.api.routes.auth.limiter.enabled', False):
with patch("app.api.routes.auth.limiter.enabled", False):
yield
@@ -27,12 +27,14 @@ class TestPasswordResetRequest:
@pytest.mark.asyncio
async def test_password_reset_request_valid_email(self, client, async_test_user):
"""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
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": async_test_user.email}
json={"email": async_test_user.email},
)
assert response.status_code == status.HTTP_200_OK
@@ -50,10 +52,12 @@ class TestPasswordResetRequest:
@pytest.mark.asyncio
async def test_password_reset_request_nonexistent_email(self, client):
"""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 = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": "nonexistent@example.com"}
json={"email": "nonexistent@example.com"},
)
# Should still return success to prevent email enumeration
@@ -65,20 +69,26 @@ class TestPasswordResetRequest:
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_password_reset_request_inactive_user(self, client, async_test_db, async_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."""
# Deactivate user
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
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 = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": async_test_user.email}
json={"email": async_test_user.email},
)
# Should still return success to prevent email enumeration
@@ -93,8 +103,7 @@ class TestPasswordResetRequest:
async def test_password_reset_request_invalid_email_format(self, client):
"""Test password reset request with invalid email format."""
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": "not-an-email"}
"/api/v1/auth/password-reset/request", json={"email": "not-an-email"}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -102,22 +111,23 @@ class TestPasswordResetRequest:
@pytest.mark.asyncio
async def test_password_reset_request_missing_email(self, client):
"""Test password reset request without email."""
response = await client.post(
"/api/v1/auth/password-reset/request",
json={}
)
response = await client.post("/api/v1/auth/password-reset/request", json={})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_password_reset_request_email_service_error(self, client, async_test_user):
async def test_password_reset_request_email_service_error(
self, client, async_test_user
):
"""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")
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": async_test_user.email}
json={"email": async_test_user.email},
)
# Should still return success even if email fails
@@ -128,14 +138,16 @@ class TestPasswordResetRequest:
@pytest.mark.asyncio
async def test_password_reset_request_rate_limiting(self, client, async_test_user):
"""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
# Make multiple requests quickly (3/minute limit)
for _ in range(3):
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": async_test_user.email}
json={"email": async_test_user.email},
)
assert response.status_code == status.HTTP_200_OK
@@ -144,7 +156,9 @@ class TestPasswordResetConfirm:
"""Tests for POST /auth/password-reset/confirm endpoint."""
@pytest.mark.asyncio
async def test_password_reset_confirm_valid_token(self, client, async_test_user, async_test_db):
async def test_password_reset_confirm_valid_token(
self, client, async_test_user, async_test_db
):
"""Test password reset confirmation with valid token."""
# Generate valid token
token = create_password_reset_token(async_test_user.email)
@@ -152,10 +166,7 @@ class TestPasswordResetConfirm:
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": new_password
}
json={"token": token, "new_password": new_password},
)
assert response.status_code == status.HTTP_200_OK
@@ -164,11 +175,14 @@ class TestPasswordResetConfirm:
assert "successfully" in data["message"].lower()
# Verify user can login with new password
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
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
@pytest.mark.asyncio
@@ -184,10 +198,7 @@ class TestPasswordResetConfirm:
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": "NewSecure123!"
}
json={"token": token, "new_password": "NewSecure123!"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -202,10 +213,7 @@ class TestPasswordResetConfirm:
"""Test password reset confirmation with invalid token."""
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": "invalid_token_xyz",
"new_password": "NewSecure123!"
}
json={"token": "invalid_token_xyz", "new_password": "NewSecure123!"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -222,19 +230,18 @@ class TestPasswordResetConfirm:
# Create valid token and tamper with it
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["payload"]["email"] = "hacker@example.com"
# 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 = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": tampered,
"new_password": "NewSecure123!"
}
json={"token": tampered, "new_password": "NewSecure123!"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -247,10 +254,7 @@ class TestPasswordResetConfirm:
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": "NewSecure123!"
}
json={"token": token, "new_password": "NewSecure123!"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -260,12 +264,16 @@ class TestPasswordResetConfirm:
assert "not found" in error_msg
@pytest.mark.asyncio
async def test_password_reset_confirm_inactive_user(self, client, async_test_user, async_test_db):
async def test_password_reset_confirm_inactive_user(
self, client, async_test_user, async_test_db
):
"""Test password reset confirmation for inactive user."""
# Deactivate user
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
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()
@@ -274,10 +282,7 @@ class TestPasswordResetConfirm:
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": "NewSecure123!"
}
json={"token": token, "new_password": "NewSecure123!"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -301,10 +306,7 @@ class TestPasswordResetConfirm:
for weak_password in weak_passwords:
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": weak_password
}
json={"token": token, "new_password": weak_password},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -315,15 +317,14 @@ class TestPasswordResetConfirm:
# Missing token
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={"new_password": "NewSecure123!"}
json={"new_password": "NewSecure123!"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
# Missing password
token = create_password_reset_token("test@example.com")
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={"token": token}
"/api/v1/auth/password-reset/confirm", json={"token": token}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -333,15 +334,12 @@ class TestPasswordResetConfirm:
token = create_password_reset_token(async_test_user.email)
# Mock the database commit to raise an exception
with patch('app.api.routes.auth.user_crud.get_by_email') as mock_get:
with patch("app.api.routes.auth.user_crud.get_by_email") as mock_get:
mock_get.side_effect = Exception("Database error")
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": token,
"new_password": "NewSecure123!"
}
json={"token": token, "new_password": "NewSecure123!"},
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -351,18 +349,22 @@ class TestPasswordResetConfirm:
assert "error" in error_msg or "resetting" in error_msg
@pytest.mark.asyncio
async def test_password_reset_full_flow(self, client, async_test_user, async_test_db):
async def test_password_reset_full_flow(
self, client, async_test_user, async_test_db
):
"""Test complete password reset flow."""
original_password = async_test_user.password_hash
new_password = "BrandNew123!"
# 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
response = await client.post(
"/api/v1/auth/password-reset/request",
json={"email": async_test_user.email}
json={"email": async_test_user.email},
)
assert response.status_code == status.HTTP_200_OK
@@ -374,29 +376,24 @@ class TestPasswordResetConfirm:
# Step 2: Confirm password reset
response = await client.post(
"/api/v1/auth/password-reset/confirm",
json={
"token": reset_token,
"new_password": new_password
}
json={"token": reset_token, "new_password": new_password},
)
assert response.status_code == status.HTTP_200_OK
# Step 3: Verify old password doesn't work
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await session.execute(select(User).where(User.id == async_test_user.id))
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
response = await client.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": new_password
}
json={"email": async_test_user.email, "password": new_password},
)
assert response.status_code == status.HTTP_200_OK

View File

@@ -8,11 +8,10 @@ Critical security tests covering:
These tests prevent real-world attack scenarios.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import create_refresh_token
from app.crud.session import session as session_crud
from app.models.user import User
@@ -30,10 +29,7 @@ class TestRevokedSessionSecurity:
@pytest.mark.asyncio
async def test_refresh_token_rejected_after_logout(
self,
client: AsyncClient,
async_test_db,
async_test_user: User
self, client: AsyncClient, async_test_db, async_test_user: User
):
"""
Test that refresh tokens are rejected after session is deactivated.
@@ -45,10 +41,10 @@ class TestRevokedSessionSecurity:
4. Attacker tries to use stolen refresh token
5. System MUST reject it (session revoked)
"""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Step 1: Create a session and refresh token for the user
async with SessionLocal() as session:
async with SessionLocal():
# Login to get tokens
response = await client.post(
"/api/v1/auth/login",
@@ -64,8 +60,7 @@ class TestRevokedSessionSecurity:
# Step 2: Verify refresh token works before logout
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token}
"/api/v1/auth/refresh", json={"refresh_token": refresh_token}
)
assert response.status_code == 200, "Refresh should work before logout"
@@ -73,14 +68,13 @@ class TestRevokedSessionSecurity:
response = await client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {access_token}"},
json={"refresh_token": refresh_token}
json={"refresh_token": refresh_token},
)
assert response.status_code == 200, "Logout should succeed"
# Step 4: Attacker tries to use stolen refresh token
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token}
"/api/v1/auth/refresh", json={"refresh_token": refresh_token}
)
# Step 5: System MUST reject (covers lines 261-262)
@@ -93,10 +87,7 @@ class TestRevokedSessionSecurity:
@pytest.mark.asyncio
async def test_refresh_token_rejected_for_deleted_session(
self,
client: AsyncClient,
async_test_db,
async_test_user: User
self, client: AsyncClient, async_test_db, async_test_user: User
):
"""
Test that tokens for deleted sessions are rejected.
@@ -104,7 +95,7 @@ class TestRevokedSessionSecurity:
Attack Scenario:
Admin deletes a session from database, but attacker has the token.
"""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Step 1: Login to create a session
response = await client.post(
@@ -120,6 +111,7 @@ class TestRevokedSessionSecurity:
# Step 2: Manually delete the session from database (simulating admin action)
from app.core.auth import decode_token
token_data = decode_token(refresh_token, verify_type="refresh")
jti = token_data.jti
@@ -132,15 +124,17 @@ class TestRevokedSessionSecurity:
# Step 3: Try to use the refresh token
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token}
"/api/v1/auth/refresh", json={"refresh_token": refresh_token}
)
# Should reject (session doesn't exist)
assert response.status_code == 401
data = response.json()
if "errors" in data:
assert "revoked" in data["errors"][0]["message"].lower() or "session" in data["errors"][0]["message"].lower()
assert (
"revoked" in data["errors"][0]["message"].lower()
or "session" in data["errors"][0]["message"].lower()
)
else:
assert "revoked" in data.get("detail", "").lower()
@@ -162,7 +156,7 @@ class TestSessionHijackingSecurity:
client: AsyncClient,
async_test_db,
async_test_user: User,
async_test_superuser: User
async_test_superuser: User,
):
"""
Test that users cannot logout other users' sessions.
@@ -173,7 +167,7 @@ class TestSessionHijackingSecurity:
3. User A tries to logout User B's session
4. System MUST reject (cross-user attack)
"""
test_engine, SessionLocal = async_test_db
_test_engine, _SessionLocal = async_test_db
# Step 1: User A logs in
response = await client.post(
@@ -202,8 +196,10 @@ class TestSessionHijackingSecurity:
# Step 3: User A tries to logout User B's session using User B's refresh token
response = await client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {user_a_access}"}, # User A's access token
json={"refresh_token": user_b_refresh} # But User B's refresh token
headers={
"Authorization": f"Bearer {user_a_access}"
}, # User A's access token
json={"refresh_token": user_b_refresh}, # But User B's refresh token
)
# Step 4: System MUST reject (covers lines 509-513)
@@ -217,9 +213,7 @@ class TestSessionHijackingSecurity:
@pytest.mark.asyncio
async def test_users_can_logout_their_own_sessions(
self,
client: AsyncClient,
async_test_user: User
self, client: AsyncClient, async_test_user: User
):
"""
Sanity check: Users CAN logout their own sessions.
@@ -241,6 +235,8 @@ class TestSessionHijackingSecurity:
response = await client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
json={"refresh_token": tokens["refresh_token"]}
json={"refresh_token": tokens["refresh_token"]},
)
assert response.status_code == 200, (
"Users should be able to logout their own sessions"
)
assert response.status_code == 200, "Users should be able to logout their own sessions"

View File

@@ -5,16 +5,18 @@ Tests for organization routes (user endpoints).
These test the routes in app/api/routes/organizations.py which allow
users to view and manage organizations they belong to.
"""
from unittest.mock import patch
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import status
from uuid import uuid4
from unittest.mock import patch, AsyncMock
from app.core.auth import get_password_hash
from app.models.organization import Organization
from app.models.user import User
from app.models.user_organization import UserOrganization, OrganizationRole
from app.core.auth import get_password_hash
from app.models.user_organization import OrganizationRole, UserOrganization
@pytest_asyncio.fixture
@@ -22,10 +24,7 @@ async def user_token(client, async_test_user):
"""Get access token for regular user."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
assert response.status_code == 200
return response.json()["access_token"]
@@ -34,7 +33,7 @@ async def user_token(client, async_test_user):
@pytest_asyncio.fixture
async def second_user(async_test_db):
"""Create a second test user."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid4(),
@@ -56,12 +55,12 @@ async def second_user(async_test_db):
@pytest_asyncio.fixture
async def test_org_with_user_member(async_test_db, async_test_user):
"""Create a test organization with async_test_user as a member."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="Member Org",
slug="member-org",
description="Test organization where user is a member"
description="Test organization where user is a member",
)
session.add(org)
await session.commit()
@@ -72,7 +71,7 @@ async def test_org_with_user_member(async_test_db, async_test_user):
user_id=async_test_user.id,
organization_id=org.id,
role=OrganizationRole.MEMBER,
is_active=True
is_active=True,
)
session.add(membership)
await session.commit()
@@ -83,12 +82,12 @@ async def test_org_with_user_member(async_test_db, async_test_user):
@pytest_asyncio.fixture
async def test_org_with_user_admin(async_test_db, async_test_user):
"""Create a test organization with async_test_user as an admin."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="Admin Org",
slug="admin-org",
description="Test organization where user is an admin"
description="Test organization where user is an admin",
)
session.add(org)
await session.commit()
@@ -99,7 +98,7 @@ async def test_org_with_user_admin(async_test_db, async_test_user):
user_id=async_test_user.id,
organization_id=org.id,
role=OrganizationRole.ADMIN,
is_active=True
is_active=True,
)
session.add(membership)
await session.commit()
@@ -110,12 +109,12 @@ async def test_org_with_user_admin(async_test_db, async_test_user):
@pytest_asyncio.fixture
async def test_org_with_user_owner(async_test_db, async_test_user):
"""Create a test organization with async_test_user as owner."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="Owner Org",
slug="owner-org",
description="Test organization where user is owner"
description="Test organization where user is owner",
)
session.add(org)
await session.commit()
@@ -126,7 +125,7 @@ async def test_org_with_user_owner(async_test_db, async_test_user):
user_id=async_test_user.id,
organization_id=org.id,
role=OrganizationRole.OWNER,
is_active=True
is_active=True,
)
session.add(membership)
await session.commit()
@@ -136,21 +135,18 @@ async def test_org_with_user_owner(async_test_db, async_test_user):
# ===== GET /api/v1/organizations/me =====
class TestGetMyOrganizations:
"""Tests for GET /api/v1/organizations/me endpoint."""
@pytest.mark.asyncio
async def test_get_my_organizations_success(
self,
client,
user_token,
test_org_with_user_member,
test_org_with_user_admin
self, client, user_token, test_org_with_user_member, test_org_with_user_admin
):
"""Test successfully getting user's organizations (covers lines 54-79)."""
response = await client.get(
"/api/v1/organizations/me",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -167,21 +163,15 @@ class TestGetMyOrganizations:
@pytest.mark.asyncio
async def test_get_my_organizations_filter_active(
self,
client,
async_test_db,
async_test_user,
user_token
self, client, async_test_db, async_test_user, user_token
):
"""Test filtering organizations by active status."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create active org
async with AsyncTestingSessionLocal() as session:
active_org = Organization(
name="Active Org",
slug="active-org-filter",
is_active=True
name="Active Org", slug="active-org-filter", is_active=True
)
session.add(active_org)
await session.commit()
@@ -192,14 +182,14 @@ class TestGetMyOrganizations:
user_id=async_test_user.id,
organization_id=active_org.id,
role=OrganizationRole.MEMBER,
is_active=True
is_active=True,
)
session.add(membership)
await session.commit()
response = await client.get(
"/api/v1/organizations/me?is_active=true",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -209,7 +199,7 @@ class TestGetMyOrganizations:
@pytest.mark.asyncio
async def test_get_my_organizations_empty(self, client, async_test_db):
"""Test getting organizations when user has none."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create user with no org memberships
async with AsyncTestingSessionLocal() as session:
@@ -219,7 +209,7 @@ class TestGetMyOrganizations:
password_hash=get_password_hash("TestPassword123!"),
first_name="No",
last_name="Org",
is_active=True
is_active=True,
)
session.add(user)
await session.commit()
@@ -227,13 +217,12 @@ class TestGetMyOrganizations:
# Login to get token
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "noorg@example.com", "password": "TestPassword123!"}
json={"email": "noorg@example.com", "password": "TestPassword123!"},
)
token = login_response.json()["access_token"]
response = await client.get(
"/api/v1/organizations/me",
headers={"Authorization": f"Bearer {token}"}
"/api/v1/organizations/me", headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == status.HTTP_200_OK
@@ -243,20 +232,18 @@ class TestGetMyOrganizations:
# ===== GET /api/v1/organizations/{organization_id} =====
class TestGetOrganization:
"""Tests for GET /api/v1/organizations/{organization_id} endpoint."""
@pytest.mark.asyncio
async def test_get_organization_success(
self,
client,
user_token,
test_org_with_user_member
self, client, user_token, test_org_with_user_member
):
"""Test successfully getting organization details (covers lines 103-122)."""
response = await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -272,7 +259,7 @@ class TestGetOrganization:
fake_org_id = uuid4()
response = await client.get(
f"/api/v1/organizations/{fake_org_id}",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
# Permission dependency checks membership before endpoint logic
@@ -283,20 +270,14 @@ class TestGetOrganization:
@pytest.mark.asyncio
async def test_get_organization_not_member(
self,
client,
async_test_db,
async_test_user
self, client, async_test_db, async_test_user
):
"""Test getting organization where user is not a member fails."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create org without adding user
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="Not Member Org",
slug="not-member-org"
)
org = Organization(name="Not Member Org", slug="not-member-org")
session.add(org)
await session.commit()
await session.refresh(org)
@@ -305,13 +286,13 @@ class TestGetOrganization:
# Login as user
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
token = login_response.json()["access_token"]
response = await client.get(
f"/api/v1/organizations/{org_id}",
headers={"Authorization": f"Bearer {token}"}
headers={"Authorization": f"Bearer {token}"},
)
# Should fail permission check
@@ -320,6 +301,7 @@ class TestGetOrganization:
# ===== GET /api/v1/organizations/{organization_id}/members =====
class TestGetOrganizationMembers:
"""Tests for GET /api/v1/organizations/{organization_id}/members endpoint."""
@@ -331,10 +313,10 @@ class TestGetOrganizationMembers:
async_test_user,
second_user,
user_token,
test_org_with_user_member
test_org_with_user_member,
):
"""Test successfully getting organization members (covers lines 150-168)."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Add second user to org
async with AsyncTestingSessionLocal() as session:
@@ -342,14 +324,14 @@ class TestGetOrganizationMembers:
user_id=second_user.id,
organization_id=test_org_with_user_member.id,
role=OrganizationRole.MEMBER,
is_active=True
is_active=True,
)
session.add(membership)
await session.commit()
response = await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}/members",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -360,15 +342,12 @@ class TestGetOrganizationMembers:
@pytest.mark.asyncio
async def test_get_organization_members_with_pagination(
self,
client,
user_token,
test_org_with_user_member
self, client, user_token, test_org_with_user_member
):
"""Test pagination parameters."""
response = await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}/members?page=1&limit=10",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -385,10 +364,10 @@ class TestGetOrganizationMembers:
async_test_user,
second_user,
user_token,
test_org_with_user_member
test_org_with_user_member,
):
"""Test filtering members by active status."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Add second user as inactive member
async with AsyncTestingSessionLocal() as session:
@@ -396,7 +375,7 @@ class TestGetOrganizationMembers:
user_id=second_user.id,
organization_id=test_org_with_user_member.id,
role=OrganizationRole.MEMBER,
is_active=False
is_active=False,
)
session.add(membership)
await session.commit()
@@ -404,7 +383,7 @@ class TestGetOrganizationMembers:
# Filter for active only
response = await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}/members?is_active=true",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -416,31 +395,26 @@ class TestGetOrganizationMembers:
# ===== PUT /api/v1/organizations/{organization_id} =====
class TestUpdateOrganization:
"""Tests for PUT /api/v1/organizations/{organization_id} endpoint."""
@pytest.mark.asyncio
async def test_update_organization_as_admin_success(
self,
client,
async_test_user,
test_org_with_user_admin
self, client, async_test_user, test_org_with_user_admin
):
"""Test successfully updating organization as admin (covers lines 193-215)."""
# Login as admin user
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
admin_token = login_response.json()["access_token"]
response = await client.put(
f"/api/v1/organizations/{test_org_with_user_admin.id}",
json={
"name": "Updated Admin Org",
"description": "Updated description"
},
headers={"Authorization": f"Bearer {admin_token}"}
json={"name": "Updated Admin Org", "description": "Updated description"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -450,23 +424,20 @@ class TestUpdateOrganization:
@pytest.mark.asyncio
async def test_update_organization_as_owner_success(
self,
client,
async_test_user,
test_org_with_user_owner
self, client, async_test_user, test_org_with_user_owner
):
"""Test successfully updating organization as owner."""
# Login as owner user
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
owner_token = login_response.json()["access_token"]
response = await client.put(
f"/api/v1/organizations/{test_org_with_user_owner.id}",
json={"name": "Updated Owner Org"},
headers={"Authorization": f"Bearer {owner_token}"}
headers={"Authorization": f"Bearer {owner_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -475,16 +446,13 @@ class TestUpdateOrganization:
@pytest.mark.asyncio
async def test_update_organization_as_member_fails(
self,
client,
user_token,
test_org_with_user_member
self, client, user_token, test_org_with_user_member
):
"""Test updating organization as regular member fails."""
response = await client.put(
f"/api/v1/organizations/{test_org_with_user_member.id}",
json={"name": "Should Fail"},
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
# Should fail permission check (need admin or owner)
@@ -492,15 +460,13 @@ class TestUpdateOrganization:
@pytest.mark.asyncio
async def test_update_organization_not_found(
self,
client,
test_org_with_user_admin
self, client, test_org_with_user_admin
):
"""Test updating nonexistent organization returns 403 (permission check first)."""
# Login as admin
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
admin_token = login_response.json()["access_token"]
@@ -508,7 +474,7 @@ class TestUpdateOrganization:
response = await client.put(
f"/api/v1/organizations/{fake_org_id}",
json={"name": "Updated"},
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}"},
)
# Permission dependency checks admin role before endpoint logic
@@ -520,6 +486,7 @@ class TestUpdateOrganization:
# ===== Authentication Tests =====
class TestOrganizationAuthentication:
"""Test authentication requirements for organization endpoints."""
@@ -548,14 +515,14 @@ class TestOrganizationAuthentication:
"""Test unauthenticated access to update fails."""
fake_id = uuid4()
response = await client.put(
f"/api/v1/organizations/{fake_id}",
json={"name": "Test"}
f"/api/v1/organizations/{fake_id}", json={"name": "Test"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# ===== Exception Handler Tests (Database Error Scenarios) =====
class TestOrganizationExceptionHandlers:
"""
Test exception handlers in organization endpoints.
@@ -566,86 +533,74 @@ class TestOrganizationExceptionHandlers:
@pytest.mark.asyncio
async def test_get_my_organizations_database_error(
self,
client,
user_token,
test_org_with_user_member
self, client, user_token, test_org_with_user_member
):
"""Test generic exception handler in get_my_organizations (covers lines 81-83)."""
with patch(
"app.crud.organization.organization.get_user_organizations_with_details",
side_effect=Exception("Database connection lost")
side_effect=Exception("Database connection lost"),
):
# The exception handler logs and re-raises, so we expect the exception
# to propagate (which proves the handler executed)
with pytest.raises(Exception, match="Database connection lost"):
await client.get(
"/api/v1/organizations/me",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
@pytest.mark.asyncio
async def test_get_organization_database_error(
self,
client,
user_token,
test_org_with_user_member
self, client, user_token, test_org_with_user_member
):
"""Test generic exception handler in get_organization (covers lines 124-128)."""
with patch(
"app.crud.organization.organization.get",
side_effect=Exception("Database timeout")
side_effect=Exception("Database timeout"),
):
with pytest.raises(Exception, match="Database timeout"):
await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
@pytest.mark.asyncio
async def test_get_organization_members_database_error(
self,
client,
user_token,
test_org_with_user_member
self, client, user_token, test_org_with_user_member
):
"""Test generic exception handler in get_organization_members (covers lines 170-172)."""
with patch(
"app.crud.organization.organization.get_organization_members",
side_effect=Exception("Connection pool exhausted")
side_effect=Exception("Connection pool exhausted"),
):
with pytest.raises(Exception, match="Connection pool exhausted"):
await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}/members",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
@pytest.mark.asyncio
async def test_update_organization_database_error(
self,
client,
async_test_user,
test_org_with_user_admin
self, client, async_test_user, test_org_with_user_admin
):
"""Test generic exception handler in update_organization (covers lines 217-221)."""
# Login as admin user
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
admin_token = login_response.json()["access_token"]
with patch(
"app.crud.organization.organization.get",
return_value=test_org_with_user_admin
return_value=test_org_with_user_admin,
):
with patch(
"app.crud.organization.organization.update",
side_effect=Exception("Write lock timeout")
side_effect=Exception("Write lock timeout"),
):
with pytest.raises(Exception, match="Write lock timeout"):
await client.put(
f"/api/v1/organizations/{test_org_with_user_admin.id}",
json={"name": "Should Fail"},
headers={"Authorization": f"Bearer {admin_token}"}
headers={"Authorization": f"Bearer {admin_token}"},
)

View File

@@ -5,15 +5,17 @@ Tests for permission dependencies - CRITICAL SECURITY PATHS.
These tests ensure superusers can bypass organization checks correctly,
and that regular users are properly blocked.
"""
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import status
from uuid import uuid4
from app.core.auth import get_password_hash
from app.models.organization import Organization
from app.models.user import User
from app.models.user_organization import UserOrganization, OrganizationRole
from app.core.auth import get_password_hash
from app.models.user_organization import OrganizationRole, UserOrganization
@pytest_asyncio.fixture
@@ -21,10 +23,7 @@ async def superuser_token(client, async_test_superuser):
"""Get access token for superuser."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "superuser@example.com",
"password": "SuperPassword123!"
}
json={"email": "superuser@example.com", "password": "SuperPassword123!"},
)
assert response.status_code == 200
return response.json()["access_token"]
@@ -35,10 +34,7 @@ async def regular_user_token(client, async_test_user):
"""Get access token for regular user."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
assert response.status_code == 200
return response.json()["access_token"]
@@ -47,12 +43,12 @@ async def regular_user_token(client, async_test_user):
@pytest_asyncio.fixture
async def test_org_no_members(async_test_db):
"""Create a test organization with NO members."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="No Members Org",
slug="no-members-org",
description="Test org with no members"
description="Test org with no members",
)
session.add(org)
await session.commit()
@@ -63,12 +59,12 @@ async def test_org_no_members(async_test_db):
@pytest_asyncio.fixture
async def test_org_with_member(async_test_db, async_test_user):
"""Create a test organization with async_test_user as member (not admin)."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="Member Only Org",
slug="member-only-org",
description="Test org where user is just a member"
description="Test org where user is just a member",
)
session.add(org)
await session.commit()
@@ -79,7 +75,7 @@ async def test_org_with_member(async_test_db, async_test_user):
user_id=async_test_user.id,
organization_id=org.id,
role=OrganizationRole.MEMBER,
is_active=True
is_active=True,
)
session.add(membership)
await session.commit()
@@ -89,6 +85,7 @@ async def test_org_with_member(async_test_db, async_test_user):
# ===== CRITICAL SECURITY TESTS: Superuser Bypass =====
class TestSuperuserBypass:
"""
CRITICAL: Test that superusers can bypass organization checks.
@@ -99,10 +96,7 @@ class TestSuperuserBypass:
@pytest.mark.asyncio
async def test_superuser_can_access_org_not_member_of(
self,
client,
superuser_token,
test_org_no_members
self, client, superuser_token, test_org_no_members
):
"""
CRITICAL: Superuser should bypass membership check (covers line 175).
@@ -111,7 +105,7 @@ class TestSuperuserBypass:
"""
response = await client.get(
f"/api/v1/organizations/{test_org_no_members.id}",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
# Superuser should succeed even though they're not a member
@@ -121,15 +115,12 @@ class TestSuperuserBypass:
@pytest.mark.asyncio
async def test_regular_user_cannot_access_org_not_member_of(
self,
client,
regular_user_token,
test_org_no_members
self, client, regular_user_token, test_org_no_members
):
"""Regular user should be blocked from org they're not a member of."""
response = await client.get(
f"/api/v1/organizations/{test_org_no_members.id}",
headers={"Authorization": f"Bearer {regular_user_token}"}
headers={"Authorization": f"Bearer {regular_user_token}"},
)
# Regular user should fail permission check
@@ -137,10 +128,7 @@ class TestSuperuserBypass:
@pytest.mark.asyncio
async def test_superuser_can_update_org_not_admin_of(
self,
client,
superuser_token,
test_org_no_members
self, client, superuser_token, test_org_no_members
):
"""
CRITICAL: Superuser should bypass admin check (covers line 99).
@@ -150,7 +138,7 @@ class TestSuperuserBypass:
response = await client.put(
f"/api/v1/organizations/{test_org_no_members.id}",
json={"name": "Updated by Superuser"},
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
# Superuser should succeed in updating org
@@ -160,16 +148,13 @@ class TestSuperuserBypass:
@pytest.mark.asyncio
async def test_regular_member_cannot_update_org(
self,
client,
regular_user_token,
test_org_with_member
self, client, regular_user_token, test_org_with_member
):
"""Regular member (not admin) should NOT be able to update org."""
response = await client.put(
f"/api/v1/organizations/{test_org_with_member.id}",
json={"name": "Should Fail"},
headers={"Authorization": f"Bearer {regular_user_token}"}
headers={"Authorization": f"Bearer {regular_user_token}"},
)
# Member should fail - need admin or owner role
@@ -177,15 +162,12 @@ class TestSuperuserBypass:
@pytest.mark.asyncio
async def test_superuser_can_list_org_members_not_member_of(
self,
client,
superuser_token,
test_org_no_members
self, client, superuser_token, test_org_no_members
):
"""CRITICAL: Superuser should bypass membership check to list members."""
response = await client.get(
f"/api/v1/organizations/{test_org_no_members.id}/members",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
# Superuser should succeed
@@ -197,13 +179,14 @@ class TestSuperuserBypass:
# ===== Edge Cases and Security Tests =====
class TestPermissionEdgeCases:
"""Test edge cases in permission system."""
@pytest.mark.asyncio
async def test_inactive_user_blocked(self, client, async_test_db):
"""Test that inactive users are blocked."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create inactive user
async with AsyncTestingSessionLocal() as session:
@@ -213,7 +196,7 @@ class TestPermissionEdgeCases:
password_hash=get_password_hash("TestPassword123!"),
first_name="Inactive",
last_name="User",
is_active=False # INACTIVE
is_active=False, # INACTIVE
)
session.add(user)
await session.commit()
@@ -222,7 +205,7 @@ class TestPermissionEdgeCases:
# But accessing protected endpoints should fail
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "inactive@example.com", "password": "TestPassword123!"}
json={"email": "inactive@example.com", "password": "TestPassword123!"},
)
# Login might fail for inactive users depending on auth implementation
@@ -231,18 +214,18 @@ class TestPermissionEdgeCases:
# Try to access protected endpoint
response = await client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {token}"}
"/api/v1/users/me", headers={"Authorization": f"Bearer {token}"}
)
# Should be blocked
assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]
assert response.status_code in [
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
]
@pytest.mark.asyncio
async def test_nonexistent_organization_returns_403_not_404(
self,
client,
regular_user_token
self, client, regular_user_token
):
"""
Test that accessing nonexistent org returns 403, not 404.
@@ -254,7 +237,7 @@ class TestPermissionEdgeCases:
fake_org_id = uuid4()
response = await client.get(
f"/api/v1/organizations/{fake_org_id}",
headers={"Authorization": f"Bearer {regular_user_token}"}
headers={"Authorization": f"Bearer {regular_user_token}"},
)
# Should get 403 (not a member), not 404 (doesn't exist)
@@ -264,18 +247,16 @@ class TestPermissionEdgeCases:
# ===== Admin Role Tests =====
class TestAdminRolePermissions:
"""Test admin role can perform admin actions."""
@pytest_asyncio.fixture
async def test_org_with_admin(self, async_test_db, async_test_user):
"""Create org where user is ADMIN."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="Admin Org",
slug="admin-org"
)
org = Organization(name="Admin Org", slug="admin-org")
session.add(org)
await session.commit()
await session.refresh(org)
@@ -284,7 +265,7 @@ class TestAdminRolePermissions:
user_id=async_test_user.id,
organization_id=org.id,
role=OrganizationRole.ADMIN,
is_active=True
is_active=True,
)
session.add(membership)
await session.commit()
@@ -293,16 +274,13 @@ class TestAdminRolePermissions:
@pytest.mark.asyncio
async def test_admin_can_update_org(
self,
client,
regular_user_token,
test_org_with_admin
self, client, regular_user_token, test_org_with_admin
):
"""Admin should be able to update organization."""
response = await client.put(
f"/api/v1/organizations/{test_org_with_admin.id}",
json={"name": "Updated by Admin"},
headers={"Authorization": f"Bearer {regular_user_token}"}
headers={"Authorization": f"Bearer {regular_user_token}"},
)
assert response.status_code == status.HTTP_200_OK

View File

@@ -7,13 +7,13 @@ Critical security tests covering:
These tests prevent unauthorized access and privilege escalation.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.organization import Organization
from app.crud.user import user as user_crud
from app.models.organization import Organization
from app.models.user import User
class TestInactiveUserBlocking:
@@ -29,11 +29,7 @@ class TestInactiveUserBlocking:
@pytest.mark.asyncio
async def test_inactive_user_cannot_access_protected_endpoints(
self,
client: AsyncClient,
async_test_db,
async_test_user: User,
user_token: str
self, client: AsyncClient, async_test_db, async_test_user: User, user_token: str
):
"""
Test that inactive users are blocked from protected endpoints.
@@ -44,12 +40,11 @@ class TestInactiveUserBlocking:
3. User tries to access protected endpoint with valid token
4. System MUST reject (account inactive)
"""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Step 1: Verify user can access endpoint while active
response = await client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {user_token}"}
"/api/v1/users/me", headers={"Authorization": f"Bearer {user_token}"}
)
assert response.status_code == 200, "Active user should have access"
@@ -61,8 +56,7 @@ class TestInactiveUserBlocking:
# Step 3: User tries to access endpoint with same token
response = await client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {user_token}"}
"/api/v1/users/me", headers={"Authorization": f"Bearer {user_token}"}
)
# Step 4: System MUST reject (covers lines 52-57)
@@ -75,18 +69,14 @@ class TestInactiveUserBlocking:
@pytest.mark.asyncio
async def test_inactive_user_blocked_from_organization_endpoints(
self,
client: AsyncClient,
async_test_db,
async_test_user: User,
user_token: str
self, client: AsyncClient, async_test_db, async_test_user: User, user_token: str
):
"""
Test that inactive users can't access organization endpoints.
Ensures the inactive check applies to ALL protected endpoints.
"""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Deactivate user
async with SessionLocal() as session:
@@ -97,7 +87,7 @@ class TestInactiveUserBlocking:
# Try to list organizations
response = await client.get(
"/api/v1/organizations/me",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
# Must be blocked
@@ -122,7 +112,7 @@ class TestSuperuserPrivilegeEscalation:
client: AsyncClient,
async_test_db,
async_test_superuser: User,
superuser_token: str
superuser_token: str,
):
"""
Test that superusers automatically get OWNER role in organizations.
@@ -131,14 +121,11 @@ class TestSuperuserPrivilegeEscalation:
Superusers can manage any organization without being explicitly added.
This is for platform administration.
"""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Step 1: Create an organization (owned by someone else)
async with SessionLocal() as session:
org = Organization(
name="Test Organization",
slug="test-org"
)
org = Organization(name="Test Organization", slug="test-org")
session.add(org)
await session.commit()
await session.refresh(org)
@@ -148,7 +135,7 @@ class TestSuperuserPrivilegeEscalation:
# (They're not a member, but should auto-get OWNER role)
response = await client.get(
f"/api/v1/organizations/{org_id}",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
# Step 3: Should have access (covers lines 154-157)
@@ -161,21 +148,18 @@ class TestSuperuserPrivilegeEscalation:
client: AsyncClient,
async_test_db,
async_test_superuser: User,
superuser_token: str
superuser_token: str,
):
"""
Test that superusers have full management access to all organizations.
Ensures the OWNER role privilege escalation works end-to-end.
"""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Create an organization
async with SessionLocal() as session:
org = Organization(
name="Test Organization",
slug="test-org"
)
org = Organization(name="Test Organization", slug="test-org")
session.add(org)
await session.commit()
await session.refresh(org)
@@ -185,34 +169,29 @@ class TestSuperuserPrivilegeEscalation:
response = await client.put(
f"/api/v1/organizations/{org_id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"name": "Updated Name"}
json={"name": "Updated Name"},
)
# Should succeed (superuser has OWNER privileges)
assert response.status_code in [200, 404], "Superuser should be able to manage any org"
assert response.status_code in [200, 404], (
"Superuser should be able to manage any org"
)
# Note: Might be 404 if org endpoints require membership, but the role check passes
@pytest.mark.asyncio
async def test_regular_user_does_not_get_owner_role(
self,
client: AsyncClient,
async_test_db,
async_test_user: User,
user_token: str
self, client: AsyncClient, async_test_db, async_test_user: User, user_token: str
):
"""
Sanity check: Regular users don't get automatic OWNER role.
Ensures the superuser check is working correctly (line 154).
"""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Create an organization
async with SessionLocal() as session:
org = Organization(
name="Test Organization",
slug="test-org"
)
org = Organization(name="Test Organization", slug="test-org")
session.add(org)
await session.commit()
await session.refresh(org)
@@ -221,8 +200,10 @@ class TestSuperuserPrivilegeEscalation:
# Regular user tries to access it (not a member)
response = await client.get(
f"/api/v1/organizations/{org_id}",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
# Should be denied (not a member, not a superuser)
assert response.status_code in [403, 404], "Regular user shouldn't access non-member org"
assert response.status_code in [403, 404], (
"Regular user shouldn't access non-member org"
)

View File

@@ -1,7 +1,8 @@
# tests/api/test_security_headers.py
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch
from app.main import app
@@ -11,8 +12,10 @@ def client():
"""Create a FastAPI test client for the main app (module-scoped for speed)."""
# Mock get_db to avoid database connection issues
with patch("app.core.database.get_db") as mock_get_db:
async def mock_session_generator():
from unittest.mock import MagicMock, AsyncMock
from unittest.mock import AsyncMock, MagicMock
mock_session = MagicMock()
mock_session.execute = AsyncMock(return_value=None)
mock_session.close = AsyncMock(return_value=None)
@@ -77,8 +80,10 @@ class TestSecurityHeaders:
"""Test that HSTS header is set in production (covers line 95)"""
with patch("app.core.config.settings.ENVIRONMENT", "production"):
with patch("app.core.database.get_db") as mock_get_db:
async def mock_session_generator():
from unittest.mock import MagicMock, AsyncMock
from unittest.mock import AsyncMock, MagicMock
mock_session = MagicMock()
mock_session.execute = AsyncMock(return_value=None)
mock_session.close = AsyncMock(return_value=None)
@@ -88,20 +93,26 @@ class TestSecurityHeaders:
# Need to reimport app to pick up the new settings
from importlib import reload
import app.main
reload(app.main)
test_client = TestClient(app.main.app)
response = test_client.get("/health")
assert "Strict-Transport-Security" in response.headers
assert "max-age=31536000" in response.headers["Strict-Transport-Security"]
assert (
"max-age=31536000" in response.headers["Strict-Transport-Security"]
)
def test_csp_strict_mode(self):
"""Test CSP strict mode (covers line 121)"""
with patch("app.core.config.settings.CSP_MODE", "strict"):
with patch("app.core.database.get_db") as mock_get_db:
async def mock_session_generator():
from unittest.mock import MagicMock, AsyncMock
from unittest.mock import AsyncMock, MagicMock
mock_session = MagicMock()
mock_session.execute = AsyncMock(return_value=None)
mock_session.close = AsyncMock(return_value=None)
@@ -110,7 +121,9 @@ class TestSecurityHeaders:
mock_get_db.side_effect = lambda: mock_session_generator()
from importlib import reload
import app.main
reload(app.main)
test_client = TestClient(app.main.app)
@@ -136,8 +149,10 @@ class TestRootEndpoint:
def test_root_endpoint(self):
"""Test root endpoint returns HTML (covers line 174)"""
with patch("app.core.database.get_db") as mock_get_db:
async def mock_session_generator():
from unittest.mock import MagicMock, AsyncMock
from unittest.mock import AsyncMock, MagicMock
mock_session = MagicMock()
mock_session.execute = AsyncMock(return_value=None)
mock_session.close = AsyncMock(return_value=None)

View File

@@ -2,23 +2,23 @@
"""
Comprehensive tests for session management API endpoints.
"""
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
from uuid import uuid4
import pytest
import pytest_asyncio
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from unittest.mock import patch
from fastapi import status
from app.models.user_session import UserSession
from app.schemas.users import UserCreate
# Disable rate limiting for tests
@pytest.fixture(autouse=True)
def disable_rate_limit():
"""Disable rate limiting for all tests in this module."""
with patch('app.api.routes.sessions.limiter.enabled', False):
with patch("app.api.routes.sessions.limiter.enabled", False):
yield
@@ -27,10 +27,7 @@ async def user_token(client, async_test_user):
"""Create and return an access token for async_test_user."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
assert response.status_code == 200
return response.json()["access_token"]
@@ -39,7 +36,7 @@ async def user_token(client, async_test_user):
@pytest_asyncio.fixture
async def async_test_user2(async_test_db):
"""Create a second test user."""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
async with SessionLocal() as session:
from app.crud.user import user as user_crud
@@ -49,7 +46,7 @@ async def async_test_user2(async_test_db):
email="testuser2@example.com",
password="TestPassword123!",
first_name="Test",
last_name="User2"
last_name="User2",
)
user = await user_crud.create(session, obj_in=user_data)
await session.commit()
@@ -61,9 +58,11 @@ class TestListMySessions:
"""Tests for GET /api/v1/sessions/me endpoint."""
@pytest.mark.asyncio
async def test_list_my_sessions_success(self, client, async_test_user, async_test_db, user_token):
async def test_list_my_sessions_success(
self, client, async_test_user, async_test_db, user_token
):
"""Test successfully listing user's active sessions."""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Create some sessions for the user
async with SessionLocal() as session:
@@ -75,8 +74,8 @@ class TestListMySessions:
ip_address="192.168.1.100",
user_agent="Mozilla/5.0 (iPhone)",
is_active=True,
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
last_used_at=datetime.now(timezone.utc)
expires_at=datetime.now(UTC) + timedelta(days=7),
last_used_at=datetime.now(UTC),
)
# Active session 2
s2 = UserSession(
@@ -86,8 +85,8 @@ class TestListMySessions:
ip_address="192.168.1.101",
user_agent="Mozilla/5.0 (Macintosh)",
is_active=True,
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
last_used_at=datetime.now(timezone.utc) - timedelta(hours=1)
expires_at=datetime.now(UTC) + timedelta(days=7),
last_used_at=datetime.now(UTC) - timedelta(hours=1),
)
# Inactive session (should not appear)
s3 = UserSession(
@@ -97,16 +96,15 @@ class TestListMySessions:
ip_address="192.168.1.102",
user_agent="Mozilla/5.0",
is_active=False,
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
last_used_at=datetime.now(timezone.utc) - timedelta(days=1)
expires_at=datetime.now(UTC) + timedelta(days=7),
last_used_at=datetime.now(UTC) - timedelta(days=1),
)
session.add_all([s1, s2, s3])
await session.commit()
# Make request
response = await client.get(
"/api/v1/sessions/me",
headers={"Authorization": f"Bearer {user_token}"}
"/api/v1/sessions/me", headers={"Authorization": f"Bearer {user_token}"}
)
assert response.status_code == status.HTTP_200_OK
@@ -128,11 +126,12 @@ class TestListMySessions:
assert data["sessions"][0]["is_current"] is True
@pytest.mark.asyncio
async def test_list_my_sessions_with_login_session(self, client, async_test_user, user_token):
async def test_list_my_sessions_with_login_session(
self, client, async_test_user, user_token
):
"""Test listing sessions shows the login session."""
response = await client.get(
"/api/v1/sessions/me",
headers={"Authorization": f"Bearer {user_token}"}
"/api/v1/sessions/me", headers={"Authorization": f"Bearer {user_token}"}
)
assert response.status_code == status.HTTP_200_OK
@@ -155,9 +154,11 @@ class TestRevokeSession:
"""Tests for DELETE /api/v1/sessions/{session_id} endpoint."""
@pytest.mark.asyncio
async def test_revoke_session_success(self, client, async_test_user, async_test_db, user_token):
async def test_revoke_session_success(
self, client, async_test_user, async_test_db, user_token
):
"""Test successfully revoking a session."""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Create a session to revoke
async with SessionLocal() as session:
@@ -168,8 +169,8 @@ class TestRevokeSession:
ip_address="192.168.1.103",
user_agent="Mozilla/5.0 (iPad)",
is_active=True,
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
last_used_at=datetime.now(timezone.utc)
expires_at=datetime.now(UTC) + timedelta(days=7),
last_used_at=datetime.now(UTC),
)
session.add(user_session)
await session.commit()
@@ -179,7 +180,7 @@ class TestRevokeSession:
# Revoke the session
response = await client.delete(
f"/api/v1/sessions/{session_id}",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -191,6 +192,7 @@ class TestRevokeSession:
# Verify session is deactivated
async with SessionLocal() as session:
from app.crud.session import session as session_crud
revoked_session = await session_crud.get(session, id=str(session_id))
assert revoked_session.is_active is False
@@ -200,7 +202,7 @@ class TestRevokeSession:
fake_id = uuid4()
response = await client.delete(
f"/api/v1/sessions/{fake_id}",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -222,7 +224,7 @@ class TestRevokeSession:
self, client, async_test_user, async_test_user2, async_test_db, user_token
):
"""Test that users cannot revoke other users' sessions."""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Create a session for user2
async with SessionLocal() as session:
@@ -233,8 +235,8 @@ class TestRevokeSession:
ip_address="192.168.1.200",
user_agent="Mozilla/5.0",
is_active=True,
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
last_used_at=datetime.now(timezone.utc)
expires_at=datetime.now(UTC) + timedelta(days=7),
last_used_at=datetime.now(UTC),
)
session.add(other_user_session)
await session.commit()
@@ -244,7 +246,7 @@ class TestRevokeSession:
# Try to revoke it as user1
response = await client.delete(
f"/api/v1/sessions/{session_id}",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -263,7 +265,7 @@ class TestCleanupExpiredSessions:
self, client, async_test_user, async_test_db, user_token
):
"""Test successfully cleaning up expired sessions."""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Create expired and active sessions using CRUD to avoid greenlet issues
from app.crud.session import session as session_crud
@@ -277,8 +279,8 @@ class TestCleanupExpiredSessions:
device_name="Expired 1",
ip_address="192.168.1.201",
user_agent="Mozilla/5.0",
expires_at=datetime.now(timezone.utc) - timedelta(days=1),
last_used_at=datetime.now(timezone.utc) - timedelta(days=2)
expires_at=datetime.now(UTC) - timedelta(days=1),
last_used_at=datetime.now(UTC) - timedelta(days=2),
)
e1 = await session_crud.create_session(db, obj_in=e1_data)
e1.is_active = False
@@ -291,8 +293,8 @@ class TestCleanupExpiredSessions:
device_name="Expired 2",
ip_address="192.168.1.202",
user_agent="Mozilla/5.0",
expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
last_used_at=datetime.now(timezone.utc) - timedelta(hours=2)
expires_at=datetime.now(UTC) - timedelta(hours=1),
last_used_at=datetime.now(UTC) - timedelta(hours=2),
)
e2 = await session_crud.create_session(db, obj_in=e2_data)
e2.is_active = False
@@ -305,8 +307,8 @@ class TestCleanupExpiredSessions:
device_name="Active",
ip_address="192.168.1.203",
user_agent="Mozilla/5.0",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
last_used_at=datetime.now(timezone.utc)
expires_at=datetime.now(UTC) + timedelta(days=7),
last_used_at=datetime.now(UTC),
)
await session_crud.create_session(db, obj_in=a1_data)
await db.commit()
@@ -314,7 +316,7 @@ class TestCleanupExpiredSessions:
# Cleanup expired sessions
response = await client.delete(
"/api/v1/sessions/me/expired",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -329,7 +331,7 @@ class TestCleanupExpiredSessions:
self, client, async_test_user, async_test_db, user_token
):
"""Test cleanup when no sessions are expired."""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Create only active sessions using CRUD
from app.crud.session import session as session_crud
@@ -342,15 +344,15 @@ class TestCleanupExpiredSessions:
device_name="Active Device",
ip_address="192.168.1.210",
user_agent="Mozilla/5.0",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
last_used_at=datetime.now(timezone.utc)
expires_at=datetime.now(UTC) + timedelta(days=7),
last_used_at=datetime.now(UTC),
)
await session_crud.create_session(db, obj_in=a1_data)
await db.commit()
response = await client.delete(
"/api/v1/sessions/me/expired",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -369,13 +371,16 @@ class TestCleanupExpiredSessions:
# Additional tests for better coverage
class TestSessionsAdditionalCases:
"""Additional tests to improve sessions endpoint coverage."""
@pytest.mark.asyncio
async def test_list_sessions_pagination(self, client, async_test_user, async_test_db, user_token):
async def test_list_sessions_pagination(
self, client, async_test_user, async_test_db, user_token
):
"""Test listing sessions with pagination."""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
# Create multiple sessions
async with SessionLocal() as session:
@@ -389,15 +394,15 @@ class TestSessionsAdditionalCases:
device_name=f"Device {i}",
ip_address=f"192.168.1.{i}",
user_agent="Mozilla/5.0",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
last_used_at=datetime.now(timezone.utc)
expires_at=datetime.now(UTC) + timedelta(days=7),
last_used_at=datetime.now(UTC),
)
await session_crud.create_session(session, obj_in=session_data)
await session.commit()
response = await client.get(
"/api/v1/sessions/me?page=1&limit=3",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -410,16 +415,21 @@ class TestSessionsAdditionalCases:
"""Test revoking session with invalid UUID."""
response = await client.delete(
"/api/v1/sessions/not-a-uuid",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
# Should return 422 for invalid UUID format
assert response.status_code in [status.HTTP_422_UNPROCESSABLE_ENTITY, status.HTTP_404_NOT_FOUND]
assert response.status_code in [
status.HTTP_422_UNPROCESSABLE_ENTITY,
status.HTTP_404_NOT_FOUND,
]
@pytest.mark.asyncio
async def test_cleanup_expired_sessions_with_mixed_states(self, client, async_test_user, async_test_db, user_token):
async def test_cleanup_expired_sessions_with_mixed_states(
self, client, async_test_user, async_test_db, user_token
):
"""Test cleanup with mix of active/inactive and expired/not-expired sessions."""
test_engine, SessionLocal = async_test_db
_test_engine, SessionLocal = async_test_db
from app.crud.session import session as session_crud
from app.schemas.sessions import SessionCreate
@@ -432,8 +442,8 @@ class TestSessionsAdditionalCases:
device_name="Expired Inactive",
ip_address="192.168.1.100",
user_agent="Mozilla/5.0",
expires_at=datetime.now(timezone.utc) - timedelta(days=1),
last_used_at=datetime.now(timezone.utc) - timedelta(days=2)
expires_at=datetime.now(UTC) - timedelta(days=1),
last_used_at=datetime.now(UTC) - timedelta(days=2),
)
e1 = await session_crud.create_session(db, obj_in=e1_data)
e1.is_active = False
@@ -446,8 +456,8 @@ class TestSessionsAdditionalCases:
device_name="Expired Active",
ip_address="192.168.1.101",
user_agent="Mozilla/5.0",
expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
last_used_at=datetime.now(timezone.utc) - timedelta(hours=2)
expires_at=datetime.now(UTC) - timedelta(hours=1),
last_used_at=datetime.now(UTC) - timedelta(hours=2),
)
await session_crud.create_session(db, obj_in=e2_data)
@@ -455,7 +465,7 @@ class TestSessionsAdditionalCases:
response = await client.delete(
"/api/v1/sessions/me/expired",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -476,10 +486,12 @@ class TestSessionExceptionHandlers:
from unittest.mock import patch
# Patch decode_token to raise an exception
with patch('app.api.routes.sessions.decode_token', side_effect=Exception("Token decode error")):
with patch(
"app.api.routes.sessions.decode_token",
side_effect=Exception("Token decode error"),
):
response = await client.get(
"/api/v1/sessions/me",
headers={"Authorization": f"Bearer {user_token}"}
"/api/v1/sessions/me", headers={"Authorization": f"Bearer {user_token}"}
)
# Should still succeed (exception is caught and ignored in try/except at line 77)
@@ -489,12 +501,16 @@ class TestSessionExceptionHandlers:
async def test_list_sessions_database_error(self, client, user_token):
"""Test list_sessions handles database errors (covers lines 104-106)."""
from unittest.mock import patch
from app.crud import session as session_module
with patch.object(session_module.session, 'get_user_sessions', side_effect=Exception("Database error")):
with patch.object(
session_module.session,
"get_user_sessions",
side_effect=Exception("Database error"),
):
response = await client.get(
"/api/v1/sessions/me",
headers={"Authorization": f"Bearer {user_token}"}
"/api/v1/sessions/me", headers={"Authorization": f"Bearer {user_token}"}
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -503,18 +519,21 @@ class TestSessionExceptionHandlers:
assert data["errors"][0]["message"] == "Failed to retrieve sessions"
@pytest.mark.asyncio
async def test_revoke_session_database_error(self, client, user_token, async_test_db, async_test_user):
async def test_revoke_session_database_error(
self, client, user_token, async_test_db, async_test_user
):
"""Test revoke_session handles database errors (covers lines 181-183)."""
from datetime import datetime, timedelta
from unittest.mock import patch
from uuid import uuid4
from app.crud import session as session_module
# First create a session to revoke
from app.crud.session import session as session_crud
from app.schemas.sessions import SessionCreate
from datetime import datetime, timedelta, timezone
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as db:
session_in = SessionCreate(
@@ -523,17 +542,21 @@ class TestSessionExceptionHandlers:
device_name="Test Device",
ip_address="192.168.1.1",
user_agent="Mozilla/5.0",
last_used_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc) + timedelta(days=60)
last_used_at=datetime.now(UTC),
expires_at=datetime.now(UTC) + timedelta(days=60),
)
user_session = await session_crud.create_session(db, obj_in=session_in)
session_id = user_session.id
# Mock the deactivate method to raise an exception
with patch.object(session_module.session, 'deactivate', side_effect=Exception("Database connection lost")):
with patch.object(
session_module.session,
"deactivate",
side_effect=Exception("Database connection lost"),
):
response = await client.delete(
f"/api/v1/sessions/{session_id}",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -544,12 +567,17 @@ class TestSessionExceptionHandlers:
async def test_cleanup_expired_sessions_database_error(self, client, user_token):
"""Test cleanup_expired_sessions handles database errors (covers lines 233-236)."""
from unittest.mock import patch
from app.crud import session as session_module
with patch.object(session_module.session, 'cleanup_expired_for_user', side_effect=Exception("Cleanup failed")):
with patch.object(
session_module.session,
"cleanup_expired_for_user",
side_effect=Exception("Cleanup failed"),
):
response = await client.delete(
"/api/v1/sessions/me/expired",
headers={"Authorization": f"Bearer {user_token}"}
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR

View File

@@ -3,32 +3,29 @@
Comprehensive tests for user management endpoints.
These tests focus on finding potential bugs, not just coverage.
"""
import pytest
import pytest_asyncio
from unittest.mock import patch
from fastapi import status
import uuid
from sqlalchemy import select
import uuid
from unittest.mock import patch
import pytest
from fastapi import status
from app.models.user import User
from app.models.user import User
from app.schemas.users import UserUpdate
# Disable rate limiting for tests
@pytest.fixture(autouse=True)
def disable_rate_limit():
"""Disable rate limiting for all tests in this module."""
with patch('app.api.routes.users.limiter.enabled', False):
with patch('app.api.routes.auth.limiter.enabled', False):
with patch("app.api.routes.users.limiter.enabled", False):
with patch("app.api.routes.auth.limiter.enabled", False):
yield
async def get_auth_headers(client, email, password):
"""Helper to get authentication headers."""
response = await client.post(
"/api/v1/auth/login",
json={"email": email, "password": password}
"/api/v1/auth/login", json={"email": email, "password": password}
)
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@@ -40,7 +37,9 @@ class TestListUsers:
@pytest.mark.asyncio
async def test_list_users_as_superuser(self, client, async_test_superuser):
"""Test listing users as superuser."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.get("/api/v1/users", headers=headers)
@@ -53,16 +52,20 @@ class TestListUsers:
@pytest.mark.asyncio
async def test_list_users_as_regular_user(self, client, async_test_user):
"""Test that regular users cannot list users."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.get("/api/v1/users", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
async def test_list_users_pagination(self, client, async_test_superuser, async_test_db):
async def test_list_users_pagination(
self, client, async_test_superuser, async_test_db
):
"""Test pagination works correctly."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create multiple users
async with AsyncTestingSessionLocal() as session:
@@ -72,12 +75,14 @@ class TestListUsers:
password_hash="hash",
first_name=f"PagUser{i}",
is_active=True,
is_superuser=False
is_superuser=False,
)
session.add(user)
await session.commit()
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
# Get first page
response = await client.get("/api/v1/users?page=1&limit=5", headers=headers)
@@ -88,9 +93,11 @@ class TestListUsers:
assert data["pagination"]["total"] >= 15
@pytest.mark.asyncio
async def test_list_users_filter_active(self, client, async_test_superuser, async_test_db):
async def test_list_users_filter_active(
self, client, async_test_superuser, async_test_db
):
"""Test filtering by active status."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create active and inactive users
async with AsyncTestingSessionLocal() as session:
@@ -99,19 +106,21 @@ class TestListUsers:
password_hash="hash",
first_name="Active",
is_active=True,
is_superuser=False
is_superuser=False,
)
inactive_user = User(
email="inactivefilter@example.com",
password_hash="hash",
first_name="Inactive",
is_active=False,
is_superuser=False
is_superuser=False,
)
session.add_all([active_user, inactive_user])
await session.commit()
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
# Filter for active users
response = await client.get("/api/v1/users?is_active=true", headers=headers)
@@ -130,9 +139,13 @@ class TestListUsers:
@pytest.mark.asyncio
async def test_list_users_sort_by_email(self, client, async_test_superuser):
"""Test sorting users by email."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await 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
data = response.json()
emails = [u["email"] for u in data["data"]]
@@ -154,7 +167,9 @@ class TestGetCurrentUserProfile:
@pytest.mark.asyncio
async def test_get_own_profile(self, client, async_test_user):
"""Test getting own profile."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.get("/api/v1/users/me", headers=headers)
@@ -176,12 +191,14 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_own_profile(self, client, async_test_user):
"""Test updating own profile."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"first_name": "Updated", "last_name": "Name"}
json={"first_name": "Updated", "last_name": "Name"},
)
assert response.status_code == status.HTTP_200_OK
@@ -192,12 +209,12 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_profile_phone_number(self, client, async_test_user, test_db):
"""Test updating phone number with validation."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"phone_number": "+19876543210"}
"/api/v1/users/me", headers=headers, json={"phone_number": "+19876543210"}
)
assert response.status_code == status.HTTP_200_OK
@@ -207,12 +224,12 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_profile_invalid_phone(self, client, async_test_user):
"""Test that invalid phone numbers are rejected."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"phone_number": "invalid"}
"/api/v1/users/me", headers=headers, json={"phone_number": "invalid"}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -220,14 +237,16 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_cannot_elevate_to_superuser(self, client, async_test_user):
"""Test that users cannot make themselves superuser."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
# Note: is_superuser is now in UserUpdate schema with explicit validation
# This tests that Pydantic rejects the attempt at the schema level
response = await client.patch(
"/api/v1/users/me",
headers=headers,
json={"first_name": "Test", "is_superuser": True}
json={"first_name": "Test", "is_superuser": True},
)
# Pydantic validation should reject this at the schema level
@@ -242,10 +261,7 @@ class TestUpdateCurrentUser:
@pytest.mark.asyncio
async def test_update_profile_no_auth(self, client):
"""Test that unauthenticated requests are rejected."""
response = await client.patch(
"/api/v1/users/me",
json={"first_name": "Hacker"}
)
response = await client.patch("/api/v1/users/me", json={"first_name": "Hacker"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Note: Removed test_update_profile_unexpected_error - see comment above
@@ -257,16 +273,22 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_own_profile_by_id(self, client, async_test_user):
"""Test getting own profile by ID."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.get(f"/api/v1/users/{async_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
data = response.json()
assert data["email"] == async_test_user.email
@pytest.mark.asyncio
async def test_get_other_user_as_regular_user(self, client, async_test_user, test_db):
async def test_get_other_user_as_regular_user(
self, client, async_test_user, test_db
):
"""Test that regular users cannot view other profiles."""
# Create another user
other_user = User(
@@ -274,24 +296,32 @@ class TestGetUserById:
password_hash="hash",
first_name="Other",
is_active=True,
is_superuser=False
is_superuser=False,
)
test_db.add(other_user)
test_db.commit()
test_db.refresh(other_user)
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.get(f"/api/v1/users/{other_user.id}", headers=headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
async def test_get_other_user_as_superuser(self, client, async_test_superuser, async_test_user):
async def test_get_other_user_as_superuser(
self, client, async_test_superuser, async_test_user
):
"""Test that superusers can view other profiles."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.get(f"/api/v1/users/{async_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
data = response.json()
@@ -300,7 +330,9 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_nonexistent_user(self, client, async_test_superuser):
"""Test getting non-existent user."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
fake_id = uuid.uuid4()
response = await client.get(f"/api/v1/users/{fake_id}", headers=headers)
@@ -310,7 +342,9 @@ class TestGetUserById:
@pytest.mark.asyncio
async def test_get_user_invalid_uuid(self, client, async_test_superuser):
"""Test getting user with invalid UUID format."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.get("/api/v1/users/not-a-uuid", headers=headers)
@@ -323,12 +357,14 @@ class TestUpdateUserById:
@pytest.mark.asyncio
async def test_update_own_profile_by_id(self, client, async_test_user, test_db):
"""Test updating own profile by ID."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "SelfUpdated"}
json={"first_name": "SelfUpdated"},
)
assert response.status_code == status.HTTP_200_OK
@@ -336,7 +372,9 @@ class TestUpdateUserById:
assert data["first_name"] == "SelfUpdated"
@pytest.mark.asyncio
async def test_update_other_user_as_regular_user(self, client, async_test_user, test_db):
async def test_update_other_user_as_regular_user(
self, client, async_test_user, test_db
):
"""Test that regular users cannot update other profiles."""
# Create another user
other_user = User(
@@ -344,18 +382,20 @@ class TestUpdateUserById:
password_hash="hash",
first_name="Other",
is_active=True,
is_superuser=False
is_superuser=False,
)
test_db.add(other_user)
test_db.commit()
test_db.refresh(other_user)
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
f"/api/v1/users/{other_user.id}",
headers=headers,
json={"first_name": "Hacked"}
json={"first_name": "Hacked"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -365,14 +405,18 @@ class TestUpdateUserById:
assert other_user.first_name == "Other"
@pytest.mark.asyncio
async def test_update_other_user_as_superuser(self, client, async_test_superuser, async_test_user, test_db):
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."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "AdminUpdated"}
json={"first_name": "AdminUpdated"},
)
assert response.status_code == status.HTTP_200_OK
@@ -380,16 +424,20 @@ class TestUpdateUserById:
assert data["first_name"] == "AdminUpdated"
@pytest.mark.asyncio
async def test_regular_user_cannot_modify_superuser_status(self, client, async_test_user):
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."""
headers = await get_auth_headers(client, async_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
# Just verify the user stays the same
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "Test"}
json={"first_name": "Test"},
)
assert response.status_code == status.HTTP_200_OK
@@ -397,14 +445,18 @@ class TestUpdateUserById:
assert data["is_superuser"] is False
@pytest.mark.asyncio
async def test_superuser_can_update_users(self, client, async_test_superuser, async_test_user, test_db):
async def test_superuser_can_update_users(
self, client, async_test_superuser, async_test_user, test_db
):
"""Test that superusers can update other users."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers=headers,
json={"first_name": "AdminChanged", "is_active": False}
json={"first_name": "AdminChanged", "is_active": False},
)
assert response.status_code == status.HTTP_200_OK
@@ -415,13 +467,13 @@ class TestUpdateUserById:
@pytest.mark.asyncio
async def test_update_nonexistent_user(self, client, async_test_superuser):
"""Test updating non-existent user."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
fake_id = uuid.uuid4()
response = await client.patch(
f"/api/v1/users/{fake_id}",
headers=headers,
json={"first_name": "Ghost"}
f"/api/v1/users/{fake_id}", headers=headers, json={"first_name": "Ghost"}
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -435,15 +487,17 @@ class TestChangePassword:
@pytest.mark.asyncio
async def test_change_password_success(self, client, async_test_user, test_db):
"""Test successful password change."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
"current_password": "TestPassword123!",
"new_password": "NewPassword123!"
}
"new_password": "NewPassword123!",
},
)
assert response.status_code == status.HTTP_200_OK
@@ -453,25 +507,24 @@ class TestChangePassword:
# Verify can login with new password
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": async_test_user.email,
"password": "NewPassword123!"
}
json={"email": async_test_user.email, "password": "NewPassword123!"},
)
assert login_response.status_code == status.HTTP_200_OK
@pytest.mark.asyncio
async def test_change_password_wrong_current(self, client, async_test_user):
"""Test that wrong current password is rejected."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
"current_password": "WrongPassword123",
"new_password": "NewPassword123!"
}
"new_password": "NewPassword123!",
},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -479,15 +532,14 @@ class TestChangePassword:
@pytest.mark.asyncio
async def test_change_password_weak_new_password(self, client, async_test_user):
"""Test that weak new passwords are rejected."""
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await client.patch(
"/api/v1/users/me/password",
headers=headers,
json={
"current_password": "TestPassword123!",
"new_password": "weak"
}
json={"current_password": "TestPassword123!", "new_password": "weak"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@@ -499,8 +551,8 @@ class TestChangePassword:
"/api/v1/users/me/password",
json={
"current_password": "TestPassword123!",
"new_password": "NewPassword123!"
}
"new_password": "NewPassword123!",
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -511,9 +563,11 @@ class TestDeleteUser:
"""Tests for DELETE /users/{user_id} endpoint."""
@pytest.mark.asyncio
async def test_delete_user_as_superuser(self, client, async_test_superuser, async_test_db):
async def test_delete_user_as_superuser(
self, client, async_test_superuser, async_test_db
):
"""Test deleting a user as superuser."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create a user to delete
async with AsyncTestingSessionLocal() as session:
@@ -522,14 +576,16 @@ class TestDeleteUser:
password_hash="hash",
first_name="Delete",
is_active=True,
is_superuser=False
is_superuser=False,
)
session.add(user_to_delete)
await session.commit()
await session.refresh(user_to_delete)
user_id = user_to_delete.id
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.delete(f"/api/v1/users/{user_id}", headers=headers)
@@ -540,6 +596,7 @@ class TestDeleteUser:
# Verify user is soft-deleted (has deleted_at timestamp)
async with AsyncTestingSessionLocal() as session:
from sqlalchemy import select
result = await session.execute(select(User).where(User.id == user_id))
deleted_user = result.scalar_one_or_none()
assert deleted_user.deleted_at is not None
@@ -547,9 +604,13 @@ class TestDeleteUser:
@pytest.mark.asyncio
async def test_cannot_delete_self(self, client, async_test_superuser):
"""Test that users cannot delete their own account."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
response = await client.delete(f"/api/v1/users/{async_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
@@ -562,22 +623,28 @@ class TestDeleteUser:
password_hash="hash",
first_name="Protected",
is_active=True,
is_superuser=False
is_superuser=False,
)
test_db.add(other_user)
test_db.commit()
test_db.refresh(other_user)
headers = await get_auth_headers(client, async_test_user.email, "TestPassword123!")
headers = await get_auth_headers(
client, async_test_user.email, "TestPassword123!"
)
response = await 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
@pytest.mark.asyncio
async def test_delete_nonexistent_user(self, client, async_test_superuser):
"""Test deleting non-existent user."""
headers = await get_auth_headers(client, async_test_superuser.email, "SuperPassword123!")
headers = await get_auth_headers(
client, async_test_superuser.email, "SuperPassword123!"
)
fake_id = uuid.uuid4()
response = await client.delete(f"/api/v1/users/{fake_id}", headers=headers)

View File

@@ -2,10 +2,12 @@
"""
Tests for user routes.
"""
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import status
from uuid import uuid4
@pytest_asyncio.fixture
@@ -13,10 +15,7 @@ async def superuser_token(client, async_test_superuser):
"""Get access token for superuser."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "superuser@example.com",
"password": "SuperPassword123!"
}
json={"email": "superuser@example.com", "password": "SuperPassword123!"},
)
assert response.status_code == 200
return response.json()["access_token"]
@@ -27,10 +26,7 @@ async def user_token(client, async_test_user):
"""Get access token for regular user."""
response = await client.post(
"/api/v1/auth/login",
json={
"email": "testuser@example.com",
"password": "TestPassword123!"
}
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
assert response.status_code == 200
return response.json()["access_token"]
@@ -43,8 +39,7 @@ class TestListUsers:
async def test_list_users_success(self, client, superuser_token):
"""Test listing users successfully (covers lines 87-100)."""
response = await client.get(
"/api/v1/users",
headers={"Authorization": f"Bearer {superuser_token}"}
"/api/v1/users", headers={"Authorization": f"Bearer {superuser_token}"}
)
assert response.status_code == status.HTTP_200_OK
@@ -58,7 +53,7 @@ class TestListUsers:
"""Test listing users with is_superuser filter (covers line 74)."""
response = await client.get(
"/api/v1/users?is_superuser=true",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -73,8 +68,7 @@ class TestGetCurrentUser:
async def test_get_current_user_success(self, client, async_test_user, user_token):
"""Test getting current user profile."""
response = await client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {user_token}"}
"/api/v1/users/me", headers={"Authorization": f"Bearer {user_token}"}
)
assert response.status_code == status.HTTP_200_OK
@@ -92,7 +86,7 @@ class TestUpdateCurrentUser:
response = await client.patch(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {user_token}"},
json={"first_name": "UpdatedName"}
json={"first_name": "UpdatedName"},
)
assert response.status_code == status.HTTP_200_OK
@@ -104,12 +98,14 @@ class TestUpdateCurrentUser:
"""Test database error handling during update (covers lines 162-169)."""
from unittest.mock import patch
with patch('app.api.routes.users.user_crud.update', side_effect=Exception("DB error")):
with patch(
"app.api.routes.users.user_crud.update", side_effect=Exception("DB error")
):
with pytest.raises(Exception):
await client.patch(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {user_token}"},
json={"first_name": "Updated"}
json={"first_name": "Updated"},
)
@pytest.mark.asyncio
@@ -118,7 +114,7 @@ class TestUpdateCurrentUser:
response = await client.patch(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {user_token}"},
json={"is_superuser": True}
json={"is_superuser": True},
)
# Pydantic validation should reject this at the schema level
@@ -137,12 +133,15 @@ class TestUpdateCurrentUser:
"""Test ValueError handling during update (covers lines 165-166)."""
from unittest.mock import patch
with patch('app.api.routes.users.user_crud.update', side_effect=ValueError("Invalid value")):
with patch(
"app.api.routes.users.user_crud.update",
side_effect=ValueError("Invalid value"),
):
with pytest.raises(ValueError):
await client.patch(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {user_token}"},
json={"first_name": "Updated"}
json={"first_name": "Updated"},
)
@@ -154,7 +153,7 @@ class TestGetUser:
"""Test getting user by ID."""
response = await client.get(
f"/api/v1/users/{async_test_user.id}",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -167,7 +166,7 @@ class TestGetUser:
fake_id = uuid4()
response = await client.get(
f"/api/v1/users/{fake_id}",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -183,30 +182,34 @@ class TestUpdateUserById:
response = await client.patch(
f"/api/v1/users/{fake_id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"first_name": "Updated"}
json={"first_name": "Updated"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
async def test_update_user_by_id_non_superuser_cannot_change_superuser_status(self, client, async_test_user, user_token):
async def test_update_user_by_id_non_superuser_cannot_change_superuser_status(
self, client, async_test_user, user_token
):
"""Test non-superuser cannot modify superuser status (Pydantic validation)."""
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers={"Authorization": f"Bearer {user_token}"},
json={"is_superuser": True}
json={"is_superuser": True},
)
# Pydantic validation should reject this at the schema level
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_update_user_by_id_success(self, client, async_test_user, superuser_token):
async def test_update_user_by_id_success(
self, client, async_test_user, superuser_token
):
"""Test updating user successfully (covers lines 276-278)."""
response = await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"first_name": "SuperUpdated"}
json={"first_name": "SuperUpdated"},
)
assert response.status_code == status.HTTP_200_OK
@@ -214,29 +217,37 @@ class TestUpdateUserById:
assert data["first_name"] == "SuperUpdated"
@pytest.mark.asyncio
async def test_update_user_by_id_value_error(self, client, async_test_user, superuser_token):
async def test_update_user_by_id_value_error(
self, client, async_test_user, superuser_token
):
"""Test ValueError handling (covers lines 280-281)."""
from unittest.mock import patch
with patch('app.api.routes.users.user_crud.update', side_effect=ValueError("Invalid")):
with patch(
"app.api.routes.users.user_crud.update", side_effect=ValueError("Invalid")
):
with pytest.raises(ValueError):
await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"first_name": "Updated"}
json={"first_name": "Updated"},
)
@pytest.mark.asyncio
async def test_update_user_by_id_unexpected_error(self, client, async_test_user, superuser_token):
async def test_update_user_by_id_unexpected_error(
self, client, async_test_user, superuser_token
):
"""Test unexpected error handling (covers lines 283-284)."""
from unittest.mock import patch
with patch('app.api.routes.users.user_crud.update', side_effect=Exception("Unexpected")):
with patch(
"app.api.routes.users.user_crud.update", side_effect=Exception("Unexpected")
):
with pytest.raises(Exception):
await client.patch(
f"/api/v1/users/{async_test_user.id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"first_name": "Updated"}
json={"first_name": "Updated"},
)
@@ -246,18 +257,18 @@ class TestChangePassword:
@pytest.mark.asyncio
async def test_change_password_success(self, client, async_test_db):
"""Test changing password successfully."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create a fresh user
async with AsyncTestingSessionLocal() as session:
from app.models.user import User
from app.core.auth import get_password_hash
from app.models.user import User
new_user = User(
email="changepass@example.com",
password_hash=get_password_hash("OldPassword123!"),
first_name="Change",
last_name="Pass"
last_name="Pass",
)
session.add(new_user)
await session.commit()
@@ -265,10 +276,7 @@ class TestChangePassword:
# Login
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": "changepass@example.com",
"password": "OldPassword123!"
}
json={"email": "changepass@example.com", "password": "OldPassword123!"},
)
token = login_response.json()["access_token"]
@@ -278,8 +286,8 @@ class TestChangePassword:
headers={"Authorization": f"Bearer {token}"},
json={
"current_password": "OldPassword123!",
"new_password": "NewPassword456!"
}
"new_password": "NewPassword456!",
},
)
assert response.status_code == status.HTTP_200_OK
@@ -289,10 +297,7 @@ class TestChangePassword:
# Verify new password works
login_response = await client.post(
"/api/v1/auth/login",
json={
"email": "changepass@example.com",
"password": "NewPassword456!"
}
json={"email": "changepass@example.com", "password": "NewPassword456!"},
)
assert login_response.status_code == status.HTTP_200_OK
@@ -306,7 +311,7 @@ class TestDeleteUserById:
fake_id = uuid4()
response = await client.delete(
f"/api/v1/users/{fake_id}",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -314,18 +319,18 @@ class TestDeleteUserById:
@pytest.mark.asyncio
async def test_delete_user_success(self, client, async_test_db, superuser_token):
"""Test deleting user successfully (covers lines 383-388)."""
test_engine, AsyncTestingSessionLocal = async_test_db
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create a user to delete
async with AsyncTestingSessionLocal() as session:
from app.models.user import User
from app.core.auth import get_password_hash
from app.models.user import User
user_to_delete = User(
email=f"delete{uuid4().hex[:8]}@example.com",
password_hash=get_password_hash("Password123!"),
first_name="Delete",
last_name="Me"
last_name="Me",
)
session.add(user_to_delete)
await session.commit()
@@ -334,7 +339,7 @@ class TestDeleteUserById:
response = await client.delete(
f"/api/v1/users/{user_id}",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == status.HTTP_200_OK
@@ -342,25 +347,35 @@ class TestDeleteUserById:
assert data["success"] is True
@pytest.mark.asyncio
async def test_delete_user_value_error(self, client, async_test_user, superuser_token):
async def test_delete_user_value_error(
self, client, async_test_user, superuser_token
):
"""Test ValueError handling during delete (covers lines 390-391)."""
from unittest.mock import patch
with patch('app.api.routes.users.user_crud.soft_delete', side_effect=ValueError("Cannot delete")):
with patch(
"app.api.routes.users.user_crud.soft_delete",
side_effect=ValueError("Cannot delete"),
):
with pytest.raises(ValueError):
await client.delete(
f"/api/v1/users/{async_test_user.id}",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)
@pytest.mark.asyncio
async def test_delete_user_unexpected_error(self, client, async_test_user, superuser_token):
async def test_delete_user_unexpected_error(
self, client, async_test_user, superuser_token
):
"""Test unexpected error handling during delete (covers lines 393-394)."""
from unittest.mock import patch
with patch('app.api.routes.users.user_crud.soft_delete', side_effect=Exception("Unexpected")):
with patch(
"app.api.routes.users.user_crud.soft_delete",
side_effect=Exception("Unexpected"),
):
with pytest.raises(Exception):
await client.delete(
f"/api/v1/users/{async_test_user.id}",
headers={"Authorization": f"Bearer {superuser_token}"}
headers={"Authorization": f"Bearer {superuser_token}"},
)