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:
2026-02-27 09:32:57 +01:00
parent 0646c96b19
commit 98b455fdc3
62 changed files with 2933 additions and 1728 deletions

View File

@@ -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:

View File

@@ -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):

View File

@@ -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(

View File

@@ -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(

View File

@@ -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

View File

@@ -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:

View File

@@ -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"):

View File

@@ -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

View File

@@ -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"),
):

View File

@@ -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):