Skip to content

Commit ad68fa5

Browse files
fix: endpoints not removable from finding via Edit Finding form (#14460)
* fix: endpoints not removable from finding via Edit Finding form Two bugs introduced in the locations refactor (PR #14198): 1. FindingForm did not set `endpoints.initial` for the non-V3 path, because `endpoints` is in Meta.exclude and Django won't auto-populate excluded fields from the model instance. Added explicit initial assignment so existing endpoints are pre-selected in the edit form. 2. add_locations() merged the submitted endpoint selection with the pre-existing set (| finding.endpoints.all()), making it impossible to remove an endpoint by deselecting it. Removed the union with the existing set so the submitted selection replaces the current one. Adds unit tests covering add, keep, remove, and switch scenarios for both add_locations() directly and the EditFinding view. * fix: restore union behaviour in add_locations() for non-edit paths The previous fix applied replace semantics unconditionally, breaking the add_finding_from_template flow which copies template endpoints onto the finding before calling add_locations(). Replace is now opt-in via a keyword argument (replace=False by default). Only EditFinding passes replace=True; all other callers (add from template, promote to finding, add finding) keep the original union behaviour so that pre-populated endpoints are not wiped by an empty form submission. Unit tests updated to pass replace=True when testing the remove/replace scenarios that are specific to the EditFinding path. * fix(tests): add required Product description to avoid V3 tagulous validation error --------- Co-authored-by: Matt Tesauro <mtesauro@gmail.com>
1 parent bcb75ec commit ad68fa5

4 files changed

Lines changed: 251 additions & 5 deletions

File tree

dojo/finding/helper.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -765,16 +765,18 @@ def removeLoop(finding_id, counter):
765765
removeLoop(f.id, counter - 1)
766766

767767

768-
def add_locations(finding, form):
768+
def add_locations(finding, form, *, replace=False):
769769
# TODO: Delete this after the move to Locations
770770
if not settings.V3_FEATURE_LOCATIONS:
771771
added_endpoints = save_endpoints_to_add(form.endpoints_to_add_list, finding.test.engagement.product)
772772
endpoint_ids = [endpoint.id for endpoint in added_endpoints]
773773

774-
# Merge form endpoints with existing endpoints (don't replace)
775774
form_endpoints = form.cleaned_data.get("endpoints", Endpoint.objects.none())
776775
new_endpoints = Endpoint.objects.filter(id__in=endpoint_ids)
777-
finding.endpoints.set(form_endpoints | new_endpoints | finding.endpoints.all())
776+
if replace:
777+
finding.endpoints.set(form_endpoints | new_endpoints)
778+
else:
779+
finding.endpoints.set(form_endpoints | new_endpoints | finding.endpoints.all())
778780

779781
for endpoint in finding.endpoints.all():
780782
_eps, _created = Endpoint_Status.objects.get_or_create(

dojo/finding/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -935,8 +935,8 @@ def process_finding_form(self, request: HttpRequest, finding: Finding, context:
935935
ra_helper.simple_risk_accept(request.user, new_finding, perform_save=False)
936936
elif new_finding.risk_accepted:
937937
ra_helper.risk_unaccept(request.user, new_finding, perform_save=False)
938-
# Save and add new locations
939-
associated_locations = finding_helper.add_locations(new_finding, context["form"])
938+
# Save and add new locations; replace=True so deselected endpoints are removed
939+
associated_locations = finding_helper.add_locations(new_finding, context["form"], replace=True)
940940
# Remove unrelated endpoints
941941
if settings.V3_FEATURE_LOCATIONS:
942942
for ref in new_finding.locations.all():

dojo/forms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,8 @@ def __init__(self, *args, **kwargs):
15821582
else:
15831583
# TODO: Delete this after the move to Locations
15841584
self.fields["endpoints"].queryset = Endpoint.objects.filter(product=self.instance.test.engagement.product)
1585+
if self.instance and self.instance.pk:
1586+
self.fields["endpoints"].initial = self.instance.endpoints.all()
15851587

15861588
self.fields["mitigated_by"].queryset = get_authorized_users(Permissions.Finding_Edit)
15871589

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""
2+
Tests for endpoint add/remove behaviour on the Edit Finding view.
3+
4+
Covers two bugs introduced in the locations refactor (PR #14198):
5+
1. Existing endpoints were not pre-selected in the edit form (Meta.exclude
6+
prevents Django from auto-populating the custom field from the instance).
7+
2. Removing a selected endpoint had no effect because add_locations() always
8+
unioned the submitted selection with the pre-existing endpoints.
9+
"""
10+
11+
import logging
12+
from datetime import date
13+
from types import SimpleNamespace
14+
15+
from django.test import TestCase, override_settings
16+
from django.urls import reverse
17+
from django.utils.timezone import now
18+
19+
from dojo.finding.helper import add_locations
20+
from dojo.models import (
21+
Endpoint,
22+
Endpoint_Status,
23+
Engagement,
24+
Finding,
25+
Product,
26+
Product_Type,
27+
Test,
28+
Test_Type,
29+
User,
30+
)
31+
32+
logger = logging.getLogger(__name__)
33+
34+
35+
def _make_form(endpoints_qs, date_value=None):
36+
"""Return a minimal form-like object accepted by add_locations()."""
37+
return SimpleNamespace(
38+
endpoints_to_add_list=[],
39+
cleaned_data={
40+
"endpoints": endpoints_qs,
41+
"date": date_value or date.today(),
42+
},
43+
)
44+
45+
46+
@override_settings(V3_FEATURE_LOCATIONS=False)
47+
class TestAddLocationsEndpoints(TestCase):
48+
49+
"""Unit tests for finding.helper.add_locations() in non-V3 (Endpoint) mode."""
50+
51+
def setUp(self):
52+
product_type = Product_Type.objects.create(name="PT")
53+
self.product = Product.objects.create(name="P", prod_type=product_type, description="Test product")
54+
engagement = Engagement.objects.create(
55+
name="E", product=self.product, target_start=now(), target_end=now(),
56+
)
57+
test_type = Test_Type.objects.create(name="TT")
58+
self.test = Test.objects.create(
59+
engagement=engagement, test_type=test_type,
60+
target_start=now(), target_end=now(),
61+
)
62+
user = User.objects.create_user(username="u1", password="pass") # noqa: S106
63+
self.finding = Finding.objects.create(
64+
title="F", severity="High", test=self.test, reporter=user,
65+
)
66+
self.ep1 = Endpoint.objects.create(host="host1.example.com", product=self.product)
67+
self.ep2 = Endpoint.objects.create(host="host2.example.com", product=self.product)
68+
69+
def test_add_endpoint(self):
70+
"""Submitting an endpoint that is not yet on the finding adds it."""
71+
form = _make_form(Endpoint.objects.filter(pk=self.ep1.pk))
72+
add_locations(self.finding, form)
73+
self.assertIn(self.ep1, self.finding.endpoints.all())
74+
75+
def test_keep_existing_endpoint(self):
76+
"""Re-submitting an already-associated endpoint keeps it."""
77+
self.finding.endpoints.add(self.ep1)
78+
79+
form = _make_form(Endpoint.objects.filter(pk=self.ep1.pk))
80+
add_locations(self.finding, form)
81+
82+
self.assertIn(self.ep1, self.finding.endpoints.all())
83+
84+
def test_remove_endpoint(self):
85+
"""Submitting an empty selection removes the previously-associated endpoint."""
86+
self.finding.endpoints.add(self.ep1)
87+
88+
form = _make_form(Endpoint.objects.none())
89+
add_locations(self.finding, form, replace=True)
90+
91+
self.assertNotIn(self.ep1, self.finding.endpoints.all())
92+
93+
def test_switch_endpoint(self):
94+
"""Deselecting one endpoint and selecting another replaces it."""
95+
self.finding.endpoints.add(self.ep1)
96+
97+
form = _make_form(Endpoint.objects.filter(pk=self.ep2.pk))
98+
add_locations(self.finding, form, replace=True)
99+
100+
self.assertNotIn(self.ep1, self.finding.endpoints.all())
101+
self.assertIn(self.ep2, self.finding.endpoints.all())
102+
103+
def test_endpoint_status_created_on_add(self):
104+
"""An Endpoint_Status record is created when an endpoint is added."""
105+
form = _make_form(Endpoint.objects.filter(pk=self.ep1.pk))
106+
add_locations(self.finding, form)
107+
108+
self.assertTrue(
109+
Endpoint_Status.objects.filter(finding=self.finding, endpoint=self.ep1).exists(),
110+
)
111+
112+
113+
@override_settings(V3_FEATURE_LOCATIONS=False)
114+
class TestEditFindingEndpointView(TestCase):
115+
116+
"""View-level tests for EditFinding endpoint handling."""
117+
118+
def _minimal_post_data(self, **overrides):
119+
data = {
120+
"title": self.finding.title,
121+
"date": "2024-01-01",
122+
"severity": "High",
123+
"description": "Test description",
124+
"active": "on",
125+
"verified": "",
126+
"false_p": "",
127+
"duplicate": "",
128+
"out_of_scope": "",
129+
"endpoints": [],
130+
"endpoints_to_add": "",
131+
"vulnerability_ids": "",
132+
"references": "",
133+
"mitigation": "",
134+
"impact": "",
135+
"steps_to_reproduce": "",
136+
"severity_justification": "",
137+
}
138+
data.update(overrides)
139+
return data
140+
141+
def setUp(self):
142+
self.user = User.objects.create_user(
143+
username="tester", password="pass", # noqa: S106
144+
is_staff=True, is_superuser=True,
145+
)
146+
self.client.force_login(self.user)
147+
product_type = Product_Type.objects.create(name="PT")
148+
self.product = Product.objects.create(name="P", prod_type=product_type, description="Test product")
149+
engagement = Engagement.objects.create(
150+
name="E", product=self.product, target_start=now(), target_end=now(),
151+
)
152+
test_type = Test_Type.objects.create(name="TT")
153+
self.test_obj = Test.objects.create(
154+
engagement=engagement, test_type=test_type,
155+
target_start=now(), target_end=now(),
156+
)
157+
self.finding = Finding.objects.create(
158+
title="Endpoint Test Finding",
159+
severity="High",
160+
test=self.test_obj,
161+
reporter=self.user,
162+
)
163+
self.endpoint = Endpoint.objects.create(
164+
host="vuln.example.com", product=self.product,
165+
)
166+
self.url = reverse("edit_finding", args=[self.finding.id])
167+
168+
def test_get_preselects_existing_endpoints(self):
169+
"""GET edit form has existing endpoints pre-selected as initial values."""
170+
self.finding.endpoints.add(self.endpoint)
171+
172+
response = self.client.get(self.url)
173+
174+
self.assertEqual(response.status_code, 200)
175+
initial = list(response.context["form"].fields["endpoints"].initial)
176+
self.assertIn(self.endpoint, initial)
177+
178+
def test_get_preselects_multiple_endpoints(self):
179+
"""GET edit form pre-selects all associated endpoints, not just the first."""
180+
endpoint2 = Endpoint.objects.create(host="vuln2.example.com", product=self.product)
181+
self.finding.endpoints.add(self.endpoint)
182+
self.finding.endpoints.add(endpoint2)
183+
184+
response = self.client.get(self.url)
185+
186+
self.assertEqual(response.status_code, 200)
187+
initial = list(response.context["form"].fields["endpoints"].initial)
188+
self.assertIn(self.endpoint, initial)
189+
self.assertIn(endpoint2, initial)
190+
191+
def test_get_no_endpoints_when_none_associated(self):
192+
"""GET edit form initial is empty when no endpoints are associated."""
193+
response = self.client.get(self.url)
194+
195+
self.assertEqual(response.status_code, 200)
196+
initial = list(response.context["form"].fields["endpoints"].initial)
197+
self.assertEqual(initial, [])
198+
199+
def test_post_removes_deselected_endpoint(self):
200+
"""POST with empty endpoints list removes the previously-associated endpoint."""
201+
self.finding.endpoints.add(self.endpoint)
202+
203+
response = self.client.post(self.url, self._minimal_post_data())
204+
205+
self.assertIn(response.status_code, [200, 302])
206+
self.finding.refresh_from_db()
207+
self.assertNotIn(self.endpoint, self.finding.endpoints.all())
208+
209+
def test_post_removes_endpoint_status_on_remove(self):
210+
"""POST that removes an endpoint also cleans up its Endpoint_Status record."""
211+
self.finding.endpoints.add(self.endpoint)
212+
213+
self.client.post(self.url, self._minimal_post_data())
214+
215+
self.assertFalse(
216+
Endpoint_Status.objects.filter(finding=self.finding, endpoint=self.endpoint).exists(),
217+
)
218+
219+
def test_post_keeps_selected_endpoint(self):
220+
"""POST with an endpoint still selected keeps it on the finding."""
221+
self.finding.endpoints.add(self.endpoint)
222+
223+
data = self._minimal_post_data(endpoints=[self.endpoint.pk])
224+
response = self.client.post(self.url, data)
225+
226+
self.assertIn(response.status_code, [200, 302])
227+
self.finding.refresh_from_db()
228+
self.assertIn(self.endpoint, self.finding.endpoints.all())
229+
230+
def test_post_keeps_all_selected_endpoints(self):
231+
"""POST that keeps all endpoints selected preserves all of them."""
232+
endpoint2 = Endpoint.objects.create(host="vuln2.example.com", product=self.product)
233+
self.finding.endpoints.add(self.endpoint)
234+
self.finding.endpoints.add(endpoint2)
235+
236+
data = self._minimal_post_data(endpoints=[self.endpoint.pk, endpoint2.pk])
237+
response = self.client.post(self.url, data)
238+
239+
self.assertIn(response.status_code, [200, 302])
240+
self.finding.refresh_from_db()
241+
self.assertIn(self.endpoint, self.finding.endpoints.all())
242+
self.assertIn(endpoint2, self.finding.endpoints.all())

0 commit comments

Comments
 (0)