@@ -59,6 +59,92 @@ def test_discover_builtin_scenarios(self):
5959 assert scenario_class is not None
6060 assert issubclass (scenario_class , Scenario )
6161
62+ def test_discover_builtin_scenarios_correct_module_paths (self ):
63+ """Test that builtin scenario discovery uses correct module paths without duplication.
64+
65+ This is a regression test for a bug where module paths were incorrectly constructed
66+ as 'pyrit.scenario.scenarios.pyrit.scenarios.scenarios.xxx' instead of
67+ 'pyrit.scenario.scenarios.xxx'.
68+ """
69+ with patch ("pyrit.cli.scenario_registry.importlib.import_module" ) as mock_import :
70+ with patch ("pyrit.cli.scenario_registry.pkgutil.iter_modules" ) as mock_iter :
71+ with patch ("pyrit.cli.scenario_registry.inspect.getmembers" ) as mock_getmembers :
72+ # Mock the scenarios package structure
73+ def iter_modules_side_effect (paths ):
74+ path_str = str (paths [0 ]) if paths else ""
75+ if "airt" in path_str :
76+ # Mock the airt subpackage
77+ return [
78+ (None , "content_harms_scenario" , False ),
79+ (None , "cyber_scenario" , False ),
80+ ]
81+ else :
82+ # Mock the main scenarios package
83+ return [
84+ (None , "encoding_scenario" , False ), # A file module
85+ (None , "foundry_scenario" , False ), # Another file module
86+ (None , "airt" , True ), # A package (subdirectory)
87+ ]
88+
89+ mock_iter .side_effect = iter_modules_side_effect
90+
91+ # Create mock module with Scenario subclass
92+ mock_scenario_class = type (
93+ "TestScenario" ,
94+ (Scenario ,),
95+ {
96+ "_get_atomic_attacks_async" : lambda self : [],
97+ "get_strategy_class" : classmethod (lambda cls : MockStrategy ),
98+ "get_default_strategy" : classmethod (lambda cls : MockStrategy .ALL ),
99+ },
100+ )
101+
102+ # Mock getmembers to return our test scenario
103+ mock_getmembers .return_value = [("TestScenario" , mock_scenario_class )]
104+
105+ # Mock import_module to return a simple module object
106+ def import_side_effect (module_name ):
107+ mock_mod = MagicMock ()
108+ mock_mod .__name__ = module_name
109+ return mock_mod
110+
111+ mock_import .side_effect = import_side_effect
112+
113+ registry = ScenarioRegistry ()
114+ registry ._discover_builtin_scenarios ()
115+
116+ # Verify the correct module paths were attempted
117+ import_calls = [call [0 ][0 ] for call in mock_import .call_args_list ]
118+
119+ # Filter to only scenario module imports (not the base package imports)
120+ # We expect imports like:
121+ # - pyrit.scenario.scenarios.encoding_scenario
122+ # - pyrit.scenario.scenarios.foundry_scenario
123+ # - pyrit.scenario.scenarios.airt.content_harms_scenario
124+ # - pyrit.scenario.scenarios.airt.cyber_scenario
125+ scenario_module_imports = [
126+ call
127+ for call in import_calls
128+ if call .startswith ("pyrit.scenario.scenarios." ) and call != "pyrit.scenario.scenarios"
129+ ]
130+
131+ # Should NOT see duplicated paths like:
132+ # - pyrit.scenario.scenarios.pyrit.scenarios.scenarios.xxx
133+
134+ assert len (scenario_module_imports ) > 0 , "No scenario modules were imported"
135+
136+ for call_path in scenario_module_imports :
137+ # Verify no path duplication
138+ assert (
139+ "pyrit.scenario.scenarios.pyrit" not in call_path
140+ ), f"Module path has duplication: { call_path } "
141+ # Verify 'scenarios' appears exactly once (not duplicated)
142+ assert call_path .count ("scenarios" ) == 1 , f"Module path has 'scenarios' duplicated: { call_path } "
143+ # Verify correct base path
144+ assert call_path .startswith (
145+ "pyrit.scenario.scenarios."
146+ ), f"Module path doesn't start with correct base: { call_path } "
147+
62148 def test_get_scenario_existing (self ):
63149 """Test getting an existing scenario."""
64150 registry = ScenarioRegistry ()
0 commit comments