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 357a3342cc4..defdc2fd7e5 100644 --- a/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md +++ b/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md @@ -9,7 +9,7 @@ When you enable Locations on an existing DefectDojo Pro instance, the data alrea Note that migration is **one-way**. There is no automated rollback path that re-creates Endpoints from Locations. -> **Endpoints are deprecated.** As of **3.2.201**, Endpoints are deprecated in favour of Locations and are scheduled for **removal in 3.4.0**. Until then the Endpoints UI and the read-only Endpoint API stay available, and the **DEPRECATED** badges shown on the Endpoints menu, the Endpoint list pages, and a Finding's endpoint tables link here. Enable Locations and run the migration below before 3.4.0. +> **Endpoints are deprecated.** As of **3.2.201**, Endpoints are deprecated in favour of Locations. The Endpoints pages go away in **3.6.0 (December 2026)**. The read-only Endpoint API (`/api/v2/endpoints/`, `/api/v2/endpoint_status/`) stays until its own deprecation announcement. The Endpoints menu, the Endpoint list pages, and a Finding's endpoint tables carry a deprecation banner. Enable Locations and run the migration below before 3.6.0. ## Running the migration from the Feature Flags page diff --git a/docs/content/navigation/PRO__menu_badges.md b/docs/content/navigation/PRO__menu_badges.md index 5fc0449e345..34ef249839a 100644 --- a/docs/content/navigation/PRO__menu_badges.md +++ b/docs/content/navigation/PRO__menu_badges.md @@ -32,7 +32,7 @@ The distinction is deliberate, because the two states call for different respons **`DEPRECATED`** means a removal has been announced. Hovering the badge tells you the release it goes away in, and clicking it opens the deprecation notice: -> \ is deprecated and will be removed by \. Click for the deprecation notice. +> \ is deprecated and will be removed by \ (\). Click for the deprecation notice. **`LEGACY`** means the feature has been superseded but no removal has been scheduled. There is deliberately no date in the hover text, because inventing one would be worse than saying nothing. Instead it names the replacement and links to its documentation: @@ -42,6 +42,10 @@ A `LEGACY` feature keeps working and keeps getting fixes. It just will not gain Both badges are links, because a tooltip closes the moment your pointer leaves it and so cannot hold a clickable link. Clicking either badge opens its notice in a new tab; it does not navigate the menu entry underneath. +## Pages of a deprecated feature carry a banner + +Every page that belongs to a `DEPRECATED` feature opens with a warning banner. The banner carries the same red badge, names the release the feature goes away in, and says what to do. It reads the same announcement as the sidebar badge, so the two always agree. + ## What currently carries a badge **`SOON`** @@ -55,10 +59,9 @@ Both badges are links, because a tooltip closes the moment your pointer leaves i **`DEPRECATED`** -* **Settings > Configuration > Tool Types** -* **Settings > Configuration > Tool Configurations** - -Both are removed in **3.5.0**, along with the API-based (pull) parsers they exist to configure. The [3.2 upgrade notes](/releases/os_upgrading/3.2/) explain what to migrate to and by when. +* **Settings > Configuration > Tool Types** and **Tool Configurations**, removed in **3.5.0 (November 2026)** along with the API-based (pull) parsers they exist to configure. The [3.2 upgrade notes](/releases/os_upgrading/3.2/) explain what to migrate to and by when. +* **API Scan Configurations** on an Asset, removed in **3.5.0 (November 2026)** for the same reason. +* **Endpoints** (the Endpoints menu, the Endpoint and Host list pages, and a Finding's endpoint tables), removed in **3.6.0 (December 2026)** in favour of Locations. See [Migrating from Endpoints](/asset_modelling/locations/pro__migrating_from_endpoints/). ![DEPRECATED badges under Settings > Configuration](images/menu_badge_deprecated.png) diff --git a/docs/content/releases/os_upgrading/3.4.100.md b/docs/content/releases/os_upgrading/3.4.100.md new file mode 100644 index 00000000000..b00c1046d0e --- /dev/null +++ b/docs/content/releases/os_upgrading/3.4.100.md @@ -0,0 +1,28 @@ +--- +title: 'Upgrading to DefectDojo Version 3.4.100' +toc_hide: true +weight: -20261013 +description: The deprecated Tool Type, Tool Configuration, and API Scan Configuration API endpoints send a corrected end-of-life date and a single-sentence Classic UI warning. +--- + +## Deprecated API endpoints: corrected end-of-life date + +The deprecated Tool Type, Tool Configuration, and API Scan Configuration API endpoints send a +corrected end-of-life date. They now send `X-End-Of-Life-Date: 2026-11-02T00:00:00`, the actual +3.5.0 release day. The earlier value was `2026-11-01T00:00:00`, one day off. + +The affected endpoints: + +- `/api/v2/tool_types/` +- `/api/v2/tool_configurations/` +- `/api/v2/product_api_scan_configurations/` +- `/api/v2/asset_api_scan_configurations/` + +Their Classic UI warning is now one sentence: ` are deprecated and will be removed in +DefectDojo 3.5.0 (November 2026). Please plan to migrate away from this feature.` + +### What you need to do + +Nothing. If your automation reads `X-End-Of-Life-Date`, it now receives the correct day. + +For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.4.100). diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index def7d4b239a..eb0ff0f4421 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, time import pghistory from dateutil.relativedelta import relativedelta @@ -37,6 +37,7 @@ ) from dojo.authorization import api_permissions as permissions from dojo.authorization.authorization import user_has_permission_or_403 +from dojo.deprecations import get_deprecation from dojo.endpoint.ui.views import get_endpoint_ids from dojo.engagement.queries import get_authorized_engagements from dojo.filters import ( @@ -166,14 +167,13 @@ class PrefetchDojoModelViewSet( class DeprecationNoticeMixin: - deprecated: bool | None = None - end_of_life_date: datetime | None = None + deprecation: str = "" def finalize_response(self, request, response, *args, **kwargs): - if self.deprecated is not None: - response["X-Deprecated"] = self.deprecated - if self.end_of_life_date is not None: - response["X-End-Of-Life-Date"] = self.end_of_life_date.isoformat() + notice = get_deprecation(self.deprecation) + if notice is not None and not notice.removed: + response["X-Deprecated"] = True + response["X-End-Of-Life-Date"] = datetime.combine(notice.removal_date, time.min).isoformat() return super().finalize_response(request, response, *args, **kwargs) diff --git a/dojo/asset/api/views.py b/dojo/asset/api/views.py index fe5cae13013..48df2a5cf1f 100644 --- a/dojo/asset/api/views.py +++ b/dojo/asset/api/views.py @@ -1,4 +1,3 @@ -from datetime import datetime from functools import partial from django.db.models import OuterRef, Value @@ -34,14 +33,12 @@ # Authorization: object-based -# Deprecated in 3.2.0, removal planned for 3.5.0 (serves the API-based pull parsers). @extend_schema_view(**schema_with_prefetch()) class AssetAPIScanConfigurationViewSet( DeprecationNoticeMixin, PrefetchDojoModelViewSet, ): - deprecated = True - end_of_life_date = datetime(2026, 11, 1) + deprecation = "api_scan_configuration" serializer_class = serializers.AssetAPIScanConfigurationSerializer queryset = Product_API_Scan_Configuration.objects.none() filter_backends = (DjangoFilterBackend,) diff --git a/dojo/decorators.py b/dojo/decorators.py index cf0ec153f93..31050436479 100644 --- a/dojo/decorators.py +++ b/dojo/decorators.py @@ -4,11 +4,13 @@ from django.conf import settings from django.contrib import messages +from django.core.exceptions import ImproperlyConfigured from django.http import Http404 from django_ratelimit import UNSAFE from django_ratelimit.core import is_ratelimited from django_ratelimit.exceptions import Ratelimited +from dojo.deprecations import get_deprecation from dojo.models import Dojo_User logger = logging.getLogger(__name__) @@ -169,26 +171,28 @@ def _wrapped(request, *args, **kw): return decorator -def deprecated_view(feature_name, removal_version="X.Y.Z", removal_date="some time in the future"): +def deprecated_view(key): """ - Decorator that adds a deprecation warning message to a view. + Show the Classic UI warning that ``dojo.deprecations`` declares for ``key``. Only adds the message on GET requests to avoid duplicate warnings when POST requests redirect. """ + if get_deprecation(key) is None: + msg = f"{key!r} has no declaration in dojo/deprecations.py" + raise ImproperlyConfigured(msg) + def decorator(func): @wraps(func) def _wrapped(request, *args, **kwargs): - if request.method == "GET": - messages.add_message( - request, - messages.WARNING, - f"{feature_name} is deprecated and will be removed in DefectDojo v{removal_version} " - f"({removal_date}). Please plan to migrate away from this feature.", - extra_tags="alert-warning", - ) + notice = get_deprecation(key) + if request.method == "GET" and notice is not None and not notice.removed: + messages.add_message(request, messages.WARNING, notice.message(), extra_tags="alert-warning") return func(request, *args, **kwargs) + + _wrapped.deprecation = key return _wrapped + return decorator diff --git a/dojo/deprecations.py b/dojo/deprecations.py new file mode 100644 index 00000000000..46ba3753176 --- /dev/null +++ b/dojo/deprecations.py @@ -0,0 +1,105 @@ +import re +from dataclasses import dataclass +from datetime import date + +from django.utils import translation +from django.utils.translation import gettext_lazy as _ + +# Copied from the release milestones. A deprecation names its removal release, and its +# day always comes from here. +RELEASE_DATES = { + "3.3.0": date(2026, 9, 8), + "3.4.0": date(2026, 10, 5), + "3.5.0": date(2026, 11, 2), + "3.6.0": date(2026, 12, 7), +} + +_MINOR_RELEASE = re.compile(r"\d+\.\d+\.0") + + +@dataclass(frozen=True) +class Deprecation: + key: str + title: str + removal_version: str + notice_url: str + action: str = "" + removed: bool = False + + @property + def removal_date(self) -> date: + return RELEASE_DATES[self.removal_version] + + @property + def removal_label(self) -> str: + return f"{self.removal_version} ({self.removal_date:%B %Y})" + + def message(self) -> str: + # Keep the whole sentence in English: no catalog holds it. + with translation.override(None): + return ( + f"{self.title} are deprecated and will be removed in DefectDojo {self.removal_label}. " + "Please plan to migrate away from this feature." + ) + + +_DEPRECATIONS: dict[str, Deprecation] = {} + + +_VERSION = re.compile(r"v?(\d+)\.(\d+)\.(\d+)") + + +def _release(version: str) -> tuple[int, ...]: + match = _VERSION.match(version) + if match is None: + msg = f"{version!r} is not an X.Y.Z version" + raise ValueError(msg) + return tuple(int(part) for part in match.groups()) + + +def register_deprecation(entry: Deprecation, *, override: bool = False) -> None: + if not _MINOR_RELEASE.fullmatch(entry.removal_version): + msg = f"{entry.key}: a feature is removed in a minor release (X.Y.0), not {entry.removal_version}" + raise ValueError(msg) + if entry.removal_version not in RELEASE_DATES: + msg = f"{entry.key}: add {entry.removal_version} to RELEASE_DATES before naming it" + raise ValueError(msg) + if entry.key in _DEPRECATIONS and not override: + return + _DEPRECATIONS[entry.key] = entry + + +def get_deprecation(key: str) -> Deprecation | None: + return _DEPRECATIONS.get(key) + + +def active_deprecations() -> list[Deprecation]: + return [entry for entry in _DEPRECATIONS.values() if not entry.removed] + + +def overdue_deprecations(version: str) -> list[Deprecation]: + current = _release(version) + return [entry for entry in active_deprecations() if _release(entry.removal_version) < current] + + +_UPGRADING_3_2 = "https://docs.defectdojo.com/releases/os_upgrading/3.2/" + +register_deprecation( + Deprecation(key="tool_type", title=_("Tool Types"), removal_version="3.5.0", notice_url=_UPGRADING_3_2), +) +register_deprecation( + Deprecation( + key="tool_configuration", + title=_("Tool Configurations"), + removal_version="3.5.0", + notice_url=_UPGRADING_3_2, + ), +) +register_deprecation( + Deprecation( + key="api_scan_configuration", + title=_("API Scan Configurations"), + removal_version="3.5.0", + notice_url=_UPGRADING_3_2, + ), +) diff --git a/dojo/product/api/views.py b/dojo/product/api/views.py index e4a4900ef63..8d52dc59bd1 100644 --- a/dojo/product/api/views.py +++ b/dojo/product/api/views.py @@ -1,4 +1,3 @@ -from datetime import datetime from functools import partial from django.db.models import OuterRef, Value @@ -35,14 +34,12 @@ # Authorization: object-based -# Deprecated in 3.2.0, removal planned for 3.5.0 (serves the API-based pull parsers). @extend_schema_view(**schema_with_prefetch()) class ProductAPIScanConfigurationViewSet( DeprecationNoticeMixin, PrefetchDojoModelViewSet, ): - deprecated = True - end_of_life_date = datetime(2026, 11, 1) + deprecation = "api_scan_configuration" serializer_class = ProductAPIScanConfigurationSerializer queryset = Product_API_Scan_Configuration.objects.none() filter_backends = (DjangoFilterBackend,) diff --git a/dojo/product/ui/views.py b/dojo/product/ui/views.py index a3ed2285b78..0db365358c3 100644 --- a/dojo/product/ui/views.py +++ b/dojo/product/ui/views.py @@ -1727,7 +1727,7 @@ def delete_product_authorized_user(request, pid, user_id): return HttpResponseRedirect(reverse("view_product", args=(pid,))) -@deprecated_view("API Scan Configuration", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("api_scan_configuration") def add_api_scan_configuration(request, pid): product = get_object_or_404(Product, id=pid) if request.method == "POST": @@ -1771,7 +1771,7 @@ def add_api_scan_configuration(request, pid): }) -@deprecated_view("API Scan Configuration", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("api_scan_configuration") def view_api_scan_configurations(request, pid): product_api_scan_configurations = Product_API_Scan_Configuration.objects.filter(product=pid) @@ -1785,7 +1785,7 @@ def view_api_scan_configurations(request, pid): }) -@deprecated_view("API Scan Configuration", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("api_scan_configuration") def edit_api_scan_configuration(request, pid, pascid): product_api_scan_configuration = get_object_or_404(Product_API_Scan_Configuration, id=pascid) @@ -1831,7 +1831,7 @@ def edit_api_scan_configuration(request, pid, pascid): }) -@deprecated_view("API Scan Configuration", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("api_scan_configuration") def delete_api_scan_configuration(request, pid, pascid): product_api_scan_configuration = get_object_or_404(Product_API_Scan_Configuration, id=pascid) diff --git a/dojo/tool_config/api/views.py b/dojo/tool_config/api/views.py index eb4985b0ad8..959a971d8bf 100644 --- a/dojo/tool_config/api/views.py +++ b/dojo/tool_config/api/views.py @@ -1,5 +1,4 @@ import logging -from datetime import datetime from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema_view @@ -13,14 +12,12 @@ # Authorization: configurations -# Deprecated in 3.2.0, removal planned for 3.5.0 (serves the API-based pull parsers). @extend_schema_view(**schema_with_prefetch()) class ToolConfigurationsViewSet( DeprecationNoticeMixin, PrefetchDojoModelViewSet, ): - deprecated = True - end_of_life_date = datetime(2026, 11, 1) + deprecation = "tool_configuration" serializer_class = ToolConfigurationSerializer queryset = Tool_Configuration.objects.none() filter_backends = (DjangoFilterBackend,) diff --git a/dojo/tool_config/ui/views.py b/dojo/tool_config/ui/views.py index d960bf1c607..d3c10985178 100644 --- a/dojo/tool_config/ui/views.py +++ b/dojo/tool_config/ui/views.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) -@deprecated_view("Tool Configuration", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("tool_configuration") def new_tool_config(request): if request.method == "POST": tform = ToolConfigForm(request.POST) @@ -51,7 +51,7 @@ def new_tool_config(request): {"tform": tform}) -@deprecated_view("Tool Configuration", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("tool_configuration") def edit_tool_config(request, ttid): tool_config = Tool_Configuration.objects.get(pk=ttid) # Read before the form binds, which overwrites the instance in place. @@ -100,7 +100,7 @@ def edit_tool_config(request, ttid): }) -@deprecated_view("Tool Configuration", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("tool_configuration") def tool_config(request): confs = Tool_Configuration.objects.all().order_by("name") add_breadcrumb(title="Tool Configuration List", top_level=not len(request.GET), request=request) diff --git a/dojo/tool_type/api/views.py b/dojo/tool_type/api/views.py index fba2bbe887e..1d2615e046f 100644 --- a/dojo/tool_type/api/views.py +++ b/dojo/tool_type/api/views.py @@ -1,5 +1,4 @@ import logging -from datetime import datetime from django_filters.rest_framework import DjangoFilterBackend @@ -12,13 +11,11 @@ # Authorization: configuration -# Deprecated in 3.2.0, removal planned for 3.5.0 (serves the API-based pull parsers). class ToolTypesViewSet( DeprecationNoticeMixin, DojoModelViewSet, ): - deprecated = True - end_of_life_date = datetime(2026, 11, 1) + deprecation = "tool_type" serializer_class = ToolTypeSerializer queryset = Tool_Type.objects.none() filter_backends = (DjangoFilterBackend,) diff --git a/dojo/tool_type/ui/views.py b/dojo/tool_type/ui/views.py index d8a6acfaa2b..59fed703c61 100644 --- a/dojo/tool_type/ui/views.py +++ b/dojo/tool_type/ui/views.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) -@deprecated_view("Tool Type", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("tool_type") def new_tool_type(request): if request.method == "POST": tform = ToolTypeForm(request.POST, instance=Tool_Type()) @@ -35,7 +35,7 @@ def new_tool_type(request): return render(request, "dojo/new_tool_type.html", {"tform": tform}) -@deprecated_view("Tool Type", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("tool_type") def edit_tool_type(request, ttid): tool_type = Tool_Type.objects.get(pk=ttid) if request.method == "POST": @@ -55,7 +55,7 @@ def edit_tool_type(request, ttid): return render(request, "dojo/edit_tool_type.html", {"tform": tform}) -@deprecated_view("Tool Type", removal_version="3.5.0", removal_date="November 2026") +@deprecated_view("tool_type") def tool_type(request): confs = Tool_Type.objects.all().order_by("name") add_breadcrumb(title=_("Tool Type List"), top_level=not len(request.GET), request=request) diff --git a/unittests/test_deprecations.py b/unittests/test_deprecations.py new file mode 100644 index 00000000000..d58297beae2 --- /dev/null +++ b/unittests/test_deprecations.py @@ -0,0 +1,245 @@ +import re +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from django.contrib import messages +from django.contrib.messages import get_messages +from django.contrib.messages.storage.fallback import FallbackStorage +from django.core.exceptions import ImproperlyConfigured +from django.http import HttpResponse +from django.test import RequestFactory, SimpleTestCase +from django.utils import translation +from rest_framework.response import Response +from rest_framework.test import APIRequestFactory +from rest_framework.views import APIView + +import dojo +import dojo.urls # noqa: F401 -- loads every viewset module, so every DeprecationNoticeMixin subclass exists +from dojo.api_v2.views import DeprecationNoticeMixin +from dojo.decorators import deprecated_view +from dojo.deprecations import ( + Deprecation, + active_deprecations, + get_deprecation, + overdue_deprecations, + register_deprecation, +) +from dojo.product.ui import views as product_views +from dojo.tool_config.ui import views as tool_config_views +from dojo.tool_type.ui import views as tool_type_views + +UPGRADING_3_2 = "https://docs.defectdojo.com/releases/os_upgrading/3.2/" + + +def widgets(**overrides): + fields = {"key": "widgets", "title": "Widgets", "removal_version": "3.5.0", "notice_url": "https://example.test"} + fields.update(overrides) + return Deprecation(**fields) + + +class TestDeprecationSchedule(SimpleTestCase): + def test_the_date_comes_from_the_release_calendar(self): + self.assertEqual(date(2026, 11, 2), widgets().removal_date) + + def test_people_read_the_version_and_the_month(self): + self.assertEqual("3.5.0 (November 2026)", widgets().removal_label) + + def test_a_patch_release_is_refused(self): + with self.assertRaisesRegex(ValueError, "minor release"): + register_deprecation(widgets(key="widgets_patch", removal_version="3.5.100")) + + def test_a_release_missing_from_the_calendar_is_refused(self): + with self.assertRaisesRegex(ValueError, "RELEASE_DATES"): + register_deprecation(widgets(key="widgets_far", removal_version="9.9.0")) + + def test_a_second_registration_does_not_replace_the_first_unless_asked(self): + first, second = widgets(), widgets(title="Gadgets") + with patch.dict("dojo.deprecations._DEPRECATIONS", {}, clear=True): + register_deprecation(first) + register_deprecation(second) + self.assertIs(first, get_deprecation("widgets")) + register_deprecation(second, override=True) + self.assertIs(second, get_deprecation("widgets")) + + def test_a_removed_feature_is_not_active(self): + gone = widgets(key="gone", removal_version="3.3.0", removed=True) + with patch.dict("dojo.deprecations._DEPRECATIONS", {"gone": gone}, clear=True): + self.assertEqual([], active_deprecations()) + + +class TestOverdue(SimpleTestCase): + def test_a_declaration_past_its_release_is_overdue(self): + old = widgets(removal_version="3.4.0") + with patch.dict("dojo.deprecations._DEPRECATIONS", {"widgets": old}, clear=True): + self.assertEqual([old], overdue_deprecations("3.5.0-dev")) + self.assertEqual([], overdue_deprecations("3.4.0-dev")) + self.assertEqual([], overdue_deprecations("3.4.0")) + + def test_a_removed_declaration_is_never_overdue(self): + gone = widgets(removal_version="3.3.0", removed=True) + with patch.dict("dojo.deprecations._DEPRECATIONS", {"gone": gone}, clear=True): + self.assertEqual([], overdue_deprecations("3.6.0-dev")) + + def test_a_release_candidate_or_a_v_prefix_still_parses(self): + old = widgets(removal_version="3.4.0") + with patch.dict("dojo.deprecations._DEPRECATIONS", {"widgets": old}, clear=True): + self.assertEqual([old], overdue_deprecations("3.5.0rc1")) + self.assertEqual([old], overdue_deprecations("v3.5.0")) + with self.assertRaisesRegex(ValueError, "X.Y.Z"): + overdue_deprecations("latest") + + def test_nothing_declared_is_overdue_on_this_release_line(self): + overdue = [entry.key for entry in overdue_deprecations(dojo.__version__)] + self.assertEqual( + [], + overdue, + f"These removals are past their release on {dojo.__version__}. For each key, remove the feature and " + "delete its declaration, set removed=True while a migration path remains, or move removal_version to " + "a later release in dojo/deprecations.py.", + ) + + +class TestOpenSourceDeclarations(SimpleTestCase): + def test_the_pull_parser_features_go_in_3_5_0(self): + for key in ("tool_type", "tool_configuration", "api_scan_configuration"): + with self.subTest(key=key): + entry = get_deprecation(key) + self.assertEqual("3.5.0", entry.removal_version) + self.assertEqual(UPGRADING_3_2, entry.notice_url) + + def test_the_classic_message_uses_one_sentence_and_one_date_form(self): + self.assertEqual( + "Tool Types are deprecated and will be removed in DefectDojo 3.5.0 (November 2026). " + "Please plan to migrate away from this feature.", + get_deprecation("tool_type").message(), + ) + + def test_the_classic_message_never_mixes_two_languages(self): + with translation.override("de"): + message = get_deprecation("tool_type").message() + self.assertTrue(message.startswith("Tool Types are deprecated"), message) + + +def ok_view(request): + return HttpResponse("ok") + + +class TestDeprecatedView(SimpleTestCase): + def shown(self, method): + request = getattr(RequestFactory(), method)("/tool_type") + request.session = {} + request._messages = FallbackStorage(request) + deprecated_view("tool_type")(ok_view)(request) + return [(message.level, message.message, message.extra_tags) for message in get_messages(request)] + + def test_a_get_shows_the_declared_warning(self): + self.assertEqual( + [(messages.WARNING, get_deprecation("tool_type").message(), "alert-warning")], + self.shown("get"), + ) + + def test_a_post_shows_nothing_so_the_redirect_does_not_repeat_it(self): + self.assertEqual([], self.shown("post")) + + def test_a_removed_declaration_shows_nothing(self): + gone = widgets(key="gone", removal_version="3.3.0", removed=True) + with patch.dict("dojo.deprecations._DEPRECATIONS", {"gone": gone}, clear=True): + request = RequestFactory().get("/gone") + request.session = {} + request._messages = FallbackStorage(request) + deprecated_view("gone")(ok_view)(request) + self.assertEqual([], list(get_messages(request))) + + def test_an_undeclared_key_fails_at_import(self): + with self.assertRaises(ImproperlyConfigured): + deprecated_view("no_such_feature") + + def test_every_deprecated_classic_view_names_its_declaration(self): + expected = { + tool_type_views.new_tool_type: "tool_type", + tool_type_views.edit_tool_type: "tool_type", + tool_type_views.tool_type: "tool_type", + tool_config_views.new_tool_config: "tool_configuration", + tool_config_views.edit_tool_config: "tool_configuration", + tool_config_views.tool_config: "tool_configuration", + product_views.add_api_scan_configuration: "api_scan_configuration", + product_views.view_api_scan_configurations: "api_scan_configuration", + product_views.edit_api_scan_configuration: "api_scan_configuration", + product_views.delete_api_scan_configuration: "api_scan_configuration", + } + for view, key in expected.items(): + with self.subTest(view=view.__name__): + self.assertEqual(key, getattr(view, "deprecation", None)) + + +class ToolTypeProbe(DeprecationNoticeMixin, APIView): + authentication_classes = () + permission_classes = () + deprecation = "tool_type" + + def get(self, request): + return Response({}) + + +class UndeclaredProbe(ToolTypeProbe): + deprecation = "no_such_feature" + + +class RemovedProbe(ToolTypeProbe): + deprecation = "gone" + + +class TestDeprecationHeaders(SimpleTestCase): + def test_a_declared_feature_sends_both_headers_from_the_calendar(self): + response = ToolTypeProbe.as_view()(APIRequestFactory().get("/")) + self.assertEqual("True", response["X-Deprecated"]) + self.assertEqual("2026-11-02T00:00:00", response["X-End-Of-Life-Date"]) + + def test_an_undeclared_key_sends_no_header(self): + response = UndeclaredProbe.as_view()(APIRequestFactory().get("/")) + self.assertFalse(response.has_header("X-Deprecated")) + self.assertFalse(response.has_header("X-End-Of-Life-Date")) + + def test_a_removed_declaration_sends_no_header(self): + gone = widgets(key="gone", removal_version="3.3.0", removed=True) + with patch.dict("dojo.deprecations._DEPRECATIONS", {"gone": gone}, clear=True): + response = RemovedProbe.as_view()(APIRequestFactory().get("/")) + self.assertFalse(response.has_header("X-Deprecated")) + self.assertFalse(response.has_header("X-End-Of-Life-Date")) + + +HAND_TYPED_SCHEDULE = re.compile( + r"removal_version=|removal_date=|end_of_life_date\s*=|will be removed (in|by) (DefectDojo )?v?\d|removal planned for \d", +) + + +def mixin_subclasses(cls): + for sub in cls.__subclasses__(): + yield sub + yield from mixin_subclasses(sub) + + +class TestEveryDeprecationKeyIsDeclared(SimpleTestCase): + def test_a_mixin_subclass_never_names_an_undeclared_key(self): + offenders = [] + for cls in mixin_subclasses(DeprecationNoticeMixin): + if cls.__module__.startswith("unittests."): + continue + key = getattr(cls, "deprecation", "") + if key and get_deprecation(key) is None: + offenders.append(f"{cls.__module__}.{cls.__qualname__}: deprecation={key!r}") + self.assertEqual([], offenders, "A typo here sends no deprecation header and fails silently.") + + +class TestNoHandTypedSchedule(SimpleTestCase): + def test_only_the_registry_names_a_removal_release(self): + root = Path(dojo.__file__).parent + offenders = [] + for path in sorted([*root.rglob("*.py"), *root.rglob("*.html")]): + if path.name == "deprecations.py" or "db_migrations" in path.parts: + continue + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if HAND_TYPED_SCHEDULE.search(line): + offenders.append(f"{path.relative_to(root.parent)}:{number}") + self.assertEqual([], offenders, "Declare the deprecation in dojo/deprecations.py and read it by key.") diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 5573745a951..78dc8028d65 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -2750,13 +2750,12 @@ def __init__(self, *args, **kwargs): BaseClass.RESTEndpointTest.__init__(self, *args, **kwargs) def test_deprecation_notice_header(self): - # Deprecated in 3.2.0, removal planned for 3.5.0 (serves the API-based pull parsers). response = self.client.get(self.url, format="json") self.assertEqual(200, response.status_code, response.content[:1000]) self.assertTrue(response.has_header("X-Deprecated")) self.assertEqual("True", str(response["X-Deprecated"])) self.assertTrue(response.has_header("X-End-Of-Life-Date")) - self.assertTrue(str(response["X-End-Of-Life-Date"]).startswith("2026-11-01")) + self.assertTrue(str(response["X-End-Of-Life-Date"]).startswith("2026-11-02")) @versioned_fixtures @@ -2783,13 +2782,12 @@ def __init__(self, *args, **kwargs): BaseClass.RESTEndpointTest.__init__(self, *args, **kwargs) def test_deprecation_notice_header(self): - # Deprecated in 3.2.0, removal planned for 3.5.0 (serves the API-based pull parsers). response = self.client.get(self.url, format="json") self.assertEqual(200, response.status_code, response.content[:1000]) self.assertTrue(response.has_header("X-Deprecated")) self.assertEqual("True", str(response["X-Deprecated"])) self.assertTrue(response.has_header("X-End-Of-Life-Date")) - self.assertTrue(str(response["X-End-Of-Life-Date"]).startswith("2026-11-01")) + self.assertTrue(str(response["X-End-Of-Life-Date"]).startswith("2026-11-02")) @versioned_fixtures @@ -2909,15 +2907,15 @@ def __init__(self, *args, **kwargs): BaseClass.RESTEndpointTest.__init__(self, *args, **kwargs) def test_deprecation_notice_header(self): - # Deprecated in 3.2.0, removal planned for 3.5.0. The DeprecationNoticeMixin - # must run in finalize_response, which only happens if it precedes the base - # viewset in the MRO (see dojo/api_v2/views.py:DeprecationNoticeMixin). + # The DeprecationNoticeMixin must run in finalize_response, which only + # happens if it precedes the base viewset in the MRO (see + # dojo/api_v2/views.py:DeprecationNoticeMixin). response = self.client.get(self.url, format="json") self.assertEqual(200, response.status_code, response.content[:1000]) self.assertTrue(response.has_header("X-Deprecated")) self.assertEqual("True", str(response["X-Deprecated"])) self.assertTrue(response.has_header("X-End-Of-Life-Date")) - self.assertTrue(str(response["X-End-Of-Life-Date"]).startswith("2026-11-01")) + self.assertTrue(str(response["X-End-Of-Life-Date"]).startswith("2026-11-02")) @versioned_fixtures @@ -2966,13 +2964,12 @@ def __init__(self, *args, **kwargs): BaseClass.RESTEndpointTest.__init__(self, *args, **kwargs) def test_deprecation_notice_header(self): - # Deprecated in 3.2.0, removal planned for 3.5.0 (serves the API-based pull parsers). response = self.client.get(self.url, format="json") self.assertEqual(200, response.status_code, response.content[:1000]) self.assertTrue(response.has_header("X-Deprecated")) self.assertEqual("True", str(response["X-Deprecated"])) self.assertTrue(response.has_header("X-End-Of-Life-Date")) - self.assertTrue(str(response["X-End-Of-Life-Date"]).startswith("2026-11-01")) + self.assertTrue(str(response["X-End-Of-Life-Date"]).startswith("2026-11-02")) @versioned_fixtures