From ef842ce4296a5f7ac3efabc687bf0b0995ed58f3 Mon Sep 17 00:00:00 2001 From: dogboat Date: Wed, 1 Jul 2026 10:35:33 -0400 Subject: [PATCH 1/8] wip --- dojo/importers/default_reimporter.py | 16 +++++++++++----- dojo/middleware.py | 9 +++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index 9defa8352f2..c843e54234e 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -292,7 +292,8 @@ def _process_findings_internal( logger.debug(f"original_findings_qyer: {original_findings.query}") self.original_items = list(original_findings) - logger.debug(f"original_items: {[(item.id, item.hash_code) for item in self.original_items]}") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("original_items: %s", [(item.id, item.hash_code) for item in self.original_items]) self.new_items = [] self.reactivated_items = [] self.unchanged_items = [] @@ -463,7 +464,10 @@ def _process_findings_internal( finding_ids_batch, dedupe_option=True, rules_option=True, - product_grading_option=True, + # Callers may defer grading to a single end-of-import pass + # (e.g. chunked connector reimport) to avoid one grade + # recalculation per dedupe batch; default keeps per-batch grading. + product_grading_option=not getattr(self, "defer_product_grading", False), issue_updater_option=True, push_to_jira=push_to_jira, jira_instance_id=getattr(self.jira_instance, "id", None), @@ -485,8 +489,10 @@ def _process_findings_internal( # Note: All chord batching is now handled within the loop above - # Synchronous tasks were already executed during processing, just calculate grade - perform_product_grading(self.test.engagement.product) + # Synchronous tasks were already executed during processing, just calculate grade. + # Callers may defer grading to a single end-of-import pass (see defer_product_grading). + if not getattr(self, "defer_product_grading", False): + perform_product_grading(self.test.engagement.product) return self.new_items, self.reactivated_items, self.to_mitigate, self.untouched @@ -575,7 +581,7 @@ def close_old_findings( if self.push_to_jira or jira_services.is_keep_in_sync(finding_group, prefetched_jira_instance=self.jira_instance): jira_services.push(finding_group) # Calculate grade once after all findings have been closed - if mitigated_findings: + if mitigated_findings and not getattr(self, "defer_product_grading", False): perform_product_grading(self.test.engagement.product) return mitigated_findings diff --git a/dojo/middleware.py b/dojo/middleware.py index a576244312a..16a5356d323 100644 --- a/dojo/middleware.py +++ b/dojo/middleware.py @@ -312,6 +312,15 @@ def install_intermediate_flush_hook(): def add_to_context_with_flush(self, engine, obj): original_add(self, engine, obj) + # The intermediate flush is a request-path optimization on the global singleton + # context (AsyncSearchContextMiddleware). The async reindex task + # update_watson_search_index_for_model() builds its OWN SearchContextManager and + # IS the drain target -- if it re-drained its batch it would dispatch a clone of + # itself, discard those pks unindexed, and loop forever (queue ~0, worker pegged, + # nothing indexed). Only the singleton intermediate-flushes; any ad-hoc context + # manager indexes its own batch on end(). + if self is not search_context_manager: + return threshold = getattr(settings, "WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE", 1000) if threshold <= 0 or not self._stack: return From c5c849e0b56c38ca79aac4fdb0650575e8423ce1 Mon Sep 17 00:00:00 2001 From: dogboat Date: Wed, 1 Jul 2026 17:19:39 -0400 Subject: [PATCH 2/8] add a ctx mgr for watson to suppress flushing --- dojo/middleware.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/dojo/middleware.py b/dojo/middleware.py index 16a5356d323..768d0a00e07 100644 --- a/dojo/middleware.py +++ b/dojo/middleware.py @@ -1,7 +1,7 @@ import logging import re import time -from contextlib import suppress +from contextlib import contextmanager, suppress from threading import local from urllib.parse import quote @@ -291,6 +291,31 @@ def _drain_search_context_to_async(objects, source): objects.discard(entry) +# Thread-local kill-switch for the intermediate-flush hook (below). Bulk operations that run +# under watson.skip_index_update() set this so the hook no-ops on their thread -- see +# suppress_intermediate_flush(). +_intermediate_flush_state = local() + + +@contextmanager +def suppress_intermediate_flush(): + """ + Disable the watson intermediate-flush hook on the current thread. + + watson.skip_index_update() only marks its context invalid at __exit__, so while a skip block + is still accumulating, the intermediate-flush hook can't tell it's a skip context and would + drain it -- dispatching async index tasks for objects meant to be discarded, defeating the + skip. Wrap a skip_index_update() block in this so the hook returns early (no accumulate, no + drain) and the skip actually holds. Thread-local, so it only affects the wrapping thread. + """ + previous = getattr(_intermediate_flush_state, "suppressed", False) + _intermediate_flush_state.suppressed = True + try: + yield + finally: + _intermediate_flush_state.suppressed = previous + + def install_intermediate_flush_hook(): """ Wrap `watson.search.search_context_manager.add_to_context` with a @@ -311,6 +336,13 @@ def install_intermediate_flush_hook(): original_add = cls.add_to_context def add_to_context_with_flush(self, engine, obj): + # Bulk ops under skip_index_update() (e.g. connector chunked reimport) set this to make the + # hook a full no-op on this thread. skip_index_update() only invalidates its context at + # __exit__, so mid-accumulation the hook would otherwise drain (index) objects meant to be + # discarded -- defeating the skip. Returning before original_add means nothing is even + # accumulated, so the skip holds and the caller's own end-of-op reindex does the indexing. + if getattr(_intermediate_flush_state, "suppressed", False): + return original_add(self, engine, obj) # The intermediate flush is a request-path optimization on the global singleton # context (AsyncSearchContextMiddleware). The async reindex task From 57d49a8687b1c0f06c592d33aebf38911d1455c7 Mon Sep 17 00:00:00 2001 From: dogboat Date: Wed, 1 Jul 2026 17:24:45 -0400 Subject: [PATCH 3/8] add defer_product_grading importer option --- dojo/importers/default_reimporter.py | 6 +++--- dojo/importers/options.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index c843e54234e..9da1eebfa6c 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -467,7 +467,7 @@ def _process_findings_internal( # Callers may defer grading to a single end-of-import pass # (e.g. chunked connector reimport) to avoid one grade # recalculation per dedupe batch; default keeps per-batch grading. - product_grading_option=not getattr(self, "defer_product_grading", False), + product_grading_option=not self.defer_product_grading, issue_updater_option=True, push_to_jira=push_to_jira, jira_instance_id=getattr(self.jira_instance, "id", None), @@ -491,7 +491,7 @@ def _process_findings_internal( # Synchronous tasks were already executed during processing, just calculate grade. # Callers may defer grading to a single end-of-import pass (see defer_product_grading). - if not getattr(self, "defer_product_grading", False): + if not self.defer_product_grading: perform_product_grading(self.test.engagement.product) return self.new_items, self.reactivated_items, self.to_mitigate, self.untouched @@ -581,7 +581,7 @@ def close_old_findings( if self.push_to_jira or jira_services.is_keep_in_sync(finding_group, prefetched_jira_instance=self.jira_instance): jira_services.push(finding_group) # Calculate grade once after all findings have been closed - if mitigated_findings and not getattr(self, "defer_product_grading", False): + if mitigated_findings and not self.defer_product_grading: perform_product_grading(self.test.engagement.product) return mitigated_findings diff --git a/dojo/importers/options.py b/dojo/importers/options.py index 02cecff1113..b1099812cae 100644 --- a/dojo/importers/options.py +++ b/dojo/importers/options.py @@ -62,6 +62,7 @@ def load_base_options( self.close_old_findings_toggle: bool = self.validate_close_old_findings(*args, **kwargs) self.close_old_findings_product_scope: bool = self.validate_close_old_findings_product_scope(*args, **kwargs) self.do_not_reactivate: bool = self.validate_do_not_reactivate(*args, **kwargs) + self.defer_product_grading: bool = self.validate_defer_product_grading(*args, **kwargs) self.commit_hash: str = self.validate_commit_hash(*args, **kwargs) self.create_finding_groups_for_all_findings: bool = self.validate_create_finding_groups_for_all_findings(*args, **kwargs) self.endpoints_to_add: list[Endpoint] | list[AbstractLocation] | None = self.validate_endpoints_to_add(*args, **kwargs) @@ -345,6 +346,21 @@ def validate_do_not_reactivate( **kwargs, ) + def validate_defer_product_grading( + self, + *args: list, + **kwargs: dict, + ) -> bool: + # Defer per-batch/end/close-old product grade dispatches so callers (e.g. the chunked + # connector reimport) can grade once at the end instead of once per dedupe batch. + return self.validate( + "defer_product_grading", + expected_types=[bool], + required=False, + default=False, + **kwargs, + ) + def validate_commit_hash( self, *args: list, From 9b9a6bfe8e53309d931cab62d6682da8fae1c6ce Mon Sep 17 00:00:00 2001 From: dogboat Date: Wed, 1 Jul 2026 17:52:24 -0400 Subject: [PATCH 4/8] tests --- unittests/test_watson_intermediate_flush.py | 46 ++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/unittests/test_watson_intermediate_flush.py b/unittests/test_watson_intermediate_flush.py index 624b516bf77..002ae158880 100644 --- a/unittests/test_watson_intermediate_flush.py +++ b/unittests/test_watson_intermediate_flush.py @@ -10,11 +10,12 @@ from unittest.mock import patch from django.test import override_settings -from watson.search import search_context_manager +from watson.search import SearchContextManager, search_context_manager from dojo.middleware import ( _drain_search_context_to_async, # noqa: PLC2701 -- internal helper under test install_intermediate_flush_hook, + suppress_intermediate_flush, ) from dojo.models import Product, Product_Type @@ -119,3 +120,46 @@ def test_invalidated_context_skips_drain(self): # hook should detect the invalid flag and bail out. search_context_manager.add_to_context(engine_marker, p) drain.assert_not_called() + + @override_settings(WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE=2) + def test_ad_hoc_context_manager_does_not_drain(self): + """ + The intermediate flush is a request-path optimization on the global singleton. An + ad-hoc SearchContextManager -- e.g. the one update_watson_search_index_for_model builds to + index its own batch -- must NOT drain, or it would dispatch a clone of itself and loop + forever (queue ~0, worker pegged, nothing indexed). + """ + adhoc = SearchContextManager() + adhoc.start() + try: + with patch("dojo.middleware._drain_search_context_to_async") as drain: + for p in self.products[:3]: # past the threshold of 2 + adhoc.add_to_context(object(), p) + drain.assert_not_called() + finally: + adhoc.invalidate() + adhoc.end() + + @override_settings(WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE=2) + def test_suppress_intermediate_flush_no_ops_hook_and_restores(self): + """ + suppress_intermediate_flush() turns the hook into a no-op on this thread, so a + skip_index_update() block isn't defeated -- otherwise the hook would drain the still-valid + skip context mid-accumulation and index objects meant to be discarded. Nothing is + accumulated or drained while suppressed, and the hook is live again after the block. + """ + self._open_context() + objects = search_context_manager._stack[-1][0] + engine_marker = object() + + with patch("dojo.middleware._drain_search_context_to_async") as drain: + with suppress_intermediate_flush(): + for p in self.products[:3]: # past threshold, but suppressed + search_context_manager.add_to_context(engine_marker, p) + drain.assert_not_called() + self.assertEqual(len(objects), 0, "suppressed hook must not even accumulate") + + # Flag restored after the block: the hook drains normally again at threshold. + for p in self.products[:2]: + search_context_manager.add_to_context(engine_marker, p) + drain.assert_called_once() From 903805546b6e2e2001dbcff9db945b92af2ad29b Mon Sep 17 00:00:00 2001 From: dogboat Date: Thu, 2 Jul 2026 16:00:13 -0400 Subject: [PATCH 5/8] comments --- dojo/importers/default_reimporter.py | 2 +- dojo/importers/options.py | 4 ++-- dojo/middleware.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index 9da1eebfa6c..b2a7269a20d 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -465,7 +465,7 @@ def _process_findings_internal( dedupe_option=True, rules_option=True, # Callers may defer grading to a single end-of-import pass - # (e.g. chunked connector reimport) to avoid one grade + # (e.g. a large chunked reimport) to avoid one grade # recalculation per dedupe batch; default keeps per-batch grading. product_grading_option=not self.defer_product_grading, issue_updater_option=True, diff --git a/dojo/importers/options.py b/dojo/importers/options.py index b1099812cae..f9b590053a5 100644 --- a/dojo/importers/options.py +++ b/dojo/importers/options.py @@ -351,8 +351,8 @@ def validate_defer_product_grading( *args: list, **kwargs: dict, ) -> bool: - # Defer per-batch/end/close-old product grade dispatches so callers (e.g. the chunked - # connector reimport) can grade once at the end instead of once per dedupe batch. + # Defer per-batch/end/close-old product grade dispatches so callers (e.g. a large + # chunked reimport) can grade once at the end instead of once per dedupe batch. return self.validate( "defer_product_grading", expected_types=[bool], diff --git a/dojo/middleware.py b/dojo/middleware.py index 768d0a00e07..5b90695b276 100644 --- a/dojo/middleware.py +++ b/dojo/middleware.py @@ -336,7 +336,7 @@ def install_intermediate_flush_hook(): original_add = cls.add_to_context def add_to_context_with_flush(self, engine, obj): - # Bulk ops under skip_index_update() (e.g. connector chunked reimport) set this to make the + # Bulk ops under skip_index_update() (e.g. a large chunked reimport) set this to make the # hook a full no-op on this thread. skip_index_update() only invalidates its context at # __exit__, so mid-accumulation the hook would otherwise drain (index) objects meant to be # discarded -- defeating the skip. Returning before original_add means nothing is even From 9cfa36736c0618c7d0ca3e93889ded6cda463628 Mon Sep 17 00:00:00 2001 From: dogboat Date: Thu, 2 Jul 2026 16:29:00 -0400 Subject: [PATCH 6/8] use option in defaultimporter as well --- dojo/importers/default_importer.py | 11 +++- dojo/importers/options.py | 2 + unittests/test_importers_importer.py | 85 ++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/dojo/importers/default_importer.py b/dojo/importers/default_importer.py index 6cdcb699024..b5cfe78d4d0 100644 --- a/dojo/importers/default_importer.py +++ b/dojo/importers/default_importer.py @@ -297,7 +297,10 @@ def _process_findings_internal( finding_ids_batch, dedupe_option=True, rules_option=True, - product_grading_option=True, + # Callers may defer grading to a single end-of-import pass + # (e.g. a large chunked import) to avoid one grade + # recalculation per dedupe batch; default keeps per-batch grading. + product_grading_option=not self.defer_product_grading, issue_updater_option=True, push_to_jira=push_to_jira, force_sync=kwargs.get("force_sync", False), @@ -324,7 +327,9 @@ def _process_findings_internal( # Note: All chord batching is now handled within the loop above # Always perform an initial grading, even though it might get overwritten later. - perform_product_grading(self.test.engagement.product) + # Callers may defer grading to a single end-of-import pass (see defer_product_grading). + if not self.defer_product_grading: + perform_product_grading(self.test.engagement.product) return new_findings @@ -428,7 +433,7 @@ def close_old_findings( jira_services.push(finding_group) # Calculate grade once after all findings have been closed - if old_findings: + if old_findings and not self.defer_product_grading: perform_product_grading(self.test.engagement.product) return old_findings diff --git a/dojo/importers/options.py b/dojo/importers/options.py index f9b590053a5..fb15f559a96 100644 --- a/dojo/importers/options.py +++ b/dojo/importers/options.py @@ -353,6 +353,8 @@ def validate_defer_product_grading( ) -> bool: # Defer per-batch/end/close-old product grade dispatches so callers (e.g. a large # chunked reimport) can grade once at the end instead of once per dedupe batch. + # Caveat: all-or-nothing -- when True the caller MUST run perform_product_grading() + # itself after the import; nothing re-grades automatically, so a stale grade goes unnoticed. return self.validate( "defer_product_grading", expected_types=[bool], diff --git a/unittests/test_importers_importer.py b/unittests/test_importers_importer.py index 3bb2f90a14d..a922ab4be39 100644 --- a/unittests/test_importers_importer.py +++ b/unittests/test_importers_importer.py @@ -91,6 +91,91 @@ def test_parse_findings(self): for finding in new_findings: self.assertIn(finding.numerical_severity, ["S0", "S1", "S2", "S3", "S4"]) + def _grading_import_options(self, scan_type, name_suffix, **overrides): + """Minimal DefaultImporter options for the product-grading-deferral tests.""" + user, _ = User.objects.get_or_create(username="admin") + product_type, _ = Product_Type.objects.get_or_create(name=f"grade-defer-pt-{name_suffix}") + product, _ = Product.objects.get_or_create( + name=f"grade-defer-prod-{name_suffix}", + description="test product", + prod_type=product_type, + ) + engagement, _ = Engagement.objects.get_or_create( + name=f"grade-defer-eng-{name_suffix}", + product=product, + target_start=timezone.now(), + target_end=timezone.now(), + ) + environment, _ = Development_Environment.objects.get_or_create(name="Development") + options = { + "user": user, + "lead": user, + "scan_date": None, + "environment": environment, + "minimum_severity": "Info", + "active": True, + "verified": True, + "scan_type": scan_type, + "engagement": engagement, + } + options.update(overrides) + return options + + def _process_findings_capturing_grading(self, importer, parsed_findings): + """Run process_findings with dispatch + grading patched; return (per_batch_options, grade_mock).""" + per_batch_options = [] + + def _spy(_task, *args, **kwargs): + per_batch_options.append(kwargs.get("product_grading_option")) + + with ( + patch("dojo.importers.default_importer.dojo_dispatch_task", side_effect=_spy), + patch("dojo.importers.default_importer.perform_product_grading") as grade, + ): + importer.process_findings(parsed_findings) + return per_batch_options, grade + + def test_defer_product_grading_skips_per_batch_and_end_grade(self): + """ + defer_product_grading=True makes DefaultImporter dispatch each dedupe batch with + product_grading_option=False and skip the end-of-import perform_product_grading pass, + leaving the caller to grade once. Mirrors DefaultReImporter's deferral. + """ + with ACUNETIX_AUDIT_ONE_VULN_FILENAME.open(encoding="utf-8") as scan: + scan_type = "Acunetix Scan" + importer = DefaultImporter(**self._grading_import_options(scan_type, "defer", defer_product_grading=True)) + test = importer.create_test(scan_type) + parsed_findings = importer.get_parser().get_findings(scan, test) + + per_batch_options, grade = self._process_findings_capturing_grading(importer, parsed_findings) + + self.assertTrue(per_batch_options, "at least one post-process batch should be dispatched") + self.assertTrue( + all(opt is False for opt in per_batch_options), + f"deferred import must dispatch product_grading_option=False, got {per_batch_options}", + ) + grade.assert_not_called() + + def test_default_import_grades_per_batch_and_at_end(self): + """ + Control for the deferral test: without defer_product_grading, DefaultImporter grades per + dedupe batch (product_grading_option=True) and once at the end, as it always has. + """ + with ACUNETIX_AUDIT_ONE_VULN_FILENAME.open(encoding="utf-8") as scan: + scan_type = "Acunetix Scan" + importer = DefaultImporter(**self._grading_import_options(scan_type, "default")) + test = importer.create_test(scan_type) + parsed_findings = importer.get_parser().get_findings(scan, test) + + per_batch_options, grade = self._process_findings_capturing_grading(importer, parsed_findings) + + self.assertTrue(per_batch_options, "at least one post-process batch should be dispatched") + self.assertTrue( + all(opt is True for opt in per_batch_options), + f"default import must dispatch product_grading_option=True, got {per_batch_options}", + ) + grade.assert_called_once() + def test_import_scan(self): with (get_unit_tests_path() / "scans" / "sarif" / "spotbugs.sarif").open(encoding="utf-8") as scan: scan_type = SarifParser().get_scan_types()[0] # SARIF format implement the new method From ee661e7361b39911143d03c1a198ab6a954213c6 Mon Sep 17 00:00:00 2001 From: dogboat Date: Thu, 2 Jul 2026 17:10:38 -0400 Subject: [PATCH 7/8] comment update --- dojo/importers/default_importer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dojo/importers/default_importer.py b/dojo/importers/default_importer.py index b5cfe78d4d0..88cce750ded 100644 --- a/dojo/importers/default_importer.py +++ b/dojo/importers/default_importer.py @@ -326,8 +326,8 @@ def _process_findings_internal( # Note: All chord batching is now handled within the loop above - # Always perform an initial grading, even though it might get overwritten later. - # Callers may defer grading to a single end-of-import pass (see defer_product_grading). + # Perform an initial grading (it may be overwritten later) unless the caller defers + # grading to a single end-of-import pass (see defer_product_grading). if not self.defer_product_grading: perform_product_grading(self.test.engagement.product) From 9484510f60d284029e38fea157d78205682d473d Mon Sep 17 00:00:00 2001 From: dogboat Date: Thu, 2 Jul 2026 17:44:59 -0400 Subject: [PATCH 8/8] add tests --- unittests/test_importers_closeold.py | 99 ++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/unittests/test_importers_closeold.py b/unittests/test_importers_closeold.py index d89f0d4153c..ac7d469b442 100644 --- a/unittests/test_importers_closeold.py +++ b/unittests/test_importers_closeold.py @@ -1,9 +1,11 @@ import logging +from unittest.mock import patch from django.utils import timezone import dojo.risk_acceptance.helper as ra_helper from dojo.importers.default_importer import DefaultImporter +from dojo.importers.default_reimporter import DefaultReImporter from dojo.models import Development_Environment, Engagement, Product, Product_Type, User from .dojo_test_case import DojoTestCase, get_unit_tests_scans_path @@ -59,6 +61,103 @@ def test_close_old_same_engagement(self): # If this behavior changes, or dedupe is on, the number of closed findings will be 4 self.assertEqual(8, len_closed_findings) + def test_defer_product_grading_skips_close_old_grade(self): + """ + With defer_product_grading=True, an import that closes old findings must skip the + close-old grade in close_old_findings (as well as the end-of-process grade), so the + importer never calls perform_product_grading -- the caller grades once itself. Pins the + close_old_findings deferral guard; the per-batch/end guards are covered in + test_importers_importer.py. + """ + scan_type = "Acunetix Scan" + user, _ = User.objects.get_or_create(username="admin") + product_type, _ = Product_Type.objects.get_or_create(name="closeold-defer") + environment, _ = Development_Environment.objects.get_or_create(name="Development") + product, _ = Product.objects.get_or_create( + name="TestDojoCloseOldDefer", + description="Test", + prod_type=product_type, + ) + engagement, _ = Engagement.objects.get_or_create( + name="Close Old Defer Grading", + product=product, + target_start=timezone.now(), + target_end=timezone.now(), + ) + import_options = { + "user": user, + "lead": user, + "scan_date": None, + "environment": environment, + "active": True, + "verified": False, + "engagement": engagement, + "scan_type": scan_type, + } + # Seed the engagement with several findings (no close-old on this first import). + with (get_unit_tests_scans_path("acunetix") / "many_findings.xml").open(encoding="utf-8") as many_findings_scan: + DefaultImporter(close_old_findings=False, **import_options).process_scan(many_findings_scan) + # Import a single finding with close-old + deferral: the other findings close, but the + # importer must not grade (neither the end pass nor the close-old pass). perform_product_grading + # is only referenced by those two direct call sites in default_importer, so a call here would + # mean a guard regressed. + with (get_unit_tests_scans_path("acunetix") / "one_finding.xml").open(encoding="utf-8") as single_finding_scan: + importer = DefaultImporter(close_old_findings=True, defer_product_grading=True, **import_options) + with patch("dojo.importers.default_importer.perform_product_grading") as grade: + _, _, _, len_closed_findings, _, _, _ = importer.process_scan(single_finding_scan) + self.assertGreater(len_closed_findings, 0, "old findings must actually close, or the close-old guard isn't exercised") + grade.assert_not_called() + + def test_reimport_defer_product_grading_skips_close_old_and_end_grade(self): + """ + Reimport counterpart to test_defer_product_grading_skips_close_old_grade: with + defer_product_grading=True, a reimport that mitigates old findings must skip both the + end-of-process grade and the close-old grade in DefaultReImporter, so the reimporter never + calls perform_product_grading -- the caller grades once. Pins the reimporter's end and + close-old deferral guards (its per-batch guard is covered by the Pro connectors tests). + """ + scan_type = "Acunetix Scan" + user, _ = User.objects.get_or_create(username="admin") + product_type, _ = Product_Type.objects.get_or_create(name="closeold-defer-reimport") + environment, _ = Development_Environment.objects.get_or_create(name="Development") + product, _ = Product.objects.get_or_create( + name="TestDojoCloseOldDeferReimport", + description="Test", + prod_type=product_type, + ) + engagement, _ = Engagement.objects.get_or_create( + name="Close Old Defer Grading Reimport", + product=product, + target_start=timezone.now(), + target_end=timezone.now(), + ) + common = { + "user": user, + "lead": user, + "scan_date": None, + "environment": environment, + "active": True, + "verified": False, + "scan_type": scan_type, + } + # Seed a test with several findings via an initial import. + with (get_unit_tests_scans_path("acunetix") / "many_findings.xml").open(encoding="utf-8") as many_findings_scan: + importer = DefaultImporter(engagement=engagement, close_old_findings=False, **common) + test, _, _, _, _, _, _ = importer.process_scan(many_findings_scan) + # Reimport a single finding with close-old + deferral: the absent findings mitigate, but the + # reimporter must not grade. perform_product_grading is only referenced by the end and + # close-old call sites in default_reimporter, so a call here means a guard regressed. + with (get_unit_tests_scans_path("acunetix") / "one_finding.xml").open(encoding="utf-8") as single_finding_scan: + reimporter = DefaultReImporter(test=test, close_old_findings=True, defer_product_grading=True, **common) + with patch("dojo.importers.default_reimporter.perform_product_grading") as grade: + reimporter.process_scan(single_finding_scan) + self.assertGreater( + test.finding_set.filter(is_mitigated=True).count(), + 0, + "old findings must actually mitigate, or the close-old guard isn't exercised", + ) + grade.assert_not_called() + def test_close_old_same_product_scan(self): scan_type = "Acunetix Scan" user, _ = User.objects.get_or_create(username="admin")