1818 AttackSeedGroup ,
1919 ComponentIdentifier ,
2020 Message ,
21+ ScenarioRunState ,
2122 SeedObjective ,
2223 config_hash ,
2324)
@@ -484,6 +485,70 @@ async def mock_run_with_logged_failure(*args, **kwargs):
484485class TestScenarioResumption :
485486 """Tests for Scenario resumption after partial failure."""
486487
488+ @pytest .mark .parametrize ("persistent_failure" , [False , True ], ids = ["transient-read" , "persistent-read" ])
489+ async def test_resume_read_failure_never_reexecutes_completed_objectives (
490+ self , mock_objective_target , persistent_failure
491+ ):
492+ completed = create_mock_atomic_attack ("completed" , ["objective1" ])
493+ pending = create_mock_atomic_attack ("pending" , ["objective2" ])
494+ completed .run_async = create_mock_run_async ([create_attack_result (1 )], atomic_attack = completed )
495+ pending .run_async = create_mock_run_async ([create_attack_result (2 )], atomic_attack = pending )
496+ scenario = ConcreteScenario (
497+ name = "Resume Read Failure" , version = 1 , atomic_attacks_to_return = [completed , pending ]
498+ )
499+ scenario .set_params_from_args (args = {"objective_target" : mock_objective_target , "max_retries" : 1 })
500+ await scenario .initialize_async ()
501+ completed .set_scenario_result_id (scenario ._scenario_result_id )
502+ save_attack_results_to_memory ([create_attack_result (1 )], atomic_attack = completed )
503+
504+ memory = CentralMemory .get_memory_instance ()
505+ original_get_results = memory .get_attack_results
506+ reads = 0
507+
508+ def read_results (** kwargs ):
509+ nonlocal reads
510+ reads += 1
511+ if persistent_failure or reads == 1 :
512+ raise RuntimeError ("resume storage unavailable" )
513+ return original_get_results (** kwargs )
514+
515+ with patch .object (memory , "get_attack_results" , side_effect = read_results ):
516+ if persistent_failure :
517+ with pytest .raises (RuntimeError , match = "resume storage unavailable" ):
518+ await scenario .run_async ()
519+ else :
520+ await scenario .run_async ()
521+
522+ completed .run_async .assert_not_called ()
523+ assert pending .run_async .call_count == (0 if persistent_failure else 1 )
524+ assert reads == 2
525+ [stored ] = memory .get_scenario_results (scenario_result_ids = [scenario ._scenario_result_id ])
526+ assert stored .number_tries == 2
527+ assert stored .scenario_run_state == (
528+ ScenarioRunState .FAILED if persistent_failure else ScenarioRunState .COMPLETED
529+ )
530+ assert len (stored .attack_results ["completed" ]) == 1
531+ if persistent_failure :
532+ assert completed .objectives == ["objective1" ]
533+ assert pending .objectives == ["objective2" ]
534+ assert stored .error_message == "resume storage unavailable"
535+
536+ async def test_resume_loads_one_result_snapshot_for_all_atomic_attacks (self , mock_objective_target ):
537+ attacks = [create_mock_atomic_attack (f"attack_{ i } " , [f"objective{ i } " ]) for i in range (10 )]
538+ scenario = ConcreteScenario (name = "Resume Snapshot" , version = 1 , atomic_attacks_to_return = attacks )
539+ scenario .set_params_from_args (args = {"objective_target" : mock_objective_target })
540+ await scenario .initialize_async ()
541+ for i , attack in enumerate (attacks [:5 ]):
542+ attack .set_scenario_result_id (scenario ._scenario_result_id )
543+ save_attack_results_to_memory ([create_attack_result (i )], atomic_attack = attack )
544+
545+ memory = CentralMemory .get_memory_instance ()
546+ with patch .object (memory , "get_attack_results" , wraps = memory .get_attack_results ) as read_results :
547+ remaining = await scenario ._get_remaining_atomic_attacks_async ()
548+
549+ assert remaining == attacks [5 :]
550+ read_results .assert_called_once_with (scenario_result_id = scenario ._scenario_result_id )
551+
487552 async def test_parameter_build_partial_result_persists_linkage_before_retry (
488553 self ,
489554 mock_objective_target : MagicMock ,
@@ -858,8 +923,8 @@ async def noop_run(*args, **kwargs):
858923
859924
860925@pytest .mark .usefixtures ("patch_central_database" )
861- class TestGetCompletedObjectiveHashesForAttack :
862- """Direct tests for ``Scenario._get_completed_objective_hashes_for_attack ``
926+ class TestGetCompletedObjectiveHashesByAttack :
927+ """Direct tests for ``Scenario._get_completed_objective_hashes_by_attack ``
863928 — the filter that excludes already-completed objectives on resume.
864929
865930 Covers the row-filtering branches: outcome=ERROR rows, rows without
@@ -873,12 +938,6 @@ def _make_scenario(self, scenario_result_id="scn-1"):
873938 scenario ._memory = MagicMock ()
874939 return scenario
875940
876- def _make_atomic (self , name , eval_hash = "hash-A" ):
877- atomic = MagicMock (spec = AtomicAttack )
878- atomic .atomic_attack_name = name
879- type(atomic ).technique_eval_hash = PropertyMock (return_value = eval_hash )
880- return atomic
881-
882941 def _row (self , * , objective , outcome = AttackOutcome .SUCCESS , attribution_data = None ):
883942 row = MagicMock ()
884943 row .outcome = outcome
@@ -889,10 +948,8 @@ def _row(self, *, objective, outcome=AttackOutcome.SUCCESS, attribution_data=Non
889948 def test_returns_empty_when_scenario_result_id_unset (self ):
890949 scenario = ConcreteScenario (name = "S" , version = 1 , atomic_attacks_to_return = [])
891950 scenario ._scenario_result_id = None
892- result = scenario ._get_completed_objective_hashes_for_attack (
893- atomic_attack = self ._make_atomic ("a" ),
894- )
895- assert result == set ()
951+ result = scenario ._get_completed_objective_hashes_by_attack ()
952+ assert result == {}
896953
897954 def test_skips_error_rows (self ):
898955 from pyrit .common .utils import to_sha256
@@ -910,10 +967,8 @@ def test_skips_error_rows(self):
910967 attribution_data = {"parent_collection" : "a" , "parent_eval_hash" : "hash-A" },
911968 ),
912969 ]
913- result = scenario ._get_completed_objective_hashes_for_attack (
914- atomic_attack = self ._make_atomic ("a" ),
915- )
916- assert result == {to_sha256 ("ok" )}
970+ result = scenario ._get_completed_objective_hashes_by_attack ()
971+ assert result == {("a" , "hash-A" ): {to_sha256 ("ok" )}}
917972
918973 def test_skips_rows_without_attribution_data (self ):
919974 from pyrit .common .utils import to_sha256
@@ -926,12 +981,10 @@ def test_skips_rows_without_attribution_data(self):
926981 attribution_data = {"parent_collection" : "a" , "parent_eval_hash" : "hash-A" },
927982 ),
928983 ]
929- result = scenario ._get_completed_objective_hashes_for_attack (
930- atomic_attack = self ._make_atomic ("a" ),
931- )
932- assert result == {to_sha256 ("new" )}
984+ result = scenario ._get_completed_objective_hashes_by_attack ()
985+ assert result == {("a" , "hash-A" ): {to_sha256 ("new" )}}
933986
934- def test_skips_rows_with_mismatched_eval_hash (self ):
987+ def test_indexes_same_name_techniques_separately (self ):
935988 """Two atomic attacks with the same name but different techniques
936989 must not cross-pollinate completed hashes. This is the core Option-B
937990 guarantee."""
@@ -948,10 +1001,11 @@ def test_skips_rows_with_mismatched_eval_hash(self):
9481001 attribution_data = {"parent_collection" : "encoding" , "parent_eval_hash" : "hash-hex" },
9491002 ),
9501003 ]
951- result = scenario ._get_completed_objective_hashes_for_attack (
952- atomic_attack = self ._make_atomic ("encoding" , eval_hash = "hash-base64" ),
953- )
954- assert result == {to_sha256 ("mine" )}
1004+ result = scenario ._get_completed_objective_hashes_by_attack ()
1005+ assert result == {
1006+ ("encoding" , "hash-base64" ): {to_sha256 ("mine" )},
1007+ ("encoding" , "hash-hex" ): {to_sha256 ("theirs" )},
1008+ }
9551009
9561010 def test_backward_compat_matches_name_only_when_eval_hash_missing (self ):
9571011 """Rows persisted before ``parent_eval_hash`` shipped match name-only
@@ -965,10 +1019,8 @@ def test_backward_compat_matches_name_only_when_eval_hash_missing(self):
9651019 attribution_data = {"parent_collection" : "a" }, # no parent_eval_hash
9661020 ),
9671021 ]
968- result = scenario ._get_completed_objective_hashes_for_attack (
969- atomic_attack = self ._make_atomic ("a" , eval_hash = "hash-A" ),
970- )
971- assert result == {to_sha256 ("old" )}
1022+ result = scenario ._get_completed_objective_hashes_by_attack ()
1023+ assert result == {("a" , None ): {to_sha256 ("old" )}}
9721024
9731025
9741026@pytest .mark .usefixtures ("patch_central_database" )
0 commit comments