Skip to content

Commit 3e672ff

Browse files
fix cascade delete bug and default (#14772)
Co-authored-by: Cody Maffucci <46459665+Maffooch@users.noreply.github.com>
1 parent bf60a27 commit 3e672ff

3 files changed

Lines changed: 65 additions & 17 deletions

File tree

dojo/finding/helper.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,33 @@ def prepare_duplicates_for_delete(obj):
667667
# so original_finding.all() now only contains outside-scope duplicates.
668668
reconfigure_duplicate_cluster(original, original.original_finding.all())
669669

670+
# When DUPLICATE_CLUSTER_CASCADE_DELETE=True, reconfigure_duplicate_cluster is a no-op.
671+
# Match legacy finding_delete(): delete outside-scope cluster members that point at
672+
# in-scope originals (duplicate_cluster.order_by("-id").delete()). Transitive duplicate
673+
# chains do not need a separate expansion pass — fix_loop_duplicates above normalizes them.
674+
if settings.DUPLICATE_CLUSTER_CASCADE_DELETE:
675+
outside_cascade_qs = Finding.objects.filter(
676+
duplicate_finding_id__in=scope_ids_subquery,
677+
).exclude(id__in=scope_ids_subquery)
678+
outside_count = outside_cascade_qs.count()
679+
if outside_count:
680+
logger.debug(
681+
"cascade-delete %d outside-scope duplicate findings (DUPLICATE_CLUSTER_CASCADE_DELETE)",
682+
outside_count,
683+
)
684+
bulk_delete_findings(outside_cascade_qs, order_desc=True)
685+
else:
686+
outside_orphan_count = Finding.objects.filter(
687+
duplicate_finding_id__in=scope_ids_subquery,
688+
).exclude(
689+
id__in=scope_ids_subquery,
690+
).update(duplicate_finding=None, duplicate=False)
691+
if outside_orphan_count:
692+
logger.debug(
693+
"nulled %d outside-scope duplicate_finding references to prevent FK violation",
694+
outside_orphan_count,
695+
)
696+
670697

671698
@receiver(pre_delete, sender=Test)
672699
def test_pre_delete(sender, instance, **kwargs):
@@ -763,14 +790,18 @@ def bulk_clear_finding_m2m(finding_qs):
763790
Notes.objects.filter(id__in=note_ids).delete()
764791

765792

766-
def _bulk_delete_findings_internal(finding_qs, chunk_size=1000):
793+
def _bulk_delete_findings_internal(finding_qs, chunk_size=1000, *, order_desc=False):
767794
"""
768795
Delete findings and all related objects efficiently. Including any related object in Dojo-Pro
769796
770797
Sends the pre_bulk_delete signal, clears M2M through tables (not
771798
discovered by _meta.related_objects), then uses cascade_delete for
772799
all FK relations via raw SQL.
773800
Chunked with per-chunk transaction.atomic() for crash safety.
801+
802+
When order_desc is True, findings are processed highest id first (matches
803+
finding_delete: duplicate_cluster.order_by("-id").delete()) so self-FK
804+
duplicate chains delete children before parents.
774805
"""
775806
from dojo.signals import pre_bulk_delete_findings # noqa: PLC0415 circular import
776807
from dojo.utils_cascade_delete import ( # noqa: PLC0415 circular import
@@ -780,9 +811,10 @@ def _bulk_delete_findings_internal(finding_qs, chunk_size=1000):
780811

781812
pre_bulk_delete_findings.send(sender=Finding, finding_qs=finding_qs)
782813
bulk_clear_finding_m2m(finding_qs)
814+
ordered_qs = finding_qs.order_by("-id") if order_desc else finding_qs.order_by("id")
783815
for chunk_num, chunk_ids in enumerate(
784816
batched(
785-
finding_qs.values_list("id", flat=True).order_by("id").iterator(chunk_size=chunk_size),
817+
ordered_qs.values_list("id", flat=True).iterator(chunk_size=chunk_size),
786818
chunk_size,
787819
strict=False,
788820
),
@@ -798,7 +830,7 @@ def _bulk_delete_findings_internal(finding_qs, chunk_size=1000):
798830
)
799831

800832

801-
def bulk_delete_findings(finding_qs, chunk_size=1000, cascade_root=None):
833+
def bulk_delete_findings(finding_qs, chunk_size=1000, cascade_root=None, *, order_desc=False):
802834
"""
803835
Entry point; may delegate to Pro via settings.BULK_DELETE_FINDINGS_METHOD.
804836
@@ -813,8 +845,9 @@ def bulk_delete_findings(finding_qs, chunk_size=1000, cascade_root=None):
813845
finding_qs,
814846
chunk_size=chunk_size,
815847
cascade_root=cascade_root,
848+
order_desc=order_desc,
816849
)
817-
return _bulk_delete_findings_internal(finding_qs, chunk_size=chunk_size)
850+
return _bulk_delete_findings_internal(finding_qs, chunk_size=chunk_size, order_desc=order_desc)
818851

819852

820853
def fix_loop_duplicates(scope_qs=None):

dojo/settings/settings.dist.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,7 @@
215215
DD_FEATURE_FINDING_GROUPS=(bool, True),
216216
DD_JIRA_TEMPLATE_ROOT=(str, "dojo/templates/issue-trackers"),
217217
DD_TEMPLATE_DIR_PREFIX=(str, "dojo/templates/"),
218-
# Initial behaviour in Defect Dojo was to delete all duplicates when an original was deleted
219-
# New behaviour is to leave the duplicates in place, but set the oldest of duplicates as new original
220-
# Set to True to revert to the old behaviour where all duplicates are deleted
221-
DD_DUPLICATE_CLUSTER_CASCADE_DELETE=(bool, True),
218+
DD_DUPLICATE_CLUSTER_CASCADE_DELETE=(bool, False),
222219
# Enable Rate Limiting for the login page
223220
DD_RATE_LIMITER_ENABLED=(bool, False),
224221
# Examples include 5/m 100/h and more https://django-ratelimit.readthedocs.io/en/stable/rates.html#simple-rates

unittests/test_prepare_duplicates_for_delete.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -236,12 +236,11 @@ def test_mixed_inside_and_outside_duplicates(self):
236236
self.assertIsNone(outside_dupe.duplicate_finding)
237237

238238
@override_settings(DUPLICATE_CLUSTER_CASCADE_DELETE=True)
239-
def test_cascade_delete_skips_outside_reconfigure(self):
239+
def test_cascade_delete_removes_outside_scope_duplicate_findings(self):
240240
"""
241-
When DUPLICATE_CLUSTER_CASCADE_DELETE=True, outside duplicates are left untouched.
242-
243-
The caller (async_delete_crawl_task) handles deletion of outside-scope
244-
duplicates separately via bulk_delete_findings.
241+
When DUPLICATE_CLUSTER_CASCADE_DELETE=True, outside-scope findings in the duplicate
242+
cluster are deleted (same as legacy duplicate_cluster.order_by("-id").delete() in
243+
finding_delete), not merely unlinked.
245244
"""
246245
original = self._create_finding(self.test1, "Original")
247246
outside_dupe = self._create_finding(self.test2, "Outside Dupe")
@@ -250,10 +249,29 @@ def test_cascade_delete_skips_outside_reconfigure(self):
250249
with impersonate(self.testuser):
251250
prepare_duplicates_for_delete(self.test1)
252251

253-
outside_dupe.refresh_from_db()
254-
# Outside dupe is still a duplicate — not reconfigured or deleted
255-
self.assertTrue(outside_dupe.duplicate)
256-
self.assertEqual(outside_dupe.duplicate_finding_id, original.id)
252+
self.assertFalse(
253+
Finding.objects.filter(id=outside_dupe.id).exists(),
254+
msg="outside-scope duplicate should be cascade-deleted with DUPLICATE_CLUSTER_CASCADE_DELETE=True",
255+
)
256+
257+
@override_settings(DUPLICATE_CLUSTER_CASCADE_DELETE=True)
258+
def test_engagement_delete_with_outside_scope_duplicate_no_fk_violation(self):
259+
# Regression: engagement.delete() raises IntegrityError when DUPLICATE_CLUSTER_CASCADE_DELETE=True
260+
# and an outside-scope finding still holds a duplicate_finding FK to an in-scope finding.
261+
original = self._create_finding(self.test1, "Original in Eng1")
262+
outside_dupe = self._create_finding(self.test3, "Outside Dupe in Eng2")
263+
self._make_duplicate(outside_dupe, original)
264+
265+
# This must not raise django.db.utils.IntegrityError (FK violation)
266+
with impersonate(self.testuser):
267+
self.engagement1.delete()
268+
269+
# Engagement and its findings are gone
270+
self.assertFalse(Engagement.objects.filter(id=self.engagement1.id).exists())
271+
self.assertFalse(Finding.objects.filter(id=original.id).exists())
272+
273+
# Outside-scope duplicate was cascade-deleted with the cluster (legacy behaviour)
274+
self.assertFalse(Finding.objects.filter(id=outside_dupe.id).exists())
257275

258276
def test_multiple_originals(self):
259277
"""Multiple originals in the same test each get their clusters handled."""

0 commit comments

Comments
 (0)