Skip to content

Commit 4a79e34

Browse files
fix: replace save_no_options with .update() in reconfigure_duplicate_cluster
Avoids triggering Finding.save() signals (pre_save_changed, execute_prioritization_calculations) when reconfiguring duplicate clusters during deletion. Adds tests for cross-engagement duplicate reconfiguration and product deletion with duplicates.
1 parent 9faae75 commit 4a79e34

2 files changed

Lines changed: 69 additions & 7 deletions

File tree

dojo/finding/helper.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -602,15 +602,16 @@ def reconfigure_duplicate_cluster(original, cluster_outside):
602602
if new_original:
603603
logger.debug("changing original of duplicate cluster %d to: %s:%s", original.id, new_original.id, new_original.title)
604604

605-
new_original.duplicate = False
606-
new_original.duplicate_finding = None
607-
new_original.active = original.active
608-
new_original.is_mitigated = original.is_mitigated
609-
new_original.save_no_options()
605+
# Use .update() to avoid triggering Finding.save() signals
606+
Finding.objects.filter(id=new_original.id).update(
607+
duplicate=False,
608+
duplicate_finding=None,
609+
active=original.active,
610+
is_mitigated=original.is_mitigated,
611+
)
610612
new_original.found_by.set(original.found_by.all())
611613

612-
# Re-point remaining duplicates to the new original in a single query
613-
if new_original and len(cluster_outside) > 1:
614+
# Re-point remaining duplicates to the new original in a single query
614615
cluster_outside.exclude(id=new_original.id).update(duplicate_finding=new_original)
615616

616617

unittests/test_prepare_duplicates_for_delete.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,64 @@ def test_found_by_copied_to_new_original(self):
295295
found_by_ids = set(outside_dupe.found_by.values_list("id", flat=True))
296296
self.assertIn(self.test_type.id, found_by_ids)
297297
self.assertIn(test_type_2.id, found_by_ids)
298+
299+
def test_delete_finding_reconfigures_cross_engagement_duplicate(self):
300+
"""Deleting an original finding makes its cross-engagement duplicate standalone.
301+
302+
Setup: product with eng A (finding A, original) and eng B (finding B, duplicate of A).
303+
Action: delete finding A.
304+
Expected: finding B becomes a standalone finding (not duplicate, active, no duplicate_finding).
305+
"""
306+
finding_a = self._create_finding(self.test1, "Original A")
307+
finding_a.active = True
308+
finding_a.is_mitigated = False
309+
super(Finding, finding_a).save(skip_validation=True)
310+
311+
finding_b = self._create_finding(self.test3, "Duplicate B")
312+
self._make_duplicate(finding_b, finding_a)
313+
314+
# Verify setup
315+
finding_b.refresh_from_db()
316+
self.assertTrue(finding_b.duplicate)
317+
self.assertEqual(finding_b.duplicate_finding_id, finding_a.id)
318+
319+
# Delete finding A — triggers finding_delete signal -> reconfigure_duplicate_cluster
320+
with impersonate(self.testuser):
321+
finding_a.delete()
322+
323+
# Finding B should now be standalone
324+
finding_b.refresh_from_db()
325+
self.assertFalse(finding_b.duplicate)
326+
self.assertIsNone(finding_b.duplicate_finding)
327+
self.assertTrue(finding_b.active)
328+
self.assertFalse(finding_b.is_mitigated)
329+
330+
def test_delete_product_with_cross_engagement_duplicates(self):
331+
"""Deleting a product with cross-engagement duplicates succeeds without FK violations.
332+
333+
Setup: product with eng A (finding A, original) and eng B (finding B, duplicate of A).
334+
Action: delete the entire product via async_delete_crawl_task.
335+
Expected: product and all findings are deleted without errors.
336+
"""
337+
from dojo.utils import ASYNC_DELETE_MAPPING, async_delete_crawl_task
338+
339+
finding_a = self._create_finding(self.test1, "Original A")
340+
finding_a.active = True
341+
finding_a.is_mitigated = False
342+
super(Finding, finding_a).save(skip_validation=True)
343+
344+
finding_b = self._create_finding(self.test3, "Duplicate B")
345+
self._make_duplicate(finding_b, finding_a)
346+
347+
product_id = self.product.id
348+
finding_a_id = finding_a.id
349+
finding_b_id = finding_b.id
350+
351+
model_list = ASYNC_DELETE_MAPPING["Product"]
352+
with impersonate(self.testuser):
353+
async_delete_crawl_task(self.product, model_list)
354+
355+
# Everything should be gone
356+
self.assertFalse(Product.objects.filter(id=product_id).exists())
357+
self.assertFalse(Finding.objects.filter(id=finding_a_id).exists())
358+
self.assertFalse(Finding.objects.filter(id=finding_b_id).exists())

0 commit comments

Comments
 (0)