Files
fast-next-template/backend/tests/api/test_permissions.py
Felipe Cardoso c589b565f0 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.
2025-11-10 11:55:15 +01:00

289 lines
9.8 KiB
Python

# 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"