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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user