Skip to content

Commit ce20094

Browse files
fix: endpoints not removable from finding via Edit Finding form
Two bugs introduced in the locations refactor (PR DefectDojo#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.
1 parent 5a39808 commit ce20094

3 files changed

Lines changed: 245 additions & 2 deletions

File tree

dojo/finding/helper.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -771,10 +771,9 @@ def add_locations(finding, form):
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+
finding.endpoints.set(form_endpoints | new_endpoints)
778777

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

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)
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)
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)
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)
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)