from typing import Any from uuid import uuid4 import pytest from httpx import AsyncClient from app.main import app from app.models import OrgRole # Type alias for our route definition: (Method, Endpoint Name, Path Params, JSON Payload) RouteSpec = tuple[str, str, dict[str, Any], dict[str, Any] | None] class SharedProjectAccessTests: """ A reusable interface for testing cross-cutting concerns (Auth, Permissions, 404s) across project-scoped API endpoints. HOW TO USE: 1. Inherit from this class. 2. Apply `@pytest.mark.parametrize("route_spec", YOUR_ROUTES, ids=your_id_formatter)` to your child class. 3. Override the `resolved_route` fixture to map static placeholders (e.g., "PROJECT_ID") to actual database fixture objects at execution time. """ @pytest.fixture def resolved_route(self, *args, **kwargs) -> RouteSpec: """ INTERFACE FIXTURE: Child classes MUST override this fixture. This fixture is responsible for taking a static `route_spec` and swapping out string placeholders for real UUIDs generated by database fixtures. Returns: Tuple containing: - HTTP Method (str) - FastAPI endpoint name (str) - Path parameters (dict) - JSON payload (dict or None) """ raise NotImplementedError( "Child classes inheriting from SharedProjectAccessTests must implement " "the `resolved_route` fixture to inject real database IDs." ) async def test_requires_auth(self, client: AsyncClient, resolved_route: RouteSpec): """Ensures the endpoint returns 401 Unauthorized if no token is provided.""" method, endpoint, kwargs, payload = resolved_route url = app.url_path_for(endpoint, **kwargs) req = getattr(client, method.lower()) response = await req(url, json=payload) if payload else await req(url) assert response.status_code == 401, f"{response.status_code} != 401" async def test_forbidden_other_org( self, client: AsyncClient, other_org_user, resolved_route: RouteSpec ): """Ensures a user from a DIFFERENT organization receives a 403 Forbidden.""" method, endpoint, kwargs, payload = resolved_route if "project_id" not in kwargs: pytest.skip("Not a project-scoped route. Skipping 403 check.") url = app.url_path_for(endpoint, **kwargs) _, headers = other_org_user req = getattr(client, method.lower()) response = ( await req(url, json=payload, headers=headers) if payload else await req(url, headers=headers) ) assert response.status_code == 403, f"{response.status_code} != 403" async def test_project_not_found( self, client: AsyncClient, make_org_user, resolved_route: RouteSpec ): """Ensures that if the project does not exist, the API returns a 404.""" method, endpoint, kwargs, payload = resolved_route _, headers = make_org_user(role=OrgRole.ORG_ADMIN) # Inject a non-existent project_id kwargs_copy = kwargs.copy() if "project_id" in kwargs_copy: kwargs_copy["project_id"] = str(uuid4()) else: pytest.skip("Not a project-scoped route. Skipping 403 check.") url = app.url_path_for(endpoint, **kwargs_copy) req = getattr(client, method.lower()) response = ( await req(url, json=payload, headers=headers) if payload else await req(url, headers=headers) ) assert response.status_code == 404