# tests/api/test_permissions.py """ 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 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 OrganizationRole, UserOrganization @pytest_asyncio.fixture 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!"}, ) assert response.status_code == 200 return response.json()["access_token"] @pytest_asyncio.fixture 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!"}, ) assert response.status_code == 200 return response.json()["access_token"] @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 async with AsyncTestingSessionLocal() as session: org = Organization( name="No Members Org", slug="no-members-org", description="Test org with no members", ) session.add(org) await session.commit() await session.refresh(org) return org @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 async with AsyncTestingSessionLocal() as session: org = Organization( name="Member Only Org", slug="member-only-org", description="Test org where user is just a member", ) session.add(org) await session.commit() await session.refresh(org) # Add user as MEMBER (not admin/owner) membership = UserOrganization( user_id=async_test_user.id, organization_id=org.id, role=OrganizationRole.MEMBER, is_active=True, ) session.add(membership) await session.commit() return org # ===== CRITICAL SECURITY TESTS: Superuser Bypass ===== class TestSuperuserBypass: """ CRITICAL: Test that superusers can bypass organization checks. Missing coverage lines: 99, 154-155, 175 These are critical security paths that MUST work correctly. """ @pytest.mark.asyncio async def test_superuser_can_access_org_not_member_of( self, client, superuser_token, test_org_no_members ): """ CRITICAL: Superuser should bypass membership check (covers line 175). Bug scenario: If this fails, superusers can't manage orgs they're not members of. """ response = await client.get( f"/api/v1/organizations/{test_org_no_members.id}", headers={"Authorization": f"Bearer {superuser_token}"}, ) # Superuser should succeed even though they're not a member assert response.status_code == status.HTTP_200_OK data = response.json() assert data["id"] == str(test_org_no_members.id) @pytest.mark.asyncio async def test_regular_user_cannot_access_org_not_member_of( 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}"}, ) # Regular user should fail permission check assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.asyncio async def test_superuser_can_update_org_not_admin_of( self, client, superuser_token, test_org_no_members ): """ CRITICAL: Superuser should bypass admin check (covers line 99). Bug scenario: If this fails, superusers can't manage orgs. """ response = await client.put( f"/api/v1/organizations/{test_org_no_members.id}", json={"name": "Updated by Superuser"}, headers={"Authorization": f"Bearer {superuser_token}"}, ) # Superuser should succeed in updating org assert response.status_code == status.HTTP_200_OK data = response.json() assert data["name"] == "Updated by Superuser" @pytest.mark.asyncio async def test_regular_member_cannot_update_org( 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}"}, ) # Member should fail - need admin or owner role assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.asyncio async def test_superuser_can_list_org_members_not_member_of( 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}"}, ) # Superuser should succeed assert response.status_code == status.HTTP_200_OK data = response.json() assert "data" in data assert "pagination" in data # ===== 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 # Create inactive user async with AsyncTestingSessionLocal() as session: user = User( id=uuid4(), email="inactive@example.com", password_hash=get_password_hash("TestPassword123!"), first_name="Inactive", last_name="User", is_active=False, # INACTIVE ) session.add(user) await session.commit() # Try to login (should work - auth checks active status separately) # But accessing protected endpoints should fail login_response = await client.post( "/api/v1/auth/login", json={"email": "inactive@example.com", "password": "TestPassword123!"}, ) # Login might fail for inactive users depending on auth implementation if login_response.status_code == 200: token = login_response.json()["access_token"] # Try to access protected endpoint response = await client.get( "/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, ] @pytest.mark.asyncio async def test_nonexistent_organization_returns_403_not_404( self, client, regular_user_token ): """ Test that accessing nonexistent org returns 403, not 404. This is correct behavior - don't leak info about org existence. The permission check runs BEFORE the org lookup, so if user is not a member, they get 403 regardless of org existence. """ fake_org_id = uuid4() response = await client.get( f"/api/v1/organizations/{fake_org_id}", headers={"Authorization": f"Bearer {regular_user_token}"}, ) # Should get 403 (not a member), not 404 (doesn't exist) # This prevents leaking information about org existence assert response.status_code == status.HTTP_403_FORBIDDEN # ===== 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 async with AsyncTestingSessionLocal() as session: org = Organization(name="Admin Org", slug="admin-org") session.add(org) await session.commit() await session.refresh(org) membership = UserOrganization( user_id=async_test_user.id, organization_id=org.id, role=OrganizationRole.ADMIN, is_active=True, ) session.add(membership) await session.commit() return org @pytest.mark.asyncio async def test_admin_can_update_org( 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}"}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["name"] == "Updated by Admin"