From 603475703732cc702ed07f92136fe31eaf17cf9f Mon Sep 17 00:00:00 2001 From: YBG Ben Date: Fri, 24 Apr 2026 14:54:03 -0400 Subject: [PATCH 1/5] Add 'Mitigation Available' filter to ApiFindingFilter and ReportFindingFilterHelper --- dojo/filters.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/dojo/filters.py b/dojo/filters.py index 8e1fdf7868f..dcb017ab4a2 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -1625,6 +1625,7 @@ class ApiFindingFilter(DojoFilter): verified = BooleanFilter(field_name="verified") has_jira = BooleanFilter(field_name="jira_issue", lookup_expr="isnull", exclude=True) fix_available = BooleanFilter(field_name="fix_available") + mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") # CharFilter component_version = CharFilter(lookup_expr="icontains") component_name = CharFilter(lookup_expr="icontains") @@ -1797,6 +1798,11 @@ def filter_mitigated_on(self, queryset, name, value): return queryset.filter(mitigated=value) + def filter_mitigation_available(self, queryset, name, value): + if value: + return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") + return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) + class PercentageFilter(NumberFilter): def __init__(self, *args, **kwargs): @@ -1831,6 +1837,8 @@ class FindingFilterHelper(FilterSet): duplicate = ReportBooleanFilter() is_mitigated = ReportBooleanFilter() fix_available = ReportBooleanFilter() + mitigation = CharFilter(lookup_expr="icontains") + mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") mitigated = DateRangeFilter(field_name="mitigated", label="Mitigated Date") mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", label="Mitigated On", method="filter_mitigated_on") mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt", label="Mitigated Before") @@ -2020,6 +2028,11 @@ def filter_mitigated_on(self, queryset, name, value): return queryset.filter(mitigated=value) + def filter_mitigation_available(self, queryset, name, value): + if value: + return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") + return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) + def get_finding_group_queryset_for_context(pid=None, eid=None, tid=None): """ @@ -3399,6 +3412,7 @@ class ReportFindingFilterHelper(FilterSet): out_of_scope = ReportBooleanFilter() outside_of_sla = FindingSLAFilter(label="Outside of SLA") file_path = CharFilter(lookup_expr="icontains") + mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") o = OrderingFilter( fields=( @@ -3421,6 +3435,11 @@ class Meta: "numerical_severity", "reporter", "last_reviewed", "jira_creation", "jira_change", "files"] + def filter_mitigation_available(self, queryset, name, value): + if value: + return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") + return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) + def manage_kwargs(self, kwargs): self.prod_type = None self.product = None From c6279f436bd6e3fe99c0e020a7eb2542a342eee1 Mon Sep 17 00:00:00 2001 From: YBG Ben Date: Fri, 24 Apr 2026 14:54:10 -0400 Subject: [PATCH 2/5] Add unit tests for mitigation filters in Finding model --- unittests/test_filter_finding_mitigation.py | 102 ++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 unittests/test_filter_finding_mitigation.py diff --git a/unittests/test_filter_finding_mitigation.py b/unittests/test_filter_finding_mitigation.py new file mode 100644 index 00000000000..3e5a3ff60bd --- /dev/null +++ b/unittests/test_filter_finding_mitigation.py @@ -0,0 +1,102 @@ +import datetime + +from django.test import TestCase +from django.utils import timezone + +from dojo.filters import ApiFindingFilter, FindingFilterHelper +from dojo.models import ( + Engagement, + Finding, + Product, + Product_Type, + Test, + Test_Type, +) + + +def _make_finding(title, mitigation, product): + test_type, _ = Test_Type.objects.get_or_create(name="Unit Test") + engagement = Engagement.objects.create( + name="Test Engagement", + product=product, + target_start=timezone.now().date(), + target_end=(timezone.now() + datetime.timedelta(days=1)).date(), + ) + test = Test.objects.create( + engagement=engagement, + test_type=test_type, + target_start=timezone.now(), + target_end=timezone.now() + datetime.timedelta(hours=1), + ) + return Finding.objects.create( + title=title, + test=test, + severity="Medium", + mitigation=mitigation, + verified=True, + active=True, + ) + + +class MitigationFilterTestCase(TestCase): + @classmethod + def setUpTestData(cls): + prod_type = Product_Type.objects.create(name="Test Type") + product = Product.objects.create( + name="Test Product", + prod_type=prod_type, + ) + cls.finding_with_mitigation = _make_finding("Finding A", "apply patch", product) + cls.finding_null_mitigation = _make_finding("Finding B", None, product) + cls.finding_empty_mitigation = _make_finding("Finding C", "", product) + + def _api_filter(self, params): + qs = Finding.objects.all() + f = ApiFindingFilter(params, queryset=qs) + return set(f.qs.values_list("id", flat=True)) + + def test_mitigation_icontains(self): + # Filtering by mitigation text returns only findings whose mitigation contains that substring + pass + + def test_mitigation_available_true(self): + # mitigation_available=true returns only findings with a non-null, non-empty mitigation + pass + + def test_mitigation_available_false(self): + # mitigation_available=false returns only findings with a null or empty mitigation + pass + + def test_mitigation_available_false_handles_null(self): + # mitigation_available=false includes findings where mitigation is NULL + pass + + def test_mitigation_available_false_handles_empty_string(self): + # mitigation_available=false includes findings where mitigation is an empty string + pass + + +class MitigationUIFilterTestCase(TestCase): + @classmethod + def setUpTestData(cls): + prod_type = Product_Type.objects.create(name="UI Test Type") + product = Product.objects.create( + name="UI Test Product", + prod_type=prod_type, + ) + cls.finding_with_mitigation = _make_finding("UI Finding A", "upgrade to v2", product) + cls.finding_null_mitigation = _make_finding("UI Finding B", None, product) + cls.finding_empty_mitigation = _make_finding("UI Finding C", "", product) + + def _ui_filter(self, params): + qs = Finding.objects.all() + f = FindingFilterHelper(params, queryset=qs) + return set(f.qs.values_list("id", flat=True)) + + def test_mitigation_available_true(self): + # mitigation_available=true returns only findings with a non-null, non-empty mitigation + pass + + def test_mitigation_available_false(self): + # mitigation_available=false returns only findings with a null or empty mitigation + pass From 8484600adfe9459b66c8e8f94ced2efcfe2f9b16 Mon Sep 17 00:00:00 2001 From: Phasakorn Date: Sun, 26 Apr 2026 22:19:07 -0400 Subject: [PATCH 3/5] test: added mitigation filter unit tests & fix finding reporter setup : --- unittests/test_filter_finding_mitigation.py | 162 +++++++++++++++++--- 1 file changed, 138 insertions(+), 24 deletions(-) diff --git a/unittests/test_filter_finding_mitigation.py b/unittests/test_filter_finding_mitigation.py index 3e5a3ff60bd..cd8e698c9e9 100644 --- a/unittests/test_filter_finding_mitigation.py +++ b/unittests/test_filter_finding_mitigation.py @@ -5,6 +5,7 @@ from dojo.filters import ApiFindingFilter, FindingFilterHelper from dojo.models import ( + Dojo_User, Engagement, Finding, Product, @@ -14,7 +15,7 @@ ) -def _make_finding(title, mitigation, product): +def _make_finding(title, mitigation, product, reporter): test_type, _ = Test_Type.objects.get_or_create(name="Unit Test") engagement = Engagement.objects.create( name="Test Engagement", @@ -33,6 +34,7 @@ def _make_finding(title, mitigation, product): test=test, severity="Medium", mitigation=mitigation, + reporter=reporter, verified=True, active=True, ) @@ -41,62 +43,174 @@ def _make_finding(title, mitigation, product): class MitigationFilterTestCase(TestCase): @classmethod def setUpTestData(cls): + cls.reporter = Dojo_User.objects.create_user( + username="mitigation-filter-api", + email="mitigation-filter-api@example.com", + password="password123", # noqa: S106 + ) prod_type = Product_Type.objects.create(name="Test Type") product = Product.objects.create( name="Test Product", prod_type=prod_type, ) - cls.finding_with_mitigation = _make_finding("Finding A", "apply patch", product) - cls.finding_null_mitigation = _make_finding("Finding B", None, product) - cls.finding_empty_mitigation = _make_finding("Finding C", "", product) + cls.finding_with_mitigation = _make_finding("Finding A", "apply patch", product, cls.reporter) + cls.finding_upper_mitigation = _make_finding("Finding D", "APPLY PATCH", product, cls.reporter) + cls.finding_whitespace_mitigation = _make_finding("Finding E", " ", product, cls.reporter) + cls.finding_null_mitigation = _make_finding("Finding B", None, product, cls.reporter) + cls.finding_empty_mitigation = _make_finding("Finding C", "", product, cls.reporter) def _api_filter(self, params): - qs = Finding.objects.all() + qs = Finding.objects.filter( + title__in=["Finding A", "Finding B", "Finding C", "Finding D", "Finding E"] + ) f = ApiFindingFilter(params, queryset=qs) return set(f.qs.values_list("id", flat=True)) - def test_mitigation_icontains(self): - # Filtering by mitigation text returns only findings whose mitigation contains that substring - pass + # --- mitigation icontains --- + + def test_mitigation_icontains_lowercase(self): + # Substring match: "patch" should hit "apply patch" and "APPLY PATCH" + result = self._api_filter({"mitigation": "patch"}) + self.assertIn(self.finding_with_mitigation.id, result) + self.assertIn(self.finding_upper_mitigation.id, result) + self.assertNotIn(self.finding_null_mitigation.id, result) + self.assertNotIn(self.finding_empty_mitigation.id, result) + + def test_mitigation_icontains_uppercase(self): + # Case-insensitive: uppercase query also matches lowercase stored value + result = self._api_filter({"mitigation": "PATCH"}) + self.assertIn(self.finding_with_mitigation.id, result) + self.assertIn(self.finding_upper_mitigation.id, result) + + def test_mitigation_icontains_no_match(self): + result = self._api_filter({"mitigation": "ZZZNOMATCH"}) + self.assertEqual(result, set()) + + def test_mitigation_icontains_partial(self): + # Partial substring match + result = self._api_filter({"mitigation": "apply"}) + self.assertIn(self.finding_with_mitigation.id, result) + self.assertIn(self.finding_upper_mitigation.id, result) + self.assertNotIn(self.finding_null_mitigation.id, result) + self.assertNotIn(self.finding_empty_mitigation.id, result) + + # --- mitigation_available=true --- def test_mitigation_available_true(self): - # mitigation_available=true returns only findings with a non-null, non-empty mitigation - pass + # Returns only findings with non-null, non-empty mitigation + result = self._api_filter({"mitigation_available": "true"}) + self.assertIn(self.finding_with_mitigation.id, result) + self.assertIn(self.finding_upper_mitigation.id, result) + # Whitespace-only is NOT null and NOT empty string — current impl includes it + self.assertIn(self.finding_whitespace_mitigation.id, result) + self.assertNotIn(self.finding_null_mitigation.id, result) + self.assertNotIn(self.finding_empty_mitigation.id, result) + + # --- mitigation_available=false --- def test_mitigation_available_false(self): - # mitigation_available=false returns only findings with a null or empty mitigation - pass + # Returns findings where mitigation is null OR empty string + result = self._api_filter({"mitigation_available": "false"}) + self.assertIn(self.finding_null_mitigation.id, result) + self.assertIn(self.finding_empty_mitigation.id, result) + self.assertNotIn(self.finding_with_mitigation.id, result) + self.assertNotIn(self.finding_upper_mitigation.id, result) def test_mitigation_available_false_handles_null(self): - # mitigation_available=false includes findings where mitigation is NULL - pass + # NULL mitigation is explicitly captured by the false branch + result = self._api_filter({"mitigation_available": "false"}) + self.assertIn(self.finding_null_mitigation.id, result) def test_mitigation_available_false_handles_empty_string(self): - # mitigation_available=false includes findings where mitigation is an empty string - pass + # Empty-string mitigation is explicitly captured by the false branch + result = self._api_filter({"mitigation_available": "false"}) + self.assertIn(self.finding_empty_mitigation.id, result) + + def test_mitigation_available_false_excludes_whitespace(self): + # Whitespace-only mitigation (" ") is NOT null and NOT empty-string, + # so the false branch does NOT include it — document current behavior. + result = self._api_filter({"mitigation_available": "false"}) + self.assertNotIn(self.finding_whitespace_mitigation.id, result) + + # --- no filter parameter --- + + def test_no_filter_returns_full_set(self): + # Baseline: no params → all five findings returned + result = self._api_filter({}) + expected = { + self.finding_with_mitigation.id, + self.finding_upper_mitigation.id, + self.finding_whitespace_mitigation.id, + self.finding_null_mitigation.id, + self.finding_empty_mitigation.id, + } + self.assertEqual(result, expected) + + # --- combined filters (intersection) --- + + def test_combined_mitigation_text_and_available_true(self): + # "patch" icontains AND mitigation_available=true → only the two "patch" findings + result = self._api_filter({"mitigation": "patch", "mitigation_available": "true"}) + self.assertEqual( + result, + {self.finding_with_mitigation.id, self.finding_upper_mitigation.id}, + ) + + def test_combined_mitigation_text_and_available_false(self): + # text filter AND mitigation_available=false → empty: false branch returns null/empty, + # icontains on null/empty returns nothing matching "patch" + result = self._api_filter({"mitigation": "patch", "mitigation_available": "false"}) + self.assertEqual(result, set()) class MitigationUIFilterTestCase(TestCase): @classmethod def setUpTestData(cls): + cls.reporter = Dojo_User.objects.create_user( + username="mitigation-filter-ui", + email="mitigation-filter-ui@example.com", + password="password123", # noqa: S106 + ) prod_type = Product_Type.objects.create(name="UI Test Type") product = Product.objects.create( name="UI Test Product", prod_type=prod_type, ) - cls.finding_with_mitigation = _make_finding("UI Finding A", "upgrade to v2", product) - cls.finding_null_mitigation = _make_finding("UI Finding B", None, product) - cls.finding_empty_mitigation = _make_finding("UI Finding C", "", product) + cls.finding_with_mitigation = _make_finding("UI Finding A", "upgrade to v2", product, cls.reporter) + cls.finding_whitespace_mitigation = _make_finding("UI Finding D", " ", product, cls.reporter) + cls.finding_null_mitigation = _make_finding("UI Finding B", None, product, cls.reporter) + cls.finding_empty_mitigation = _make_finding("UI Finding C", "", product, cls.reporter) def _ui_filter(self, params): - qs = Finding.objects.all() + qs = Finding.objects.filter( + title__in=["UI Finding A", "UI Finding B", "UI Finding C", "UI Finding D"] + ) f = FindingFilterHelper(params, queryset=qs) return set(f.qs.values_list("id", flat=True)) def test_mitigation_available_true(self): - # mitigation_available=true returns only findings with a non-null, non-empty mitigation - pass + # True branch: excludes null and empty string; whitespace-only is included + result = self._ui_filter({"mitigation_available": "true"}) + self.assertIn(self.finding_with_mitigation.id, result) + self.assertIn(self.finding_whitespace_mitigation.id, result) + self.assertNotIn(self.finding_null_mitigation.id, result) + self.assertNotIn(self.finding_empty_mitigation.id, result) def test_mitigation_available_false(self): - # mitigation_available=false returns only findings with a null or empty mitigation - pass + # False branch: returns null and empty string, excludes non-empty + result = self._ui_filter({"mitigation_available": "false"}) + self.assertIn(self.finding_null_mitigation.id, result) + self.assertIn(self.finding_empty_mitigation.id, result) + self.assertNotIn(self.finding_with_mitigation.id, result) + # Whitespace-only is not null/empty → not in false branch + self.assertNotIn(self.finding_whitespace_mitigation.id, result) + + def test_no_filter_returns_full_set(self): + result = self._ui_filter({}) + expected = { + self.finding_with_mitigation.id, + self.finding_whitespace_mitigation.id, + self.finding_null_mitigation.id, + self.finding_empty_mitigation.id, + } + self.assertEqual(result, expected) From c01f980d76d129b566148d0560f3bdbd78511e60 Mon Sep 17 00:00:00 2001 From: YBG Ben Date: Wed, 29 Apr 2026 18:40:00 -0400 Subject: [PATCH 4/5] Fix syntax by adding trailing commas in filter queries for consistency --- unittests/test_filter_finding_mitigation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unittests/test_filter_finding_mitigation.py b/unittests/test_filter_finding_mitigation.py index cd8e698c9e9..71eb55af179 100644 --- a/unittests/test_filter_finding_mitigation.py +++ b/unittests/test_filter_finding_mitigation.py @@ -61,7 +61,7 @@ def setUpTestData(cls): def _api_filter(self, params): qs = Finding.objects.filter( - title__in=["Finding A", "Finding B", "Finding C", "Finding D", "Finding E"] + title__in=["Finding A", "Finding B", "Finding C", "Finding D", "Finding E"], ) f = ApiFindingFilter(params, queryset=qs) return set(f.qs.values_list("id", flat=True)) @@ -183,7 +183,7 @@ def setUpTestData(cls): def _ui_filter(self, params): qs = Finding.objects.filter( - title__in=["UI Finding A", "UI Finding B", "UI Finding C", "UI Finding D"] + title__in=["UI Finding A", "UI Finding B", "UI Finding C", "UI Finding D"], ) f = FindingFilterHelper(params, queryset=qs) return set(f.qs.values_list("id", flat=True)) From 53fcf5eb2f9cde137cd6d7cd850365bf366d5bdc Mon Sep 17 00:00:00 2001 From: YBG Ben Date: Wed, 29 Apr 2026 23:37:21 -0400 Subject: [PATCH 5/5] fix: add descriptions to products in mitigation filter tests --- unittests/test_filter_finding_mitigation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/unittests/test_filter_finding_mitigation.py b/unittests/test_filter_finding_mitigation.py index 71eb55af179..31b3fd5ce95 100644 --- a/unittests/test_filter_finding_mitigation.py +++ b/unittests/test_filter_finding_mitigation.py @@ -51,6 +51,7 @@ def setUpTestData(cls): prod_type = Product_Type.objects.create(name="Test Type") product = Product.objects.create( name="Test Product", + description="Test Product", prod_type=prod_type, ) cls.finding_with_mitigation = _make_finding("Finding A", "apply patch", product, cls.reporter) @@ -174,6 +175,7 @@ def setUpTestData(cls): prod_type = Product_Type.objects.create(name="UI Test Type") product = Product.objects.create( name="UI Test Product", + description="UI Test Product", prod_type=prod_type, ) cls.finding_with_mitigation = _make_finding("UI Finding A", "upgrade to v2", product, cls.reporter)