Skip to content

Commit 79f58ec

Browse files
bendnemabennema1pskodr
authored
Add mitigation finding filters and complete mitigation filter tests (#14790)
* Add 'Mitigation Available' filter to ApiFindingFilter and ReportFindingFilterHelper * Add unit tests for mitigation filters in Finding model * test: added mitigation filter unit tests & fix finding reporter setup : * Fix syntax by adding trailing commas in filter queries for consistency * fix: add descriptions to products in mitigation filter tests --------- Co-authored-by: YBG Ben <benedictnema@gmail.com> Co-authored-by: Phasakorn <pchivatx@andrew.cmu.edu>
1 parent 450a683 commit 79f58ec

2 files changed

Lines changed: 237 additions & 0 deletions

File tree

dojo/filters.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,6 +1624,7 @@ class ApiFindingFilter(DojoFilter):
16241624
verified = BooleanFilter(field_name="verified")
16251625
has_jira = BooleanFilter(field_name="jira_issue", lookup_expr="isnull", exclude=True)
16261626
fix_available = BooleanFilter(field_name="fix_available")
1627+
mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available")
16271628
# CharFilter
16281629
component_version = CharFilter(lookup_expr="icontains")
16291630
component_name = CharFilter(lookup_expr="icontains")
@@ -1796,6 +1797,11 @@ def filter_mitigated_on(self, queryset, name, value):
17961797

17971798
return queryset.filter(mitigated=value)
17981799

1800+
def filter_mitigation_available(self, queryset, name, value):
1801+
if value:
1802+
return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="")
1803+
return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact=""))
1804+
17991805

18001806
class PercentageFilter(NumberFilter):
18011807
def __init__(self, *args, **kwargs):
@@ -1830,6 +1836,8 @@ class FindingFilterHelper(FilterSet):
18301836
duplicate = ReportBooleanFilter()
18311837
is_mitigated = ReportBooleanFilter()
18321838
fix_available = ReportBooleanFilter()
1839+
mitigation = CharFilter(lookup_expr="icontains")
1840+
mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available")
18331841
mitigated = DateRangeFilter(field_name="mitigated", label="Mitigated Date")
18341842
mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", label="Mitigated On", method="filter_mitigated_on")
18351843
mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt", label="Mitigated Before")
@@ -2021,6 +2029,11 @@ def filter_mitigated_on(self, queryset, name, value):
20212029

20222030
return queryset.filter(mitigated=value)
20232031

2032+
def filter_mitigation_available(self, queryset, name, value):
2033+
if value:
2034+
return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="")
2035+
return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact=""))
2036+
20242037

20252038
def get_finding_group_queryset_for_context(pid=None, eid=None, tid=None):
20262039
"""
@@ -3417,6 +3430,7 @@ class ReportFindingFilterHelper(FilterSet):
34173430
out_of_scope = ReportBooleanFilter()
34183431
outside_of_sla = FindingSLAFilter(label="Outside of SLA")
34193432
file_path = CharFilter(lookup_expr="icontains")
3433+
mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available")
34203434

34213435
o = OrderingFilter(
34223436
fields=(
@@ -3439,6 +3453,11 @@ class Meta:
34393453
"numerical_severity", "reporter", "last_reviewed",
34403454
"jira_creation", "jira_change", "files"]
34413455

3456+
def filter_mitigation_available(self, queryset, name, value):
3457+
if value:
3458+
return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="")
3459+
return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact=""))
3460+
34423461
def manage_kwargs(self, kwargs):
34433462
self.prod_type = None
34443463
self.product = None
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import datetime
2+
3+
from django.test import TestCase
4+
from django.utils import timezone
5+
6+
from dojo.filters import ApiFindingFilter, FindingFilterHelper
7+
from dojo.models import (
8+
Dojo_User,
9+
Engagement,
10+
Finding,
11+
Product,
12+
Product_Type,
13+
Test,
14+
Test_Type,
15+
)
16+
17+
18+
def _make_finding(title, mitigation, product, reporter):
19+
test_type, _ = Test_Type.objects.get_or_create(name="Unit Test")
20+
engagement = Engagement.objects.create(
21+
name="Test Engagement",
22+
product=product,
23+
target_start=timezone.now().date(),
24+
target_end=(timezone.now() + datetime.timedelta(days=1)).date(),
25+
)
26+
test = Test.objects.create(
27+
engagement=engagement,
28+
test_type=test_type,
29+
target_start=timezone.now(),
30+
target_end=timezone.now() + datetime.timedelta(hours=1),
31+
)
32+
return Finding.objects.create(
33+
title=title,
34+
test=test,
35+
severity="Medium",
36+
mitigation=mitigation,
37+
reporter=reporter,
38+
verified=True,
39+
active=True,
40+
)
41+
42+
43+
class MitigationFilterTestCase(TestCase):
44+
@classmethod
45+
def setUpTestData(cls):
46+
cls.reporter = Dojo_User.objects.create_user(
47+
username="mitigation-filter-api",
48+
email="mitigation-filter-api@example.com",
49+
password="password123", # noqa: S106
50+
)
51+
prod_type = Product_Type.objects.create(name="Test Type")
52+
product = Product.objects.create(
53+
name="Test Product",
54+
description="Test Product",
55+
prod_type=prod_type,
56+
)
57+
cls.finding_with_mitigation = _make_finding("Finding A", "apply patch", product, cls.reporter)
58+
cls.finding_upper_mitigation = _make_finding("Finding D", "APPLY PATCH", product, cls.reporter)
59+
cls.finding_whitespace_mitigation = _make_finding("Finding E", " ", product, cls.reporter)
60+
cls.finding_null_mitigation = _make_finding("Finding B", None, product, cls.reporter)
61+
cls.finding_empty_mitigation = _make_finding("Finding C", "", product, cls.reporter)
62+
63+
def _api_filter(self, params):
64+
qs = Finding.objects.filter(
65+
title__in=["Finding A", "Finding B", "Finding C", "Finding D", "Finding E"],
66+
)
67+
f = ApiFindingFilter(params, queryset=qs)
68+
return set(f.qs.values_list("id", flat=True))
69+
70+
# --- mitigation icontains ---
71+
72+
def test_mitigation_icontains_lowercase(self):
73+
# Substring match: "patch" should hit "apply patch" and "APPLY PATCH"
74+
result = self._api_filter({"mitigation": "patch"})
75+
self.assertIn(self.finding_with_mitigation.id, result)
76+
self.assertIn(self.finding_upper_mitigation.id, result)
77+
self.assertNotIn(self.finding_null_mitigation.id, result)
78+
self.assertNotIn(self.finding_empty_mitigation.id, result)
79+
80+
def test_mitigation_icontains_uppercase(self):
81+
# Case-insensitive: uppercase query also matches lowercase stored value
82+
result = self._api_filter({"mitigation": "PATCH"})
83+
self.assertIn(self.finding_with_mitigation.id, result)
84+
self.assertIn(self.finding_upper_mitigation.id, result)
85+
86+
def test_mitigation_icontains_no_match(self):
87+
result = self._api_filter({"mitigation": "ZZZNOMATCH"})
88+
self.assertEqual(result, set())
89+
90+
def test_mitigation_icontains_partial(self):
91+
# Partial substring match
92+
result = self._api_filter({"mitigation": "apply"})
93+
self.assertIn(self.finding_with_mitigation.id, result)
94+
self.assertIn(self.finding_upper_mitigation.id, result)
95+
self.assertNotIn(self.finding_null_mitigation.id, result)
96+
self.assertNotIn(self.finding_empty_mitigation.id, result)
97+
98+
# --- mitigation_available=true ---
99+
100+
def test_mitigation_available_true(self):
101+
# Returns only findings with non-null, non-empty mitigation
102+
result = self._api_filter({"mitigation_available": "true"})
103+
self.assertIn(self.finding_with_mitigation.id, result)
104+
self.assertIn(self.finding_upper_mitigation.id, result)
105+
# Whitespace-only is NOT null and NOT empty string — current impl includes it
106+
self.assertIn(self.finding_whitespace_mitigation.id, result)
107+
self.assertNotIn(self.finding_null_mitigation.id, result)
108+
self.assertNotIn(self.finding_empty_mitigation.id, result)
109+
110+
# --- mitigation_available=false ---
111+
112+
def test_mitigation_available_false(self):
113+
# Returns findings where mitigation is null OR empty string
114+
result = self._api_filter({"mitigation_available": "false"})
115+
self.assertIn(self.finding_null_mitigation.id, result)
116+
self.assertIn(self.finding_empty_mitigation.id, result)
117+
self.assertNotIn(self.finding_with_mitigation.id, result)
118+
self.assertNotIn(self.finding_upper_mitigation.id, result)
119+
120+
def test_mitigation_available_false_handles_null(self):
121+
# NULL mitigation is explicitly captured by the false branch
122+
result = self._api_filter({"mitigation_available": "false"})
123+
self.assertIn(self.finding_null_mitigation.id, result)
124+
125+
def test_mitigation_available_false_handles_empty_string(self):
126+
# Empty-string mitigation is explicitly captured by the false branch
127+
result = self._api_filter({"mitigation_available": "false"})
128+
self.assertIn(self.finding_empty_mitigation.id, result)
129+
130+
def test_mitigation_available_false_excludes_whitespace(self):
131+
# Whitespace-only mitigation (" ") is NOT null and NOT empty-string,
132+
# so the false branch does NOT include it — document current behavior.
133+
result = self._api_filter({"mitigation_available": "false"})
134+
self.assertNotIn(self.finding_whitespace_mitigation.id, result)
135+
136+
# --- no filter parameter ---
137+
138+
def test_no_filter_returns_full_set(self):
139+
# Baseline: no params → all five findings returned
140+
result = self._api_filter({})
141+
expected = {
142+
self.finding_with_mitigation.id,
143+
self.finding_upper_mitigation.id,
144+
self.finding_whitespace_mitigation.id,
145+
self.finding_null_mitigation.id,
146+
self.finding_empty_mitigation.id,
147+
}
148+
self.assertEqual(result, expected)
149+
150+
# --- combined filters (intersection) ---
151+
152+
def test_combined_mitigation_text_and_available_true(self):
153+
# "patch" icontains AND mitigation_available=true → only the two "patch" findings
154+
result = self._api_filter({"mitigation": "patch", "mitigation_available": "true"})
155+
self.assertEqual(
156+
result,
157+
{self.finding_with_mitigation.id, self.finding_upper_mitigation.id},
158+
)
159+
160+
def test_combined_mitigation_text_and_available_false(self):
161+
# text filter AND mitigation_available=false → empty: false branch returns null/empty,
162+
# icontains on null/empty returns nothing matching "patch"
163+
result = self._api_filter({"mitigation": "patch", "mitigation_available": "false"})
164+
self.assertEqual(result, set())
165+
166+
167+
class MitigationUIFilterTestCase(TestCase):
168+
@classmethod
169+
def setUpTestData(cls):
170+
cls.reporter = Dojo_User.objects.create_user(
171+
username="mitigation-filter-ui",
172+
email="mitigation-filter-ui@example.com",
173+
password="password123", # noqa: S106
174+
)
175+
prod_type = Product_Type.objects.create(name="UI Test Type")
176+
product = Product.objects.create(
177+
name="UI Test Product",
178+
description="UI Test Product",
179+
prod_type=prod_type,
180+
)
181+
cls.finding_with_mitigation = _make_finding("UI Finding A", "upgrade to v2", product, cls.reporter)
182+
cls.finding_whitespace_mitigation = _make_finding("UI Finding D", " ", product, cls.reporter)
183+
cls.finding_null_mitigation = _make_finding("UI Finding B", None, product, cls.reporter)
184+
cls.finding_empty_mitigation = _make_finding("UI Finding C", "", product, cls.reporter)
185+
186+
def _ui_filter(self, params):
187+
qs = Finding.objects.filter(
188+
title__in=["UI Finding A", "UI Finding B", "UI Finding C", "UI Finding D"],
189+
)
190+
f = FindingFilterHelper(params, queryset=qs)
191+
return set(f.qs.values_list("id", flat=True))
192+
193+
def test_mitigation_available_true(self):
194+
# True branch: excludes null and empty string; whitespace-only is included
195+
result = self._ui_filter({"mitigation_available": "true"})
196+
self.assertIn(self.finding_with_mitigation.id, result)
197+
self.assertIn(self.finding_whitespace_mitigation.id, result)
198+
self.assertNotIn(self.finding_null_mitigation.id, result)
199+
self.assertNotIn(self.finding_empty_mitigation.id, result)
200+
201+
def test_mitigation_available_false(self):
202+
# False branch: returns null and empty string, excludes non-empty
203+
result = self._ui_filter({"mitigation_available": "false"})
204+
self.assertIn(self.finding_null_mitigation.id, result)
205+
self.assertIn(self.finding_empty_mitigation.id, result)
206+
self.assertNotIn(self.finding_with_mitigation.id, result)
207+
# Whitespace-only is not null/empty → not in false branch
208+
self.assertNotIn(self.finding_whitespace_mitigation.id, result)
209+
210+
def test_no_filter_returns_full_set(self):
211+
result = self._ui_filter({})
212+
expected = {
213+
self.finding_with_mitigation.id,
214+
self.finding_whitespace_mitigation.id,
215+
self.finding_null_mitigation.id,
216+
self.finding_empty_mitigation.id,
217+
}
218+
self.assertEqual(result, expected)

0 commit comments

Comments
 (0)