Skip to content

Commit 376d0fe

Browse files
feat: add Remove from Finding bulk action on View Finding page (#14461)
* feat: add "Remove from Finding" bulk action on View Finding page Adds a trash button next to the Bulk Edit dropdown on the View Finding page that removes the selected endpoints from the finding without deleting the endpoint objects themselves. - endpoint_status_bulk_update (non-V3): if remove_from_finding is posted, delete the matching Endpoint_Status records for the selected endpoints - finding_location_bulk_update (V3): if remove_from_finding is posted, delete the matching LocationFindingReference records - view_finding.html: add "Remove from Finding" button that appends the selected endpoint IDs and a remove_from_finding flag to the existing bulk_change_form before submitting - Add unit tests covering single, partial, and full removal, Endpoint_Status cleanup, no-op without the flag, and redirect behaviour * fix(tests): add required Product description to avoid V3 tagulous validation error * fix(tests): gate test classes on V3_FEATURE_LOCATIONS; add V3 path tests @override_settings does not affect URL routing (which is set up at startup). In V3=true CI, endpoints_status_bulk resolves to the V3 handler (finding_location_bulk_update) rather than the non-V3 handler (endpoint_status_bulk_update), causing the non-V3 tests to fail. Fix by: - Wrapping each test class with @skipUnless so it only runs when the matching URL handler is active - Adding TestRemoveLocationsFromFindingView to exercise the V3 path (finding_location_bulk_update + LocationFindingReference) when V3_FEATURE_LOCATIONS=True * style: remove text label from trash button, icon only * style: add tooltip to Remove from Finding trash button * style: match trash button style to other delete buttons (btn-primary, click on whole button) --------- Co-authored-by: Matt Tesauro <mtesauro@gmail.com>
1 parent ad68fa5 commit 376d0fe

4 files changed

Lines changed: 289 additions & 2 deletions

File tree

dojo/endpoint/views.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,15 @@ def endpoint_status_bulk_update(request, fid):
398398
status_list = ["active", "false_positive", "mitigated", "out_of_scope", "risk_accepted"]
399399
enable = [item for item in status_list if item in list(post.keys())]
400400

401-
if endpoints_to_update and len(enable) > 0:
401+
if request.POST.get("remove_from_finding") and endpoints_to_update:
402+
Endpoint_Status.objects.filter(finding_id=fid, endpoint_id__in=endpoints_to_update).delete()
403+
messages.add_message(
404+
request,
405+
messages.SUCCESS,
406+
"Selected endpoints have been removed from this finding.",
407+
extra_tags="alert-success",
408+
)
409+
elif endpoints_to_update and len(enable) > 0:
402410
endpoints = Endpoint.objects.filter(id__in=endpoints_to_update).order_by("endpoint_meta__product__id")
403411
for endpoint in endpoints:
404412
endpoint_status = Endpoint_Status.objects.get(

dojo/templates/dojo/view_finding.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,11 @@ <h4 class="has-filters">
928928
</li>
929929
</ul>
930930
</div>
931+
<div class="btn-group mr-2" role="group">
932+
<button type="button" class="btn btn-sm btn-primary remove-from-finding" data-toggle="tooltip" data-placement="bottom" title="Remove selected endpoints from this finding" aria-label="Remove selected endpoints from this finding">
933+
<i class="fa-solid fa-trash"></i>
934+
</button>
935+
</div>
931936
</div>
932937
</div>
933938

@@ -1468,6 +1473,18 @@ <h4>Credential
14681473
});
14691474
});
14701475

1476+
$('button.remove-from-finding').on('click', function(e) {
1477+
e.preventDefault();
1478+
if (confirm('Remove selected endpoints from this finding?')) {
1479+
$('input[type=checkbox].select_one:checked').each(function(){
1480+
var hidden_input = $('<input type="hidden" value="' + this.id + '" name="endpoints_to_update">');
1481+
$('form#bulk_change_form').append(hidden_input);
1482+
});
1483+
$('form#bulk_change_form').append($('<input type="hidden" name="remove_from_finding" value="1">'));
1484+
$('form#bulk_change_form').submit();
1485+
}
1486+
});
1487+
14711488
$('input#select_all_vulnerable').on('click', function (e) {
14721489
var checkbox_values = $("input[type=checkbox][name^='select_vulnerable']");
14731490
if ($(this).is(":checked")) {

dojo/url/ui/views.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,8 +561,19 @@ def finding_location_bulk_update(request, finding_id):
561561
finding_locations_to_update = request.POST.getlist("endpoints_to_update")
562562
# Get the status
563563
status = request.POST.get("bulk_status")
564+
if request.POST.get("remove_from_finding") and finding_locations_to_update:
565+
# Remove the selected location-finding associations without deleting the locations themselves
566+
LocationFindingReference.objects.filter(
567+
location__in=finding_locations_to_update, finding__id=finding_id,
568+
).delete()
569+
messages.add_message(
570+
request,
571+
messages.SUCCESS,
572+
"Selected endpoints have been removed from this finding.",
573+
extra_tags="alert-success",
574+
)
564575
# Check that endpoints and statuses are selected before proceeding
565-
if finding_locations_to_update and status in FindingLocationStatus:
576+
elif finding_locations_to_update and status in FindingLocationStatus:
566577
# Iterate over selected locations and update their finding location references
567578
for location_ref in LocationFindingReference.objects.filter(location__in=finding_locations_to_update, finding__id=finding_id):
568579
# Set the status
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
"""
2+
Tests for the "Remove from Finding" bulk action on the View Finding page.
3+
4+
Covers both the non-V3 (Endpoint/Endpoint_Status) and V3 (Location/
5+
LocationFindingReference) paths via the respective bulk-update views.
6+
7+
The two test classes are gated by skipUnless so that each class only runs
8+
against the URL configuration that is active for its code path:
9+
- TestRemoveEndpointsFromFindingView — skipped when V3_FEATURE_LOCATIONS=True
10+
- TestRemoveLocationsFromFindingView — skipped when V3_FEATURE_LOCATIONS=False
11+
"""
12+
13+
import logging
14+
from unittest import skipUnless
15+
16+
from django.conf import settings
17+
from django.test import TestCase
18+
from django.urls import reverse
19+
from django.utils.timezone import now
20+
21+
from dojo.location.models import LocationFindingReference
22+
from dojo.models import (
23+
Endpoint,
24+
Endpoint_Status,
25+
Engagement,
26+
Finding,
27+
Product,
28+
Product_Type,
29+
Test,
30+
Test_Type,
31+
User,
32+
)
33+
from dojo.url.models import URL
34+
35+
logger = logging.getLogger(__name__)
36+
37+
38+
def _make_superuser(username):
39+
return User.objects.create_user(
40+
username=username,
41+
password="pass", # noqa: S106
42+
is_staff=True,
43+
is_superuser=True,
44+
)
45+
46+
47+
def _make_finding(test, reporter):
48+
return Finding.objects.create(
49+
title="Bulk Remove Test Finding",
50+
severity="High",
51+
test=test,
52+
reporter=reporter,
53+
)
54+
55+
56+
def _make_product_tree(product_name="P"):
57+
pt = Product_Type.objects.create(name="PT")
58+
product = Product.objects.create(name=product_name, prod_type=pt, description="Test product")
59+
engagement = Engagement.objects.create(
60+
name="E", product=product, target_start=now(), target_end=now(),
61+
)
62+
test_type = Test_Type.objects.create(name="TT")
63+
test = Test.objects.create(
64+
engagement=engagement, test_type=test_type,
65+
target_start=now(), target_end=now(),
66+
)
67+
return product, test
68+
69+
70+
@skipUnless(not settings.V3_FEATURE_LOCATIONS, "Non-V3 endpoint path only")
71+
class TestRemoveEndpointsFromFindingView(TestCase):
72+
73+
"""Tests for endpoint_status_bulk_update (non-V3 path)."""
74+
75+
def setUp(self):
76+
self.user = _make_superuser("tester")
77+
self.client.force_login(self.user)
78+
self.product, self.test_obj = _make_product_tree()
79+
self.finding = _make_finding(self.test_obj, self.user)
80+
self.ep1 = Endpoint.objects.create(host="ep1.example.com", product=self.product)
81+
self.ep2 = Endpoint.objects.create(host="ep2.example.com", product=self.product)
82+
self.url = reverse("endpoints_status_bulk", args=[self.finding.id])
83+
84+
def _post(self, endpoint_ids, *, remove=False):
85+
data = {
86+
"return_url": reverse("view_finding", args=[self.finding.id]),
87+
"endpoints_to_update": endpoint_ids,
88+
}
89+
if remove:
90+
data["remove_from_finding"] = "1"
91+
return self.client.post(self.url, data)
92+
93+
def test_remove_single_endpoint(self):
94+
"""POST with remove_from_finding removes the selected endpoint from the finding."""
95+
self.finding.endpoints.add(self.ep1)
96+
97+
response = self._post([self.ep1.pk], remove=True)
98+
99+
self.assertIn(response.status_code, [200, 302])
100+
self.assertNotIn(self.ep1, self.finding.endpoints.all())
101+
102+
def test_remove_cleans_up_endpoint_status(self):
103+
"""Removing an endpoint also deletes its Endpoint_Status record."""
104+
self.finding.endpoints.add(self.ep1)
105+
self.assertTrue(
106+
Endpoint_Status.objects.filter(finding=self.finding, endpoint=self.ep1).exists(),
107+
)
108+
109+
self._post([self.ep1.pk], remove=True)
110+
111+
self.assertFalse(
112+
Endpoint_Status.objects.filter(finding=self.finding, endpoint=self.ep1).exists(),
113+
)
114+
115+
def test_remove_only_selected_endpoint(self):
116+
"""Only the selected endpoint is removed; others remain."""
117+
self.finding.endpoints.add(self.ep1)
118+
self.finding.endpoints.add(self.ep2)
119+
120+
self._post([self.ep1.pk], remove=True)
121+
122+
self.assertNotIn(self.ep1, self.finding.endpoints.all())
123+
self.assertIn(self.ep2, self.finding.endpoints.all())
124+
125+
def test_remove_multiple_endpoints(self):
126+
"""Multiple endpoints can be removed in a single request."""
127+
self.finding.endpoints.add(self.ep1)
128+
self.finding.endpoints.add(self.ep2)
129+
130+
self._post([self.ep1.pk, self.ep2.pk], remove=True)
131+
132+
self.assertNotIn(self.ep1, self.finding.endpoints.all())
133+
self.assertNotIn(self.ep2, self.finding.endpoints.all())
134+
135+
def test_remove_without_flag_does_not_remove(self):
136+
"""Submitting endpoint IDs without remove_from_finding does not remove them."""
137+
self.finding.endpoints.add(self.ep1)
138+
139+
# Post without remove flag and without any status checkboxes — triggers
140+
# the "nothing selected" error branch, but must NOT remove the endpoint.
141+
self._post([self.ep1.pk], remove=False)
142+
143+
self.assertIn(self.ep1, self.finding.endpoints.all())
144+
145+
def test_remove_redirects(self):
146+
"""The view redirects after a successful remove."""
147+
self.finding.endpoints.add(self.ep1)
148+
return_url = reverse("view_finding", args=[self.finding.id])
149+
150+
response = self._post([self.ep1.pk], remove=True)
151+
152+
self.assertRedirects(response, return_url, fetch_redirect_response=False)
153+
154+
155+
@skipUnless(settings.V3_FEATURE_LOCATIONS, "V3 locations path only")
156+
class TestRemoveLocationsFromFindingView(TestCase):
157+
158+
"""Tests for finding_location_bulk_update (V3/Locations path)."""
159+
160+
def setUp(self):
161+
self.user = _make_superuser("tester")
162+
self.client.force_login(self.user)
163+
self.product, self.test_obj = _make_product_tree()
164+
self.finding = _make_finding(self.test_obj, self.user)
165+
166+
self.url1 = URL.get_or_create_from_values(host="loc1.example.com")
167+
self.url2 = URL.get_or_create_from_values(host="loc2.example.com")
168+
self.loc1 = self.url1.location
169+
self.loc2 = self.url2.location
170+
171+
self.url = reverse("endpoints_status_bulk", args=[self.finding.id])
172+
173+
def _associate(self, location):
174+
ref, _ = LocationFindingReference.objects.get_or_create(
175+
location=location, finding=self.finding,
176+
)
177+
return ref
178+
179+
def _post(self, location_ids, *, remove=False):
180+
data = {
181+
"return_url": reverse("view_finding", args=[self.finding.id]),
182+
"endpoints_to_update": location_ids,
183+
}
184+
if remove:
185+
data["remove_from_finding"] = "1"
186+
return self.client.post(self.url, data)
187+
188+
def test_remove_single_location(self):
189+
"""POST with remove_from_finding removes the selected location from the finding."""
190+
self._associate(self.loc1)
191+
192+
response = self._post([self.loc1.pk], remove=True)
193+
194+
self.assertIn(response.status_code, [200, 302])
195+
self.assertFalse(
196+
LocationFindingReference.objects.filter(
197+
finding=self.finding, location=self.loc1,
198+
).exists(),
199+
)
200+
201+
def test_remove_only_selected_location(self):
202+
"""Only the selected location is removed; others remain."""
203+
self._associate(self.loc1)
204+
self._associate(self.loc2)
205+
206+
self._post([self.loc1.pk], remove=True)
207+
208+
self.assertFalse(
209+
LocationFindingReference.objects.filter(
210+
finding=self.finding, location=self.loc1,
211+
).exists(),
212+
)
213+
self.assertTrue(
214+
LocationFindingReference.objects.filter(
215+
finding=self.finding, location=self.loc2,
216+
).exists(),
217+
)
218+
219+
def test_remove_multiple_locations(self):
220+
"""Multiple locations can be removed in a single request."""
221+
self._associate(self.loc1)
222+
self._associate(self.loc2)
223+
224+
self._post([self.loc1.pk, self.loc2.pk], remove=True)
225+
226+
self.assertFalse(
227+
LocationFindingReference.objects.filter(
228+
finding=self.finding, location__in=[self.loc1, self.loc2],
229+
).exists(),
230+
)
231+
232+
def test_remove_without_flag_does_not_remove(self):
233+
"""Submitting location IDs without remove_from_finding does not remove them."""
234+
self._associate(self.loc1)
235+
236+
self._post([self.loc1.pk], remove=False)
237+
238+
self.assertTrue(
239+
LocationFindingReference.objects.filter(
240+
finding=self.finding, location=self.loc1,
241+
).exists(),
242+
)
243+
244+
def test_remove_redirects(self):
245+
"""The view redirects after a successful remove."""
246+
self._associate(self.loc1)
247+
return_url = reverse("view_finding", args=[self.finding.id])
248+
249+
response = self._post([self.loc1.pk], remove=True)
250+
251+
self.assertRedirects(response, return_url, fetch_redirect_response=False)

0 commit comments

Comments
 (0)