forked from cardosofelipe/pragma-stack
refactor(backend): enforce route→service→repo layered architecture
- introduce custom repository exception hierarchy (DuplicateEntryError, IntegrityConstraintError, InvalidInputError) replacing raw ValueError - eliminate all direct repository imports and raw SQL from route layer - add UserService, SessionService, OrganizationService to service layer - add get_stats/get_org_distribution service methods replacing admin inline SQL - fix timing side-channel in authenticate_user via dummy bcrypt check - replace SHA-256 client secret fallback with explicit InvalidClientError - replace assert with InvalidGrantError in authorization code exchange - replace N+1 token revocation loops with bulk UPDATE statements - rename oauth account token fields (drop misleading 'encrypted' suffix) - add Alembic migration 0003 for token field column rename - add 45 new service/repository tests; 975 passing, 94% coverage
This commit is contained in:
@@ -147,7 +147,7 @@ class TestAdminCreateUser:
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
|
||||
class TestAdminGetUser:
|
||||
@@ -565,7 +565,7 @@ class TestAdminCreateOrganization:
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
|
||||
class TestAdminGetOrganization:
|
||||
|
||||
@@ -45,7 +45,7 @@ class TestAdminListUsersFilters:
|
||||
async def test_list_users_database_error_propagates(self, client, superuser_token):
|
||||
"""Test that database errors propagate correctly (covers line 118-120)."""
|
||||
with patch(
|
||||
"app.api.routes.admin.user_crud.get_multi_with_total",
|
||||
"app.api.routes.admin.user_service.list_users",
|
||||
side_effect=Exception("DB error"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
@@ -74,8 +74,8 @@ class TestAdminCreateUserErrors:
|
||||
},
|
||||
)
|
||||
|
||||
# Should get error for duplicate email
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Should get conflict for duplicate email
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_unexpected_error_propagates(
|
||||
@@ -83,7 +83,7 @@ class TestAdminCreateUserErrors:
|
||||
):
|
||||
"""Test unexpected errors during user creation (covers line 151-153)."""
|
||||
with patch(
|
||||
"app.api.routes.admin.user_crud.create",
|
||||
"app.api.routes.admin.user_service.create_user",
|
||||
side_effect=RuntimeError("Unexpected error"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
@@ -135,7 +135,7 @@ class TestAdminUpdateUserErrors:
|
||||
):
|
||||
"""Test unexpected errors during user update (covers line 206-208)."""
|
||||
with patch(
|
||||
"app.api.routes.admin.user_crud.update",
|
||||
"app.api.routes.admin.user_service.update_user",
|
||||
side_effect=RuntimeError("Update failed"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
@@ -166,7 +166,7 @@ class TestAdminDeleteUserErrors:
|
||||
):
|
||||
"""Test unexpected errors during user deletion (covers line 238-240)."""
|
||||
with patch(
|
||||
"app.api.routes.admin.user_crud.soft_delete",
|
||||
"app.api.routes.admin.user_service.soft_delete_user",
|
||||
side_effect=Exception("Delete failed"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
@@ -196,7 +196,7 @@ class TestAdminActivateUserErrors:
|
||||
):
|
||||
"""Test unexpected errors during user activation (covers line 282-284)."""
|
||||
with patch(
|
||||
"app.api.routes.admin.user_crud.update",
|
||||
"app.api.routes.admin.user_service.update_user",
|
||||
side_effect=Exception("Activation failed"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
@@ -238,7 +238,7 @@ class TestAdminDeactivateUserErrors:
|
||||
):
|
||||
"""Test unexpected errors during user deactivation (covers line 326-328)."""
|
||||
with patch(
|
||||
"app.api.routes.admin.user_crud.update",
|
||||
"app.api.routes.admin.user_service.update_user",
|
||||
side_effect=Exception("Deactivation failed"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
@@ -258,7 +258,7 @@ class TestAdminListOrganizationsErrors:
|
||||
async def test_list_organizations_database_error(self, client, superuser_token):
|
||||
"""Test list organizations with database error (covers line 427-456)."""
|
||||
with patch(
|
||||
"app.api.routes.admin.organization_crud.get_multi_with_member_counts",
|
||||
"app.api.routes.admin.organization_service.get_multi_with_member_counts",
|
||||
side_effect=Exception("DB error"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
@@ -299,14 +299,14 @@ class TestAdminCreateOrganizationErrors:
|
||||
},
|
||||
)
|
||||
|
||||
# Should get error for duplicate slug
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Should get conflict for duplicate slug
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_organization_unexpected_error(self, client, superuser_token):
|
||||
"""Test unexpected errors during organization creation (covers line 484-485)."""
|
||||
with patch(
|
||||
"app.api.routes.admin.organization_crud.create",
|
||||
"app.api.routes.admin.organization_service.create_organization",
|
||||
side_effect=RuntimeError("Creation failed"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
@@ -367,7 +367,7 @@ class TestAdminUpdateOrganizationErrors:
|
||||
org_id = org.id
|
||||
|
||||
with patch(
|
||||
"app.api.routes.admin.organization_crud.update",
|
||||
"app.api.routes.admin.organization_service.update_organization",
|
||||
side_effect=Exception("Update failed"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
@@ -412,7 +412,7 @@ class TestAdminDeleteOrganizationErrors:
|
||||
org_id = org.id
|
||||
|
||||
with patch(
|
||||
"app.api.routes.admin.organization_crud.remove",
|
||||
"app.api.routes.admin.organization_service.remove_organization",
|
||||
side_effect=Exception("Delete failed"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
@@ -456,7 +456,7 @@ class TestAdminListOrganizationMembersErrors:
|
||||
org_id = org.id
|
||||
|
||||
with patch(
|
||||
"app.api.routes.admin.organization_crud.get_organization_members",
|
||||
"app.api.routes.admin.organization_service.get_organization_members",
|
||||
side_effect=Exception("DB error"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
@@ -531,7 +531,7 @@ class TestAdminAddOrganizationMemberErrors:
|
||||
org_id = org.id
|
||||
|
||||
with patch(
|
||||
"app.api.routes.admin.organization_crud.add_user",
|
||||
"app.api.routes.admin.organization_service.add_member",
|
||||
side_effect=Exception("Add failed"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
@@ -587,7 +587,7 @@ class TestAdminRemoveOrganizationMemberErrors:
|
||||
org_id = org.id
|
||||
|
||||
with patch(
|
||||
"app.api.routes.admin.organization_crud.remove_user",
|
||||
"app.api.routes.admin.organization_service.remove_member",
|
||||
side_effect=Exception("Remove failed"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestLoginSessionCreationFailure:
|
||||
"""Test that login succeeds even if session creation fails."""
|
||||
# Mock session creation to fail
|
||||
with patch(
|
||||
"app.api.routes.auth.session_crud.create_session",
|
||||
"app.api.routes.auth.session_service.create_session",
|
||||
side_effect=Exception("Session creation failed"),
|
||||
):
|
||||
response = await client.post(
|
||||
@@ -43,7 +43,7 @@ class TestOAuthLoginSessionCreationFailure:
|
||||
):
|
||||
"""Test OAuth login succeeds even if session creation fails."""
|
||||
with patch(
|
||||
"app.api.routes.auth.session_crud.create_session",
|
||||
"app.api.routes.auth.session_service.create_session",
|
||||
side_effect=Exception("Session failed"),
|
||||
):
|
||||
response = await client.post(
|
||||
@@ -76,7 +76,7 @@ class TestRefreshTokenSessionUpdateFailure:
|
||||
|
||||
# Mock session update to fail
|
||||
with patch(
|
||||
"app.api.routes.auth.session_crud.update_refresh_token",
|
||||
"app.api.routes.auth.session_service.update_refresh_token",
|
||||
side_effect=Exception("Update failed"),
|
||||
):
|
||||
response = await client.post(
|
||||
@@ -130,7 +130,7 @@ class TestLogoutWithNonExistentSession:
|
||||
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_service.get_by_jti", return_value=None):
|
||||
response = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
@@ -157,7 +157,7 @@ class TestLogoutUnexpectedError:
|
||||
|
||||
# Mock to raise unexpected error
|
||||
with patch(
|
||||
"app.api.routes.auth.session_crud.get_by_jti",
|
||||
"app.api.routes.auth.session_service.get_by_jti",
|
||||
side_effect=Exception("Unexpected error"),
|
||||
):
|
||||
response = await client.post(
|
||||
@@ -186,7 +186,7 @@ class TestLogoutAllUnexpectedError:
|
||||
|
||||
# Mock to raise database error
|
||||
with patch(
|
||||
"app.api.routes.auth.session_crud.deactivate_all_user_sessions",
|
||||
"app.api.routes.auth.session_service.deactivate_all_user_sessions",
|
||||
side_effect=Exception("DB error"),
|
||||
):
|
||||
response = await client.post(
|
||||
@@ -212,7 +212,7 @@ class TestPasswordResetConfirmSessionInvalidation:
|
||||
|
||||
# Mock session invalidation to fail
|
||||
with patch(
|
||||
"app.api.routes.auth.session_crud.deactivate_all_user_sessions",
|
||||
"app.api.routes.auth.session_service.deactivate_all_user_sessions",
|
||||
side_effect=Exception("Invalidation failed"),
|
||||
):
|
||||
response = await client.post(
|
||||
|
||||
@@ -334,7 +334,7 @@ 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.services.auth_service.user_repo.get_by_email") as mock_get:
|
||||
mock_get.side_effect = Exception("Database error")
|
||||
|
||||
response = await client.post(
|
||||
|
||||
@@ -12,7 +12,7 @@ These tests prevent real-world attack scenarios.
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.crud.session import session as session_crud
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.crud.oauth import oauth_account
|
||||
from app.repositories.oauth_account import oauth_account_repo as oauth_account
|
||||
from app.schemas.oauth import OAuthAccountCreate
|
||||
|
||||
|
||||
@@ -349,7 +349,7 @@ class TestOAuthProviderEndpoints:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
# Create a test client
|
||||
from app.crud.oauth import oauth_client
|
||||
from app.repositories.oauth_client import oauth_client_repo as oauth_client
|
||||
from app.schemas.oauth import OAuthClientCreate
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -386,7 +386,7 @@ class TestOAuthProviderEndpoints:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
# Create a test client
|
||||
from app.crud.oauth import oauth_client
|
||||
from app.repositories.oauth_client import oauth_client_repo as oauth_client
|
||||
from app.schemas.oauth import OAuthClientCreate
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
|
||||
@@ -537,7 +537,7 @@ class TestOrganizationExceptionHandlers:
|
||||
):
|
||||
"""Test generic exception handler in get_my_organizations (covers lines 81-83)."""
|
||||
with patch(
|
||||
"app.crud.organization.organization.get_user_organizations_with_details",
|
||||
"app.api.routes.organizations.organization_service.get_user_organizations_with_details",
|
||||
side_effect=Exception("Database connection lost"),
|
||||
):
|
||||
# The exception handler logs and re-raises, so we expect the exception
|
||||
@@ -554,7 +554,7 @@ class TestOrganizationExceptionHandlers:
|
||||
):
|
||||
"""Test generic exception handler in get_organization (covers lines 124-128)."""
|
||||
with patch(
|
||||
"app.crud.organization.organization.get",
|
||||
"app.api.routes.organizations.organization_service.get_organization",
|
||||
side_effect=Exception("Database timeout"),
|
||||
):
|
||||
with pytest.raises(Exception, match="Database timeout"):
|
||||
@@ -569,7 +569,7 @@ class TestOrganizationExceptionHandlers:
|
||||
):
|
||||
"""Test generic exception handler in get_organization_members (covers lines 170-172)."""
|
||||
with patch(
|
||||
"app.crud.organization.organization.get_organization_members",
|
||||
"app.api.routes.organizations.organization_service.get_organization_members",
|
||||
side_effect=Exception("Connection pool exhausted"),
|
||||
):
|
||||
with pytest.raises(Exception, match="Connection pool exhausted"):
|
||||
@@ -591,11 +591,11 @@ class TestOrganizationExceptionHandlers:
|
||||
admin_token = login_response.json()["access_token"]
|
||||
|
||||
with patch(
|
||||
"app.crud.organization.organization.get",
|
||||
"app.api.routes.organizations.organization_service.get_organization",
|
||||
return_value=test_org_with_user_admin,
|
||||
):
|
||||
with patch(
|
||||
"app.crud.organization.organization.update",
|
||||
"app.api.routes.organizations.organization_service.update_organization",
|
||||
side_effect=Exception("Write lock timeout"),
|
||||
):
|
||||
with pytest.raises(Exception, match="Write lock timeout"):
|
||||
|
||||
@@ -11,7 +11,7 @@ These tests prevent unauthorized access and privilege escalation.
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.crud.user import user as user_crud
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ async def async_test_user2(async_test_db):
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
from app.crud.user import user as user_crud
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.schemas.users import UserCreate
|
||||
|
||||
user_data = UserCreate(
|
||||
@@ -191,7 +191,7 @@ class TestRevokeSession:
|
||||
|
||||
# Verify session is deactivated
|
||||
async with SessionLocal() as session:
|
||||
from app.crud.session import session as session_crud
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
|
||||
revoked_session = await session_crud.get(session, id=str(session_id))
|
||||
assert revoked_session.is_active is False
|
||||
@@ -268,7 +268,7 @@ class TestCleanupExpiredSessions:
|
||||
_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
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
async with SessionLocal() as db:
|
||||
@@ -334,7 +334,7 @@ class TestCleanupExpiredSessions:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
# Create only active sessions using CRUD
|
||||
from app.crud.session import session as session_crud
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
async with SessionLocal() as db:
|
||||
@@ -384,7 +384,7 @@ class TestSessionsAdditionalCases:
|
||||
|
||||
# Create multiple sessions
|
||||
async with SessionLocal() as session:
|
||||
from app.crud.session import session as session_crud
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
for i in range(5):
|
||||
@@ -431,7 +431,7 @@ class TestSessionsAdditionalCases:
|
||||
"""Test cleanup with mix of active/inactive and expired/not-expired sessions."""
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
from app.crud.session import session as session_crud
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
async with SessionLocal() as db:
|
||||
@@ -502,10 +502,10 @@ class TestSessionExceptionHandlers:
|
||||
"""Test list_sessions handles database errors (covers lines 104-106)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.crud import session as session_module
|
||||
from app.repositories import session as session_module
|
||||
|
||||
with patch.object(
|
||||
session_module.session,
|
||||
session_module.session_repo,
|
||||
"get_user_sessions",
|
||||
side_effect=Exception("Database error"),
|
||||
):
|
||||
@@ -527,10 +527,10 @@ class TestSessionExceptionHandlers:
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from app.crud import session as session_module
|
||||
from app.repositories import session as session_module
|
||||
|
||||
# First create a session to revoke
|
||||
from app.crud.session import session as session_crud
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
@@ -550,7 +550,7 @@ class TestSessionExceptionHandlers:
|
||||
|
||||
# Mock the deactivate method to raise an exception
|
||||
with patch.object(
|
||||
session_module.session,
|
||||
session_module.session_repo,
|
||||
"deactivate",
|
||||
side_effect=Exception("Database connection lost"),
|
||||
):
|
||||
@@ -568,10 +568,10 @@ class TestSessionExceptionHandlers:
|
||||
"""Test cleanup_expired_sessions handles database errors (covers lines 233-236)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.crud import session as session_module
|
||||
from app.repositories import session as session_module
|
||||
|
||||
with patch.object(
|
||||
session_module.session,
|
||||
session_module.session_repo,
|
||||
"cleanup_expired_for_user",
|
||||
side_effect=Exception("Cleanup failed"),
|
||||
):
|
||||
|
||||
@@ -99,7 +99,7 @@ class TestUpdateCurrentUser:
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"app.api.routes.users.user_crud.update", side_effect=Exception("DB error")
|
||||
"app.api.routes.users.user_service.update_user", side_effect=Exception("DB error")
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
await client.patch(
|
||||
@@ -134,7 +134,7 @@ class TestUpdateCurrentUser:
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"app.api.routes.users.user_crud.update",
|
||||
"app.api.routes.users.user_service.update_user",
|
||||
side_effect=ValueError("Invalid value"),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
@@ -224,7 +224,7 @@ class TestUpdateUserById:
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"app.api.routes.users.user_crud.update", side_effect=ValueError("Invalid")
|
||||
"app.api.routes.users.user_service.update_user", side_effect=ValueError("Invalid")
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
await client.patch(
|
||||
@@ -241,7 +241,7 @@ class TestUpdateUserById:
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"app.api.routes.users.user_crud.update", side_effect=Exception("Unexpected")
|
||||
"app.api.routes.users.user_service.update_user", side_effect=Exception("Unexpected")
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
await client.patch(
|
||||
@@ -354,7 +354,7 @@ class TestDeleteUserById:
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"app.api.routes.users.user_crud.soft_delete",
|
||||
"app.api.routes.users.user_service.soft_delete_user",
|
||||
side_effect=ValueError("Cannot delete"),
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
@@ -371,7 +371,7 @@ class TestDeleteUserById:
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"app.api.routes.users.user_crud.soft_delete",
|
||||
"app.api.routes.users.user_service.soft_delete_user",
|
||||
side_effect=Exception("Unexpected"),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
|
||||
Reference in New Issue
Block a user