Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
53 changes: 53 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/tests/test_home.py
Original file line number Diff line number Diff line change
@@ -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)
81 changes: 81 additions & 0 deletions openedx/core/lib/api/exceptions.py
Original file line number Diff line number Diff line change
@@ -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"

Check failure on line 46 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:46:65: E701 Multiple statements on one line (colon)
if isinstance(exc, PermissionDenied): return "authz"

Check failure on line 47 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:47:41: E701 Multiple statements on one line (colon)
if isinstance(exc, NotFound): return "not-found"

Check failure on line 48 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:48:33: E701 Multiple statements on one line (colon)
if isinstance(exc, ValidationError): return "validation"

Check failure on line 49 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:49:40: E701 Multiple statements on one line (colon)
if isinstance(exc, Throttled): return "rate-limited"

Check failure on line 50 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:50:34: E701 Multiple statements on one line (colon)
if isinstance(exc, Conflict): return "conflict"

Check failure on line 51 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:51:33: E701 Multiple statements on one line (colon)
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

Check failure on line 71 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:71:29: E701 Multiple statements on one line (colon)
if isinstance(data, dict) and "detail" in data: return str(data["detail"])

Check failure on line 72 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:72:51: E701 Multiple statements on one line (colon)
if isinstance(data, list) and data: return str(data[0])

Check failure on line 73 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:73:39: E701 Multiple statements on one line (colon)
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]}

Check failure on line 80 in openedx/core/lib/api/exceptions.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.11, 20)

ruff (E701)

openedx/core/lib/api/exceptions.py:80:32: E701 Multiple statements on one line (colon)
return {"non_field_errors": [str(detail)]}
2 changes: 1 addition & 1 deletion openedx/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
Loading