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