From 5a4960e9f7ae14c1b0ff7a13d61eaec89f0eccea Mon Sep 17 00:00:00 2001 From: svader0 Date: Tue, 25 Aug 2026 18:13:04 -0500 Subject: [PATCH 1/3] test: lock the api/v2 contract on V3-Locations tenants Two customer automations 500'd on /api/v2/endpoints/?limit=500 and on an ordered /api/v2/test_imports/. Both were fixed in 3.2.100, but nothing held them there. Adds the exact production requests as regression tests, plus a sweep over the registered v2 routes so a route that hydrates a deprecated Endpoint fails the suite instead of paging us. --- ...est_apiv2_endpoint_deprecation_contract.py | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 unittests/test_apiv2_endpoint_deprecation_contract.py diff --git a/unittests/test_apiv2_endpoint_deprecation_contract.py b/unittests/test_apiv2_endpoint_deprecation_contract.py new file mode 100644 index 00000000000..043fd1812eb --- /dev/null +++ b/unittests/test_apiv2_endpoint_deprecation_contract.py @@ -0,0 +1,130 @@ +""" +The api/v2 contract on a V3-Locations tenant. + +Three customer automations produced an hourly 500 flood because the deprecated +``Endpoint`` model raises on init and nothing on the v2 surface caught it. Both +named routes were fixed in 3.2.100; these tests hold them there and check that +no other registered route has quietly picked the fault up. +""" +from django.contrib.auth.models import User +from django.test import override_settings +from django.utils import timezone +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from dojo.location.models import LocationProductReference +from dojo.location.status import ProductLocationStatus +from dojo.models import ( + IMPORT_CREATED_FINDING, + Endpoint, + Endpoint_Status, + Engagement, + Finding, + Product, + Product_Type, + Test, + Test_Import, + Test_Import_Finding_Action, + Test_Type, + UserContactInfo, +) +from dojo.url.models import URL +from dojo.urls import v2_api + +from .dojo_test_case import DojoTestCase, skip_unless_v3 +from .test_rest_framework import BASE_API_URL + +# Routes that are not plain list routes. Every entry is a route this sweep stops +# protecting, so keep the list short and justified. +SWEEP_EXEMPT = { + "import-scan", # POST-only + "reimport-scan", # POST-only + "endpoint_meta_import", # POST-only, multipart + "import-languages", # POST-only +} + + +@skip_unless_v3 +@override_settings(SECURE_SSL_REDIRECT=False) +class ApiV2EndpointDeprecationSweep(DojoTestCase): + def setUp(self): + super().setUp() + self.admin = User.objects.create( + username="apiv2_sunset_admin", is_staff=True, is_superuser=True, + ) + UserContactInfo.objects.create(user=self.admin, block_execution=True) + token, _ = Token.objects.get_or_create(user=self.admin) + self.api_client = APIClient() + self.api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + + product_type = Product_Type.objects.create(name="Sunset Org") + self.product = Product.objects.create( + name="Sunset Product", description="regression fixture", prod_type=product_type, + ) + engagement = Engagement.objects.create( + name="Sunset Eng", product=self.product, + target_start=timezone.now(), target_end=timezone.now(), + ) + test = Test.objects.create( + engagement=engagement, + test_type=Test_Type.objects.get_or_create(name="Manual Test")[0], + target_start=timezone.now(), target_end=timezone.now(), + ) + self.finding = Finding.objects.create( + test=test, title="Sunset finding", severity="High", + description="regression fixture", mitigation="n/a", impact="n/a", + reporter=self.admin, active=True, verified=True, + ) + test_import = Test_Import.objects.create(test=test, type=Test_Import.IMPORT_TYPE) + Test_Import_Finding_Action.objects.create( + test_import=test_import, finding=self.finding, action=IMPORT_CREATED_FINDING, + ) + + # A migrated tenant still carries legacy Endpoint rows. Without one, every + # test below passes with nothing to hydrate. + with Endpoint.allow_endpoint_init(): + endpoint = Endpoint( + product=self.product, protocol="https", host="legacy-sunset.example.com", + ) + endpoint.save() + Endpoint_Status(endpoint=endpoint, finding=self.finding).save() + + url = URL(protocol="https", host="loc-sunset.example.com") + url.clean() + saved = URL.bulk_get_or_create([url]) + LocationProductReference.objects.create( + location=saved[0].location, product=self.product, + status=ProductLocationStatus.Active, + ) + + def test_shift_left_and_teambank_endpoints_list(self): + """The exact failing request: GET /api/v2/endpoints/?limit=500.""" + response = self.api_client.get(f"{BASE_API_URL}/endpoints/?limit=500", format="json") + + self.assertEqual(response.status_code, 200, response.content) + self.assertIn("results", response.json()) + + def test_personio_ordered_test_imports_list(self): + """The exact failing request: an ordered GET /api/v2/test_imports/.""" + response = self.api_client.get(f"{BASE_API_URL}/test_imports/?o=id", format="json") + + self.assertEqual(response.status_code, 200, response.content) + self.assertIn("results", response.json()) + + def test_no_registered_v2_list_route_answers_5xx(self): + """ + The audit, as a running check. + + A route that hydrates a legacy Endpoint raises and lands in the exception + handler's unknown-exception branch, which answers 500. This sweep is what + makes "zero unhandled paths" checkable rather than a claim. + """ + failures = [] + for prefix, _viewset, _basename in v2_api.registry: + if prefix in SWEEP_EXEMPT: + continue + response = self.api_client.get(f"{BASE_API_URL}/{prefix}/", format="json") + if response.status_code >= 500: + failures.append((prefix, response.status_code)) + + self.assertEqual(failures, [], f"v2 routes answered 5xx: {failures}") From 7927af2ccfeec43fd35b0cffab53d820e08aa627 Mon Sep 17 00:00:00 2001 From: svader0 Date: Tue, 25 Aug 2026 18:52:54 -0500 Subject: [PATCH 2/3] fix(api): answer a structured 410 when a v2 route reaches a deprecated Endpoint Every fix in this crash class so far patched one call site, so the next unconverted path became the next hourly 500. Catches the class once, in DRF's configured exception handler, and answers a machine-readable body instead: a stable code, a replacement route, and a docs link. Endpoint.__init__ now raises its own NotImplementedError subclass so the handler matches on a type rather than a message, and a genuine NotImplementedError still surfaces as the 500 it is. Logged at info, because an expected answer is not an outage. The 403 on writes is unchanged. --- .../PRO__migrating_from_endpoints.md | 15 +++ dojo/api_v2/exception_handler.py | 13 ++ dojo/endpoint/models.py | 13 +- dojo/location/api/deprecation.py | 27 ++++ ...est_apiv2_endpoint_deprecation_contract.py | 115 +++++++++++++++--- 5 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 dojo/location/api/deprecation.py diff --git a/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md b/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md index 683f4b6a560..4aa1eff41d1 100644 --- a/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md +++ b/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md @@ -99,6 +99,21 @@ A few things behave differently from the original Endpoint API: - **`endpoint` field on Endpoint_Status.** The legacy `endpoint` field is reconstructed by looking up the matching Asset Reference. In rare cases where a Finding's Asset no longer matches its Location's Asset references, this field may be null. - **Pagination and ordering.** Available ordering fields on the read-compat shim are `host`, `product`, `id`, and `active_finding_count`. If your client orders by another field, switch to one of these or move to the new Locations endpoints. +### If a Route Returns 410 + +Any remaining `/api/v2/` path that reaches the old Endpoint data answers `HTTP 410 Gone` rather than a server error. The body is machine-readable, so your automation can branch on `code` instead of parsing the message: + +```json +{ + "code": "endpoint_api_sunset", + "message": "The Endpoint API is not available on instances with Locations enabled. Reads are served from Locations; writes are not available.", + "replacement": "/api/v2/location/", + "docs": "https://docs.defectdojo.com/asset_modelling/locations/pro__migrating_from_endpoints/" +} +``` + +If you hit this on a read you expected to work, send us the request path. That response means we have a route left to convert, and it is a bug on our side, not on yours. + ## Tags and Metadata Tags applied to Endpoints become tags on the Location object (not on the URL subtype). Tag-based filters in the legacy API continue to match. diff --git a/dojo/api_v2/exception_handler.py b/dojo/api_v2/exception_handler.py index 5b9d5fd8b6e..4a90211b94b 100644 --- a/dojo/api_v2/exception_handler.py +++ b/dojo/api_v2/exception_handler.py @@ -8,10 +8,13 @@ from rest_framework.status import ( HTTP_400_BAD_REQUEST, HTTP_409_CONFLICT, + HTTP_410_GONE, HTTP_500_INTERNAL_SERVER_ERROR, ) from rest_framework.views import exception_handler +from dojo.endpoint.models import EndpointDeprecatedError +from dojo.location.api.deprecation import sunset_body from dojo.models import System_Settings from dojo.product_announcements import ErrorPageProductAnnouncement @@ -99,6 +102,16 @@ def custom_exception_handler(exc, context): # Matching the RestrictedError 409 above, no product announcement is # attached to a conflict response. response.data = {"message": UNIQUE_VIOLATION_RESPONSE_MESSAGE} + elif isinstance(exc, EndpointDeprecatedError): + # A v2 route reached the deprecated Endpoint model. Answer the documented + # sunset contract rather than the generic 500 below, and log at info: an + # expected, documented answer reads as an outage in error reporting. + logger.info( + "endpoint api sunset on %s", + (context or {}).get("request", "unknown request"), + ) + response = Response(sunset_body()) + response.status_code = HTTP_410_GONE elif response is None: if System_Settings.objects.get().api_expose_error_details: exception_message = str(exc.args[0]) diff --git a/dojo/endpoint/models.py b/dojo/endpoint/models.py index fb32340a58e..eb67db92b09 100644 --- a/dojo/endpoint/models.py +++ b/dojo/endpoint/models.py @@ -101,6 +101,17 @@ def age(self): return max(0, days) +class EndpointDeprecatedError(NotImplementedError): + + """ + Raised when code hydrates an Endpoint while Locations is enabled. + + A subclass rather than a bare NotImplementedError so the api_v2 exception + handler can answer the sunset contract for this case alone, and leave a + genuine NotImplementedError as the 500 it is. + """ + + class Endpoint(models.Model): protocol = models.CharField(null=True, blank=True, max_length=20, help_text=_("The communication protocol/scheme such as 'http', 'ftp', 'dns', etc.")) @@ -157,7 +168,7 @@ def __init__(self, *args, **kwargs): # migration existing. See dojo/location/feature.py and pro/features/relabel.py:14-28. if settings.V3_FEATURE_LOCATIONS and not getattr(self, "_allow_v3_init", False): msg = "Endpoint model is deprecated when V3_FEATURE_LOCATIONS is enabled" - raise NotImplementedError(msg) + raise EndpointDeprecatedError(msg) super().__init__(*args, **kwargs) def __hash__(self): diff --git a/dojo/location/api/deprecation.py b/dojo/location/api/deprecation.py new file mode 100644 index 00000000000..08e0bf4c9cd --- /dev/null +++ b/dojo/location/api/deprecation.py @@ -0,0 +1,27 @@ +""" +The sunset contract for the deprecated Endpoint API surface. + +Imports nothing on purpose: ``dojo.api_v2.exception_handler`` imports this at +request time and must not pull the model layer in behind it. +""" + +SUNSET_CODE = "endpoint_api_sunset" + +DOCS_URL = "https://docs.defectdojo.com/asset_modelling/locations/pro__migrating_from_endpoints/" + +SUNSET_MESSAGE = ( + "The Endpoint API is not available on instances with Locations enabled. " + "Reads are served from Locations; writes are not available." +) + +DEFAULT_REPLACEMENT = "/api/v2/location/" + + +def sunset_body(): + """The response body a client branches on: a stable code, then where to go.""" + return { + "code": SUNSET_CODE, + "message": SUNSET_MESSAGE, + "replacement": DEFAULT_REPLACEMENT, + "docs": DOCS_URL, + } diff --git a/unittests/test_apiv2_endpoint_deprecation_contract.py b/unittests/test_apiv2_endpoint_deprecation_contract.py index 043fd1812eb..cf516b1af46 100644 --- a/unittests/test_apiv2_endpoint_deprecation_contract.py +++ b/unittests/test_apiv2_endpoint_deprecation_contract.py @@ -6,12 +6,15 @@ named routes were fixed in 3.2.100; these tests hold them there and check that no other registered route has quietly picked the fault up. """ + from django.contrib.auth.models import User from django.test import override_settings from django.utils import timezone from rest_framework.authtoken.models import Token -from rest_framework.test import APIClient +from rest_framework.test import APIClient, APIRequestFactory +from dojo.api_v2.exception_handler import custom_exception_handler +from dojo.endpoint.models import EndpointDeprecatedError from dojo.location.models import LocationProductReference from dojo.location.status import ProductLocationStatus from dojo.models import ( @@ -37,10 +40,10 @@ # Routes that are not plain list routes. Every entry is a route this sweep stops # protecting, so keep the list short and justified. SWEEP_EXEMPT = { - "import-scan", # POST-only - "reimport-scan", # POST-only + "import-scan", # POST-only + "reimport-scan", # POST-only "endpoint_meta_import", # POST-only, multipart - "import-languages", # POST-only + "import-languages", # POST-only } @@ -50,7 +53,9 @@ class ApiV2EndpointDeprecationSweep(DojoTestCase): def setUp(self): super().setUp() self.admin = User.objects.create( - username="apiv2_sunset_admin", is_staff=True, is_superuser=True, + username="apiv2_sunset_admin", + is_staff=True, + is_superuser=True, ) UserContactInfo.objects.create(user=self.admin, block_execution=True) token, _ = Token.objects.get_or_create(user=self.admin) @@ -59,32 +64,47 @@ def setUp(self): product_type = Product_Type.objects.create(name="Sunset Org") self.product = Product.objects.create( - name="Sunset Product", description="regression fixture", prod_type=product_type, + name="Sunset Product", + description="regression fixture", + prod_type=product_type, ) engagement = Engagement.objects.create( - name="Sunset Eng", product=self.product, - target_start=timezone.now(), target_end=timezone.now(), + name="Sunset Eng", + product=self.product, + target_start=timezone.now(), + target_end=timezone.now(), ) test = Test.objects.create( engagement=engagement, test_type=Test_Type.objects.get_or_create(name="Manual Test")[0], - target_start=timezone.now(), target_end=timezone.now(), + target_start=timezone.now(), + target_end=timezone.now(), ) self.finding = Finding.objects.create( - test=test, title="Sunset finding", severity="High", - description="regression fixture", mitigation="n/a", impact="n/a", - reporter=self.admin, active=True, verified=True, + test=test, + title="Sunset finding", + severity="High", + description="regression fixture", + mitigation="n/a", + impact="n/a", + reporter=self.admin, + active=True, + verified=True, ) test_import = Test_Import.objects.create(test=test, type=Test_Import.IMPORT_TYPE) Test_Import_Finding_Action.objects.create( - test_import=test_import, finding=self.finding, action=IMPORT_CREATED_FINDING, + test_import=test_import, + finding=self.finding, + action=IMPORT_CREATED_FINDING, ) # A migrated tenant still carries legacy Endpoint rows. Without one, every # test below passes with nothing to hydrate. with Endpoint.allow_endpoint_init(): endpoint = Endpoint( - product=self.product, protocol="https", host="legacy-sunset.example.com", + product=self.product, + protocol="https", + host="legacy-sunset.example.com", ) endpoint.save() Endpoint_Status(endpoint=endpoint, finding=self.finding).save() @@ -93,7 +113,8 @@ def setUp(self): url.clean() saved = URL.bulk_get_or_create([url]) LocationProductReference.objects.create( - location=saved[0].location, product=self.product, + location=saved[0].location, + product=self.product, status=ProductLocationStatus.Active, ) @@ -128,3 +149,67 @@ def test_no_registered_v2_list_route_answers_5xx(self): failures.append((prefix, response.status_code)) self.assertEqual(failures, [], f"v2 routes answered 5xx: {failures}") + + +@skip_unless_v3 +@override_settings(SECURE_SSL_REDIRECT=False) +class ApiV2EndpointSunsetBackstop(DojoTestCase): + + """ + Any v2 path that still reaches the deprecated Endpoint model must answer the + documented sunset contract, not a 500. This is the floor: a path nobody + converted still answers 410. + """ + + def test_endpoint_init_raises_a_dedicated_subclass(self): + """The backstop matches on a type, so the guard must raise its own class.""" + with self.assertRaises(EndpointDeprecatedError): + Endpoint(host="sunset.example.com") + + # Existing `except NotImplementedError` handlers must keep working. + self.assertTrue(issubclass(EndpointDeprecatedError, NotImplementedError)) + + def test_a_path_that_hydrates_an_endpoint_answers_410(self): + context = {"request": APIRequestFactory().get("/api/v2/findings/")} + response = custom_exception_handler(EndpointDeprecatedError("boom"), context) + + self.assertEqual(response.status_code, 410) + self.assertEqual(response.data["code"], "endpoint_api_sunset") + self.assertEqual(response.data["replacement"], "/api/v2/location/") + self.assertEqual( + response.data["docs"], + "https://docs.defectdojo.com/asset_modelling/locations/pro__migrating_from_endpoints/", + ) + self.assertIn("Locations", response.data["message"]) + + def test_an_unrelated_not_implemented_error_still_answers_500(self): + """The backstop must be narrow. A genuine bug must stay a 500.""" + context = {"request": APIRequestFactory().get("/api/v2/findings/")} + response = custom_exception_handler(NotImplementedError("a real bug"), context) + + self.assertEqual(response.status_code, 500) + + def test_the_shipped_403_on_writes_is_unchanged(self): + """ + The backstop must not move the documented write contract. + + Writes answer 403 today and the migration guide says so. Changing that is + a separate decision for a minor release. + """ + admin = User.objects.create( + username="apiv2_sunset_403_admin", + is_staff=True, + is_superuser=True, + ) + UserContactInfo.objects.create(user=admin, block_execution=True) + token, _ = Token.objects.get_or_create(user=admin) + api_client = APIClient() + api_client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + + response = api_client.post( + f"{BASE_API_URL}/endpoints/", + {"host": "write.example.com"}, + format="json", + ) + + self.assertEqual(response.status_code, 403, response.content) From 20ad0c97bd13e25278dcbe2c47b741bae16c31a7 Mon Sep 17 00:00:00 2001 From: svader0 Date: Tue, 25 Aug 2026 19:18:09 -0500 Subject: [PATCH 3/3] test: describe the failing requests without naming reporters The two regression tests were named after the accounts that reported them, which does not belong in a public repository. Renames them after the requests they make, and rewrites the module docstring so it describes the failure instead of the reporters. Widens the route sweep to fail on 410 as well as 5xx. The new backstop logs at info, so a route that drifts into the deprecation sunset would otherwise produce no signal at all, and the sweep is the only check we control. Adds an assertion that DRF is actually wired to the configured handler, since every 410 assertion in this file calls it directly instead. Renames the test that calls the handler by hand, since it hydrates nothing. Drops a false claim from the deprecation module's docstring: importing it does pull in the model layer, through dojo/location/__init__.py, so it cannot say it does not. Notes in the exception handler that the deprecation branch only sees exceptions raised during DRF's dispatch. --- dojo/api_v2/exception_handler.py | 1 + dojo/location/api/deprecation.py | 7 +--- ...est_apiv2_endpoint_deprecation_contract.py | 38 +++++++++++++------ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/dojo/api_v2/exception_handler.py b/dojo/api_v2/exception_handler.py index 4a90211b94b..d362f71304b 100644 --- a/dojo/api_v2/exception_handler.py +++ b/dojo/api_v2/exception_handler.py @@ -106,6 +106,7 @@ def custom_exception_handler(exc, context): # A v2 route reached the deprecated Endpoint model. Answer the documented # sunset contract rather than the generic 500 below, and log at info: an # expected, documented answer reads as an outage in error reporting. + # This branch only catches exceptions raised during DRF's dispatch. logger.info( "endpoint api sunset on %s", (context or {}).get("request", "unknown request"), diff --git a/dojo/location/api/deprecation.py b/dojo/location/api/deprecation.py index 08e0bf4c9cd..c1ab7ecc584 100644 --- a/dojo/location/api/deprecation.py +++ b/dojo/location/api/deprecation.py @@ -1,9 +1,4 @@ -""" -The sunset contract for the deprecated Endpoint API surface. - -Imports nothing on purpose: ``dojo.api_v2.exception_handler`` imports this at -request time and must not pull the model layer in behind it. -""" +"""The sunset contract for the deprecated Endpoint API surface.""" SUNSET_CODE = "endpoint_api_sunset" diff --git a/unittests/test_apiv2_endpoint_deprecation_contract.py b/unittests/test_apiv2_endpoint_deprecation_contract.py index cf516b1af46..e0211add7bb 100644 --- a/unittests/test_apiv2_endpoint_deprecation_contract.py +++ b/unittests/test_apiv2_endpoint_deprecation_contract.py @@ -1,15 +1,18 @@ """ The api/v2 contract on a V3-Locations tenant. -Three customer automations produced an hourly 500 flood because the deprecated -``Endpoint`` model raises on init and nothing on the v2 surface caught it. Both -named routes were fixed in 3.2.100; these tests hold them there and check that -no other registered route has quietly picked the fault up. +Some v2 routes were reported to answer with an hourly 500 flood, because the +deprecated ``Endpoint`` model raises on init and nothing on the v2 surface +caught it. The reported routes were fixed in 3.2.100; these tests hold them +there and check that no other registered route has quietly picked the fault +up. """ +from django.conf import settings from django.contrib.auth.models import User from django.test import override_settings from django.utils import timezone +from django.utils.module_loading import import_string from rest_framework.authtoken.models import Token from rest_framework.test import APIClient, APIRequestFactory @@ -118,14 +121,14 @@ def setUp(self): status=ProductLocationStatus.Active, ) - def test_shift_left_and_teambank_endpoints_list(self): + def test_endpoints_list_with_a_large_limit(self): """The exact failing request: GET /api/v2/endpoints/?limit=500.""" response = self.api_client.get(f"{BASE_API_URL}/endpoints/?limit=500", format="json") self.assertEqual(response.status_code, 200, response.content) self.assertIn("results", response.json()) - def test_personio_ordered_test_imports_list(self): + def test_ordered_test_imports_list(self): """The exact failing request: an ordered GET /api/v2/test_imports/.""" response = self.api_client.get(f"{BASE_API_URL}/test_imports/?o=id", format="json") @@ -137,18 +140,21 @@ def test_no_registered_v2_list_route_answers_5xx(self): The audit, as a running check. A route that hydrates a legacy Endpoint raises and lands in the exception - handler's unknown-exception branch, which answers 500. This sweep is what - makes "zero unhandled paths" checkable rather than a claim. + handler's unknown-exception branch, which answers 500. The backstop now + catches that and answers 410 instead, logged at info, so a route that + drifts into the deprecation sunset produces no error signal on its own. + This sweep is what makes both claims checkable: no v2 list route may + answer 5xx, and none may fall into the deprecation sunset. """ failures = [] for prefix, _viewset, _basename in v2_api.registry: if prefix in SWEEP_EXEMPT: continue response = self.api_client.get(f"{BASE_API_URL}/{prefix}/", format="json") - if response.status_code >= 500: + if response.status_code >= 500 or response.status_code == 410: failures.append((prefix, response.status_code)) - self.assertEqual(failures, [], f"v2 routes answered 5xx: {failures}") + self.assertEqual(failures, [], f"v2 routes answered 5xx or fell into the deprecation sunset: {failures}") @skip_unless_v3 @@ -161,6 +167,16 @@ class ApiV2EndpointSunsetBackstop(DojoTestCase): converted still answers 410. """ + def test_the_configured_handler_answers_410(self): + """Every other test calls the handler directly. This one goes through DRF's wiring.""" + handler = import_string(settings.REST_FRAMEWORK["EXCEPTION_HANDLER"]) + context = {"request": APIRequestFactory().get("/api/v2/findings/")} + + response = handler(EndpointDeprecatedError("boom"), context) + + self.assertEqual(response.status_code, 410) + self.assertEqual(response.data["code"], "endpoint_api_sunset") + def test_endpoint_init_raises_a_dedicated_subclass(self): """The backstop matches on a type, so the guard must raise its own class.""" with self.assertRaises(EndpointDeprecatedError): @@ -169,7 +185,7 @@ def test_endpoint_init_raises_a_dedicated_subclass(self): # Existing `except NotImplementedError` handlers must keep working. self.assertTrue(issubclass(EndpointDeprecatedError, NotImplementedError)) - def test_a_path_that_hydrates_an_endpoint_answers_410(self): + def test_the_handler_answers_410_for_the_deprecation_error(self): context = {"request": APIRequestFactory().get("/api/v2/findings/")} response = custom_exception_handler(EndpointDeprecatedError("boom"), context)