shared_access.py 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. from typing import Any
  2. from uuid import uuid4
  3. import pytest
  4. from httpx import AsyncClient
  5. from app.main import app
  6. from app.models import OrgRole
  7. # Type alias for our route definition: (Method, Endpoint Name, Path Params, JSON Payload)
  8. RouteSpec = tuple[str, str, dict[str, Any], dict[str, Any] | None]
  9. class SharedProjectAccessTests:
  10. """
  11. A reusable interface for testing cross-cutting concerns (Auth, Permissions, 404s)
  12. across project-scoped API endpoints.
  13. HOW TO USE:
  14. 1. Inherit from this class.
  15. 2. Apply `@pytest.mark.parametrize("route_spec", YOUR_ROUTES, ids=your_id_formatter)`
  16. to your child class.
  17. 3. Override the `resolved_route` fixture to map static placeholders (e.g., "PROJECT_ID")
  18. to actual database fixture objects at execution time.
  19. """
  20. @pytest.fixture
  21. def resolved_route(self, *args, **kwargs) -> RouteSpec:
  22. """
  23. INTERFACE FIXTURE: Child classes MUST override this fixture.
  24. This fixture is responsible for taking a static `route_spec` and swapping
  25. out string placeholders for real UUIDs generated by database fixtures.
  26. Returns:
  27. Tuple containing:
  28. - HTTP Method (str)
  29. - FastAPI endpoint name (str)
  30. - Path parameters (dict)
  31. - JSON payload (dict or None)
  32. """
  33. raise NotImplementedError(
  34. "Child classes inheriting from SharedProjectAccessTests must implement "
  35. "the `resolved_route` fixture to inject real database IDs."
  36. )
  37. async def test_requires_auth(self, client: AsyncClient, resolved_route: RouteSpec):
  38. """Ensures the endpoint returns 401 Unauthorized if no token is provided."""
  39. method, endpoint, kwargs, payload = resolved_route
  40. url = app.url_path_for(endpoint, **kwargs)
  41. req = getattr(client, method.lower())
  42. response = await req(url, json=payload) if payload else await req(url)
  43. assert response.status_code == 401, f"{response.status_code} != 401"
  44. async def test_forbidden_other_org(
  45. self, client: AsyncClient, other_org_user, resolved_route: RouteSpec
  46. ):
  47. """Ensures a user from a DIFFERENT organization receives a 403 Forbidden."""
  48. method, endpoint, kwargs, payload = resolved_route
  49. if "project_id" not in kwargs:
  50. pytest.skip("Not a project-scoped route. Skipping 403 check.")
  51. url = app.url_path_for(endpoint, **kwargs)
  52. _, headers = other_org_user
  53. req = getattr(client, method.lower())
  54. response = (
  55. await req(url, json=payload, headers=headers)
  56. if payload
  57. else await req(url, headers=headers)
  58. )
  59. assert response.status_code == 403, f"{response.status_code} != 403"
  60. async def test_project_not_found(
  61. self, client: AsyncClient, make_org_user, resolved_route: RouteSpec
  62. ):
  63. """Ensures that if the project does not exist, the API returns a 404."""
  64. method, endpoint, kwargs, payload = resolved_route
  65. _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
  66. # Inject a non-existent project_id
  67. kwargs_copy = kwargs.copy()
  68. if "project_id" in kwargs_copy:
  69. kwargs_copy["project_id"] = str(uuid4())
  70. else:
  71. pytest.skip("Not a project-scoped route. Skipping 403 check.")
  72. url = app.url_path_for(endpoint, **kwargs_copy)
  73. req = getattr(client, method.lower())
  74. response = (
  75. await req(url, json=payload, headers=headers)
  76. if payload
  77. else await req(url, headers=headers)
  78. )
  79. assert response.status_code == 404