Skip to content

Commit 85f6050

Browse files
Maffoochclaude
andcommitted
test(dedupe): add black-box reimport order-independence regression guards
Add behavior-level regression tests that drive only the public reimport API (DefaultReImporter.process_scan with force_sync) and assert only on observable Finding state, so they keep protecting the customer-visible dedupe contract even if the import/reimport machinery is rewritten: - surviving active original is export-order independent (unique_id_from_tool) - same, under unique_id_from_tool_or_hash_code (combined matching path) - re-scanning the same report keeps the same surviving original (stability) The survivor is asserted as the single active, non-duplicate finding rather than by row count, so an implementation that keeps the loser as an inactive duplicate row instead of collapsing it away still passes. All three fail if the reimporter stops creating findings in a content-stable order (the flip this PR fixes); the re-scan-stability guard holds independently. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f49f62 commit 85f6050

1 file changed

Lines changed: 136 additions & 0 deletions

File tree

unittests/test_deduplication_logic.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,142 @@ def test_fortify_same_uid_different_content_original_is_order_independent(self):
737737
self.assertEqual(original_scan1.line, original_scan2.line)
738738
self.assertEqual(original_scan1.title, original_scan2.title)
739739

740+
# ------------------------------------------------------------------
741+
# Black-box behavior guards for the import/reimport dedupe baseline.
742+
#
743+
# These drive ONLY the public reimport API (DefaultReImporter.process_scan
744+
# with force_sync so post-processing runs inline) and assert ONLY on observable
745+
# Finding state (which finding survives as the active original). They import no
746+
# deduplication internals and make no claim about HOW the behavior is achieved,
747+
# so they keep protecting the user-visible contract even if the import/reimport
748+
# machinery is rewritten. If a rewrite regresses the baseline, these go red.
749+
# ------------------------------------------------------------------
750+
751+
def _reimport_fortify_repro(self, fpr_filename, engagement_name):
752+
"""
753+
Reimport one Fortify repro .fpr through the real reimporter into its own
754+
isolated engagement and return every Finding in the resulting test
755+
(ordered by id). Uses force_sync so deduplication has completed by the
756+
time process_scan returns; no dedupe internals are touched.
757+
"""
758+
product_type, _ = Product_Type.objects.get_or_create(name="Fortify UID Repro PT")
759+
product, _ = Product.objects.get_or_create(
760+
name="Fortify UID Repro Product",
761+
defaults={"description": "Fortify UID dedupe repro", "prod_type": product_type},
762+
)
763+
engagement = Engagement.objects.create(
764+
name=engagement_name,
765+
product=product,
766+
target_start=timezone.now().date(),
767+
target_end=timezone.now().date(),
768+
deduplication_on_engagement=True, # isolate each scan's dedupe scope
769+
)
770+
test_type, _ = Test_Type.objects.get_or_create(name="Fortify Scan")
771+
test = Test.objects.create(
772+
engagement=engagement,
773+
test_type=test_type,
774+
scan_type="Fortify Scan",
775+
target_start=timezone.now(),
776+
target_end=timezone.now(),
777+
)
778+
admin = User.objects.get(username="admin")
779+
with (get_unit_tests_scans_path("fortify") / fpr_filename).open(encoding="utf-8") as scan:
780+
reimporter = DefaultReImporter(
781+
test=test,
782+
user=admin,
783+
lead=admin,
784+
scan_date=None,
785+
minimum_severity="Info",
786+
active=True,
787+
verified=True,
788+
force_sync=True,
789+
scan_type="Fortify Scan",
790+
)
791+
reimporter.process_scan(scan)
792+
return test, list(Finding.objects.filter(test=test).order_by("id"))
793+
794+
def _assert_single_surviving_original(self, findings):
795+
"""
796+
The colliding pair must resolve to exactly one active, non-duplicate finding.
797+
798+
We assert on the observable survivor (active and not a duplicate) rather than
799+
the total row count: whether the loser is collapsed away during reimport
800+
matching or kept as an inactive duplicate row is an implementation detail, but
801+
either way the user must see exactly one active original.
802+
"""
803+
originals = [f for f in findings if f.active and not f.duplicate]
804+
self.assertEqual(
805+
len(originals), 1,
806+
f"expected exactly one active original, got {[(f.id, f.active, f.duplicate, f.line) for f in findings]}",
807+
)
808+
return originals[0]
809+
810+
@override_settings(DEDUPLICATION_ALGORITHM_PER_PARSER={"Fortify Scan": settings.DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL})
811+
def test_reimport_surviving_original_is_export_order_independent_uid(self):
812+
# BASELINE: two scans contain the same pair of findings (shared
813+
# unique_id_from_tool, different content) in opposite document order.
814+
# After reimport, the finding that survives as the active original must be
815+
# the SAME content regardless of the scanner's export order. This is the
816+
# customer-reported contract; pre-fix the surviving finding flips with order.
817+
_, findings1 = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan1.fpr", "fortify-uid-behavior-1")
818+
_, findings2 = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan2.fpr", "fortify-uid-behavior-2")
819+
original1 = self._assert_single_surviving_original(findings1)
820+
original2 = self._assert_single_surviving_original(findings2)
821+
822+
self.assertEqual(original1.unique_id_from_tool, original2.unique_id_from_tool)
823+
self.assertEqual(
824+
(original1.title, original1.line, original1.hash_code),
825+
(original2.title, original2.line, original2.hash_code),
826+
"surviving original differs between scan orders — dedupe is export-order dependent",
827+
)
828+
829+
@override_settings(DEDUPLICATION_ALGORITHM_PER_PARSER={"Fortify Scan": settings.DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE})
830+
def test_reimport_surviving_original_is_export_order_independent_uid_or_hash(self):
831+
# BASELINE (combined algorithm): same repro under unique_id_from_tool_or_hash_code.
832+
# The pair collides through the shared unique_id_from_tool (their content, and
833+
# thus hash_code, differ), so this exercises the combined UID/hash matching path.
834+
# The surviving active original must still be content-stable across scan orders.
835+
_, findings1 = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan1.fpr", "fortify-uidhash-behavior-1")
836+
_, findings2 = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan2.fpr", "fortify-uidhash-behavior-2")
837+
original1 = self._assert_single_surviving_original(findings1)
838+
original2 = self._assert_single_surviving_original(findings2)
839+
840+
self.assertEqual(original1.unique_id_from_tool, original2.unique_id_from_tool)
841+
self.assertEqual(
842+
(original1.title, original1.line, original1.hash_code),
843+
(original2.title, original2.line, original2.hash_code),
844+
"surviving original differs between scan orders under uid_or_hash",
845+
)
846+
847+
@override_settings(DEDUPLICATION_ALGORITHM_PER_PARSER={"Fortify Scan": settings.DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL})
848+
def test_reimport_same_report_twice_keeps_the_same_surviving_original(self):
849+
# BASELINE (re-scan stability): re-scanning the same content must not flip the
850+
# original/duplicate assignment. Reimport the repro once, note the surviving
851+
# active original, then reimport the SAME file again into the same test and
852+
# assert the surviving original is unchanged (same finding id and content, still
853+
# exactly one active original). No new duplicate chain is spun up on re-scan.
854+
test, findings = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan1.fpr", "fortify-uid-rescan")
855+
original_first = self._assert_single_surviving_original(findings)
856+
857+
admin = User.objects.get(username="admin")
858+
with (get_unit_tests_scans_path("fortify") / "fortify_dedupe_uid_repro_scan1.fpr").open(encoding="utf-8") as scan:
859+
reimporter = DefaultReImporter(
860+
test=test, user=admin, lead=admin, scan_date=None, minimum_severity="Info",
861+
active=True, verified=True, force_sync=True, scan_type="Fortify Scan",
862+
)
863+
reimporter.process_scan(scan)
864+
865+
findings_after = list(Finding.objects.filter(test=test).order_by("id"))
866+
original_after = self._assert_single_surviving_original(findings_after)
867+
self.assertEqual(
868+
original_after.id, original_first.id,
869+
"re-scanning the same report flipped which finding is the surviving original",
870+
)
871+
self.assertEqual(
872+
(original_after.title, original_after.line, original_after.hash_code),
873+
(original_first.title, original_first.line, original_first.hash_code),
874+
)
875+
740876
def test_identical_except_title_hash_code(self):
741877
# 4 is already a duplicate of 2, let's see what happens if we create an identical finding with different title (and reset status)
742878
# expect: NOT marked as duplicate as title is part of hash_code calculation

0 commit comments

Comments
 (0)