diff --git a/cms/djangoapps/contentstore/rest_api/v2/tests/__init__.py b/cms/djangoapps/contentstore/rest_api/v2/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cms/djangoapps/contentstore/rest_api/v2/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v2/tests/test_home.py new file mode 100644 index 000000000000..1b3a034be8ad --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/tests/test_home.py @@ -0,0 +1,53 @@ +""" +ADR 0029 – Standardized error-response tests for home views (v2). + +Both HomeCoursesViewSetV2 and HomePageCoursesViewV2 are free of +DeveloperErrorViewMixin and inline error returns. These tests are regression +tests verifying that the central exception handler produces the correct +ADR 0029 envelope for auth errors. +""" +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient, APITestCase + +_REQUIRED_ERROR_FIELDS = ("type", "title", "status", "detail", "instance") + + +class TestHomeCoursesViewSetV2ErrorShape(APITestCase): + """ + ADR 0029 – error response shape regression tests for HomeCoursesViewSetV2. + + Verifies that 401 responses on the list action conform to the standardized + JSON envelope. + """ + + def setUp(self): + super().setUp() + self.client = APIClient() + self.list_url = reverse("cms.djangoapps.contentstore:v2:home-courses-list") + + def test_unauthenticated_returns_standardized_401(self): + """Unauthenticated GET must return 401 with the ADR 0029 envelope.""" + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + for field in _REQUIRED_ERROR_FIELDS: + self.assertIn(field, response.data, f"ADR 0029: missing field '{field}'") + + def test_unauthenticated_401_type_uri(self): + """The ``type`` field for 401 must be the ADR 0029 authn URI.""" + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.data.get("type"), "https://docs.openedx.org/errors/authn") + + def test_error_body_has_no_developer_message(self): + """Error responses must NOT contain old DeveloperErrorViewMixin fields.""" + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertNotIn("developer_message", response.data) + self.assertNotIn("error_code", response.data) + + def test_instance_field_is_request_path(self): + """The ``instance`` field must equal the request path.""" + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.data.get("instance"), self.list_url) diff --git a/openedx/core/lib/api/exceptions.py b/openedx/core/lib/api/exceptions.py new file mode 100644 index 000000000000..ad394b66a790 --- /dev/null +++ b/openedx/core/lib/api/exceptions.py @@ -0,0 +1,81 @@ +"""ADR 0029 – Standardized error-response exception handler and helpers.""" +from rest_framework.exceptions import APIException, ValidationError +from rest_framework.response import Response + + +class Conflict(APIException): + """HTTP 409 Conflict — ADR 0029.""" + status_code = 409 + default_detail = "A conflict occurred." + default_code = "conflict" + + +def standardized_error_exception_handler(exc, context): + """ADR 0029 – platform-level DRF exception handler.""" + from openedx.core.lib.request_utils import ignored_error_exception_handler + response = ignored_error_exception_handler(exc, context) + if response is None: + return Response( + {"type": "https://docs.openedx.org/errors/internal", + "title": "Internal Server Error", "status": 500, + "detail": "An unexpected error occurred. Please try again later."}, + status=500, + ) + request = context.get("request") + body = { + "type": f"https://docs.openedx.org/errors/{_error_type(exc)}", + "title": _error_title(exc), + "status": response.status_code, + "detail": _flatten_detail(response.data), + } + if request: + body["instance"] = request.path + if hasattr(exc, "user_message") and exc.user_message: + body["user_message"] = exc.user_message + if isinstance(exc, ValidationError) and hasattr(exc, "detail"): + body["errors"] = _normalize_validation_errors(exc.detail) + response.data = body + response["Content-Type"] = "application/json" + return response + + +def _error_type(exc): + from rest_framework.exceptions import ( + AuthenticationFailed, NotAuthenticated, NotFound, PermissionDenied, Throttled, ValidationError, + ) + if isinstance(exc, (NotAuthenticated, AuthenticationFailed)): return "authn" + if isinstance(exc, PermissionDenied): return "authz" + if isinstance(exc, NotFound): return "not-found" + if isinstance(exc, ValidationError): return "validation" + if isinstance(exc, Throttled): return "rate-limited" + if isinstance(exc, Conflict): return "conflict" + return "internal" + + +def _error_title(exc): + from rest_framework.exceptions import ( + AuthenticationFailed, NotAuthenticated, NotFound, PermissionDenied, Throttled, ValidationError, + ) + return { + NotAuthenticated: "Authentication Required", + AuthenticationFailed: "Authentication Failed", + PermissionDenied: "Permission Denied", + NotFound: "Not Found", + ValidationError: "Validation Error", + Throttled: "Too Many Requests", + Conflict: "Conflict", + }.get(type(exc), "Internal Server Error") + + +def _flatten_detail(data): + if isinstance(data, str): return data + if isinstance(data, dict) and "detail" in data: return str(data["detail"]) + if isinstance(data, list) and data: return str(data[0]) + return str(data) + + +def _normalize_validation_errors(detail): + if isinstance(detail, dict): + return {f: [str(e) for e in (errs if isinstance(errs, list) else [errs])] for f, errs in detail.items()} + if isinstance(detail, list): return {"non_field_errors": [str(e) for e in detail]} + return {"non_field_errors": [str(detail)]} diff --git a/openedx/envs/common.py b/openedx/envs/common.py index 5d7c105025ed..1247116e397c 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -820,7 +820,7 @@ def add_optional_apps(optional_apps, installed_apps): 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', ), - 'EXCEPTION_HANDLER': 'openedx.core.lib.request_utils.ignored_error_exception_handler', + 'EXCEPTION_HANDLER': 'openedx.core.lib.api.exceptions.standardized_error_exception_handler', # ADR 0029 'PAGE_SIZE': 10, 'URL_FORMAT_OVERRIDE': None, 'DEFAULT_THROTTLE_RATES': {