scythe-ttp 0.17.2__tar.gz → 0.17.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of scythe-ttp might be problematic. Click here for more details.

Files changed (61) hide show
  1. {scythe_ttp-0.17.2/scythe_ttp.egg-info → scythe_ttp-0.17.4}/PKG-INFO +1 -1
  2. scythe_ttp-0.17.4/VERSION +1 -0
  3. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/auth/cookie_jwt.py +12 -5
  4. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/core/executor.py +116 -2
  5. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4/scythe_ttp.egg-info}/PKG-INFO +1 -1
  6. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe_ttp.egg-info/SOURCES.txt +1 -0
  7. scythe_ttp-0.17.4/tests/test_executor_modes.py +481 -0
  8. scythe_ttp-0.17.2/VERSION +0 -1
  9. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/LICENSE +0 -0
  10. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/MANIFEST.in +0 -0
  11. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/README.md +0 -0
  12. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/pyproject.toml +0 -0
  13. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/requirements.txt +0 -0
  14. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/__init__.py +0 -0
  15. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/auth/__init__.py +0 -0
  16. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/auth/base.py +0 -0
  17. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/auth/basic.py +0 -0
  18. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/auth/bearer.py +0 -0
  19. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/behaviors/__init__.py +0 -0
  20. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/behaviors/base.py +0 -0
  21. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/behaviors/default.py +0 -0
  22. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/behaviors/human.py +0 -0
  23. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/behaviors/machine.py +0 -0
  24. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/behaviors/stealth.py +0 -0
  25. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/cli/__init__.py +0 -0
  26. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/cli/main.py +0 -0
  27. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/core/__init__.py +0 -0
  28. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/core/headers.py +0 -0
  29. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/core/ttp.py +0 -0
  30. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/journeys/__init__.py +0 -0
  31. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/journeys/actions.py +0 -0
  32. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/journeys/base.py +0 -0
  33. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/journeys/executor.py +0 -0
  34. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/orchestrators/__init__.py +0 -0
  35. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/orchestrators/base.py +0 -0
  36. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/orchestrators/batch.py +0 -0
  37. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/orchestrators/distributed.py +0 -0
  38. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/orchestrators/scale.py +0 -0
  39. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/payloads/__init__.py +0 -0
  40. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/payloads/generators.py +0 -0
  41. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/ttps/__init__.py +0 -0
  42. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/ttps/web/__init__.py +0 -0
  43. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/ttps/web/login_bruteforce.py +0 -0
  44. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/ttps/web/sql_injection.py +0 -0
  45. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe/ttps/web/uuid_guessing.py +0 -0
  46. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe_ttp.egg-info/dependency_links.txt +0 -0
  47. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe_ttp.egg-info/entry_points.txt +0 -0
  48. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe_ttp.egg-info/requires.txt +0 -0
  49. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/scythe_ttp.egg-info/top_level.txt +0 -0
  50. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/setup.cfg +0 -0
  51. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_api_models.py +0 -0
  52. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_authentication.py +0 -0
  53. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_behaviors.py +0 -0
  54. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_cli.py +0 -0
  55. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_cookie_jwt_auth.py +0 -0
  56. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_expected_results.py +0 -0
  57. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_feature_completeness.py +0 -0
  58. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_header_extraction.py +0 -0
  59. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_journeys.py +0 -0
  60. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_orchestrators.py +0 -0
  61. {scythe_ttp-0.17.2 → scythe_ttp-0.17.4}/tests/test_ttp_api_mode.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scythe-ttp
3
- Version: 0.17.2
3
+ Version: 0.17.4
4
4
  Summary: An extensible framework for emulating attacker TTPs with Selenium.
5
5
  Author-email: EpykLab <cyber@epyklab.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -0,0 +1 @@
1
+ 0.17.4
@@ -53,6 +53,10 @@ class CookieJWTAuth(Authentication):
53
53
  token via jwt_json_path, and return {cookie_name: token}.
54
54
  - In UI mode: authenticate() will ensure the browser has the cookie set for
55
55
  the target domain.
56
+
57
+ Parameters:
58
+ - content_type: Either "json" (default) to send payload as JSON, or "form"
59
+ to send as application/x-www-form-urlencoded form data.
56
60
  """
57
61
 
58
62
  def __init__(self,
@@ -64,6 +68,7 @@ class CookieJWTAuth(Authentication):
64
68
  extra_fields: Optional[Dict[str, Any]] = None,
65
69
  jwt_json_path: str = "token",
66
70
  cookie_name: str = "stellarbridge",
71
+ content_type: str = "json",
67
72
  session: Optional[requests.Session] = None,
68
73
  description: str = "Authenticate via API and set JWT cookie"):
69
74
  super().__init__(
@@ -78,18 +83,20 @@ class CookieJWTAuth(Authentication):
78
83
  self.extra_fields = extra_fields or {}
79
84
  self.jwt_json_path = jwt_json_path
80
85
  self.cookie_name = cookie_name
86
+ self.content_type = content_type
81
87
  # Avoid importing requests in test environments; allow injected session
82
88
  self._session = session or (requests.Session() if requests is not None else None)
83
89
  self.token: Optional[str] = None
84
90
 
85
91
  def _login_and_get_token(self) -> str:
86
92
  payload: Dict[str, Any] = dict(self.extra_fields)
87
- if self.username is not None:
88
- payload[self.username_field] = self.username
89
- if self.password is not None:
90
- payload[self.password_field] = self.password
93
+ payload[self.username_field] = self.username
94
+ payload[self.password_field] = self.password
91
95
  try:
92
- resp = self._session.post(self.login_url, json=payload, timeout=15)
96
+ if self.content_type == "form":
97
+ resp = self._session.post(self.login_url, data=payload, timeout=15)
98
+ else:
99
+ resp = self._session.post(self.login_url, json=payload, timeout=15)
93
100
  # try json; raise on non-2xx to surface errors
94
101
  resp.raise_for_status()
95
102
  data = resp.json()
@@ -3,9 +3,10 @@ import logging
3
3
  from selenium import webdriver
4
4
  from selenium.webdriver.chrome.options import Options
5
5
  from .ttp import TTP
6
- from typing import Optional
6
+ from typing import Optional, Dict, Any
7
7
  from ..behaviors.base import Behavior
8
8
  from .headers import HeaderExtractor
9
+ import requests
9
10
 
10
11
  # Configure logging
11
12
  logging.basicConfig(
@@ -60,7 +61,18 @@ class TTPExecutor:
60
61
  self.logger.info(f"Using behavior: {self.behavior.name}")
61
62
  self.logger.info(f"Behavior description: {self.behavior.description}")
62
63
 
63
- self._setup_driver()
64
+ # Check execution mode
65
+ if self.ttp.execution_mode == 'api':
66
+ self.logger.info("Execution mode: API")
67
+ self._run_api_mode()
68
+ return
69
+ else:
70
+ self.logger.info("Execution mode: UI")
71
+ self._setup_driver()
72
+ self._run_ui_mode()
73
+
74
+ def _run_ui_mode(self):
75
+ """Execute TTP in UI mode using Selenium."""
64
76
 
65
77
  try:
66
78
  # Handle authentication if required
@@ -172,6 +184,108 @@ class TTPExecutor:
172
184
  finally:
173
185
  self._cleanup()
174
186
 
187
+ def _run_api_mode(self):
188
+ """Execute TTP in API mode using requests."""
189
+ session = requests.Session()
190
+ context: Dict[str, Any] = {
191
+ 'target_url': self.target_url,
192
+ 'auth_headers': {},
193
+ 'rate_limit_resume_at': None
194
+ }
195
+
196
+ try:
197
+ # Handle authentication if required (API mode)
198
+ if self.ttp.requires_authentication():
199
+ auth_name = self.ttp.authentication.name if self.ttp.authentication else "Unknown"
200
+ self.logger.info(f"Authentication required for TTP: {auth_name}")
201
+
202
+ # Try to get auth headers directly
203
+ try:
204
+ if hasattr(self.ttp.authentication, 'get_auth_headers'):
205
+ auth_headers = self.ttp.authentication.get_auth_headers() or {}
206
+ context['auth_headers'] = auth_headers
207
+ session.headers.update(auth_headers)
208
+ self.logger.info("Authentication headers applied")
209
+ except Exception as e:
210
+ self.logger.warning(f"Failed to get auth headers: {e}")
211
+
212
+ consecutive_failures = 0
213
+
214
+ for i, payload in enumerate(self.ttp.get_payloads(), 1):
215
+ # Check if behavior wants to continue
216
+ if self.behavior and not self.behavior.should_continue(i, consecutive_failures):
217
+ self.logger.info("Behavior requested to stop execution")
218
+ break
219
+
220
+ self.logger.info(f"Attempt {i}: Executing with payload -> '{payload}'")
221
+
222
+ try:
223
+ # Execute API request
224
+ response = self.ttp.execute_step_api(session, payload, context)
225
+
226
+ # Use behavior delay if available, otherwise use default
227
+ if self.behavior:
228
+ step_delay = self.behavior.get_step_delay(i)
229
+ else:
230
+ step_delay = self.delay
231
+
232
+ time.sleep(step_delay)
233
+
234
+ # Verify result
235
+ success = self.ttp.verify_result_api(response, context)
236
+
237
+ # Compare actual result with expected result
238
+ if success:
239
+ consecutive_failures = 0
240
+
241
+ # Extract target version from response headers
242
+ target_version = response.headers.get('X-SCYTHE-TARGET-VERSION') or response.headers.get('x-scythe-target-version')
243
+
244
+ result_entry = {
245
+ 'payload': payload,
246
+ 'url': response.url if hasattr(response, 'url') else self.target_url,
247
+ 'expected': self.ttp.expected_result,
248
+ 'actual': True,
249
+ 'target_version': target_version
250
+ }
251
+ self.results.append(result_entry)
252
+
253
+ if self.ttp.expected_result:
254
+ version_info = f" | Version: {target_version}" if target_version else ""
255
+ self.logger.info(f"EXPECTED SUCCESS: '{payload}'{version_info}")
256
+ else:
257
+ version_info = f" | Version: {target_version}" if target_version else ""
258
+ self.logger.warning(f"UNEXPECTED SUCCESS: '{payload}' (expected to fail){version_info}")
259
+ self.has_test_failures = True
260
+ else:
261
+ consecutive_failures += 1
262
+ if self.ttp.expected_result:
263
+ self.logger.info(f"EXPECTED FAILURE: '{payload}' (security control working)")
264
+ self.has_test_failures = True
265
+ else:
266
+ self.logger.info(f"EXPECTED FAILURE: '{payload}'")
267
+
268
+ except Exception as step_error:
269
+ consecutive_failures += 1
270
+ self.logger.error(f"Error during step {i}: {step_error}")
271
+
272
+ # Let behavior handle the error
273
+ if self.behavior:
274
+ if not self.behavior.on_error(step_error, i):
275
+ self.logger.info("Behavior requested to stop due to error")
276
+ break
277
+ else:
278
+ # Default behavior: continue on most errors
279
+ continue
280
+
281
+ except KeyboardInterrupt:
282
+ self.logger.info("Test interrupted by user.")
283
+ except Exception as e:
284
+ self.logger.error(f"An unexpected error occurred: {e}", exc_info=True)
285
+ finally:
286
+ session.close()
287
+ self._cleanup()
288
+
175
289
  def _cleanup(self):
176
290
  """Closes the WebDriver and prints a summary."""
177
291
  if self.driver:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scythe-ttp
3
- Version: 0.17.2
3
+ Version: 0.17.4
4
4
  Summary: An extensible framework for emulating attacker TTPs with Selenium.
5
5
  Author-email: EpykLab <cyber@epyklab.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -49,6 +49,7 @@ tests/test_authentication.py
49
49
  tests/test_behaviors.py
50
50
  tests/test_cli.py
51
51
  tests/test_cookie_jwt_auth.py
52
+ tests/test_executor_modes.py
52
53
  tests/test_expected_results.py
53
54
  tests/test_feature_completeness.py
54
55
  tests/test_header_extraction.py
@@ -0,0 +1,481 @@
1
+ """
2
+ Comprehensive tests for TTPExecutor in both UI and API modes.
3
+
4
+ This test file ensures that TTPExecutor correctly handles both execution modes
5
+ and would have caught the bug where API mode was not properly implemented.
6
+ """
7
+
8
+ import unittest
9
+ from unittest.mock import Mock, patch, MagicMock
10
+ import sys
11
+ import os
12
+
13
+ # Add the scythe package to the path for testing
14
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
15
+
16
+ from scythe.core.ttp import TTP
17
+ from scythe.core.executor import TTPExecutor
18
+
19
+
20
+ class MockTTPDualMode(TTP):
21
+ """Mock TTP that supports both UI and API modes."""
22
+
23
+ def __init__(self, execution_mode='ui', expected_result=True):
24
+ super().__init__(
25
+ name="Mock Dual Mode TTP",
26
+ description="Test TTP supporting both modes",
27
+ expected_result=expected_result,
28
+ execution_mode=execution_mode
29
+ )
30
+ self.ui_execute_called = False
31
+ self.ui_verify_called = False
32
+ self.api_execute_called = False
33
+ self.api_verify_called = False
34
+ self.payloads_list = ['payload1', 'payload2', 'payload3']
35
+
36
+ def get_payloads(self):
37
+ """Yield test payloads."""
38
+ yield from self.payloads_list
39
+
40
+ def execute_step(self, driver, payload):
41
+ """Mock UI execution step."""
42
+ self.ui_execute_called = True
43
+
44
+ def verify_result(self, driver):
45
+ """Mock UI result verification."""
46
+ self.ui_verify_called = True
47
+ return True
48
+
49
+ def execute_step_api(self, session, payload, context):
50
+ """Mock API execution step."""
51
+ self.api_execute_called = True
52
+
53
+ # Create a mock response
54
+ mock_response = Mock()
55
+ mock_response.status_code = 200
56
+ mock_response.text = '{"success": true}'
57
+ mock_response.url = context.get('target_url', 'http://test.com')
58
+ mock_response.headers = {'X-SCYTHE-TARGET-VERSION': '1.0.0'}
59
+
60
+ return mock_response
61
+
62
+ def verify_result_api(self, response, context):
63
+ """Mock API result verification."""
64
+ self.api_verify_called = True
65
+ return response.status_code == 200
66
+
67
+
68
+ class MockTTPUIOnly(TTP):
69
+ """Mock TTP that only supports UI mode (legacy behavior)."""
70
+
71
+ def __init__(self):
72
+ super().__init__(
73
+ name="Mock UI Only TTP",
74
+ description="Test TTP without API support",
75
+ execution_mode='ui'
76
+ )
77
+
78
+ def get_payloads(self):
79
+ yield 'payload1'
80
+
81
+ def execute_step(self, driver, payload):
82
+ pass
83
+
84
+ def verify_result(self, driver):
85
+ return True
86
+
87
+
88
+ class TestTTPExecutorUIMode(unittest.TestCase):
89
+ """Test cases for TTPExecutor in UI mode."""
90
+
91
+ def setUp(self):
92
+ """Set up test fixtures."""
93
+ self.mock_driver = Mock()
94
+ self.mock_driver.current_url = "http://test.com"
95
+ self.mock_driver.quit = Mock()
96
+
97
+ @patch('scythe.core.executor.webdriver.Chrome')
98
+ def test_ui_mode_initializes_webdriver(self, mock_webdriver):
99
+ """Test that UI mode initializes WebDriver."""
100
+ mock_webdriver.return_value = self.mock_driver
101
+
102
+ ttp = MockTTPDualMode(execution_mode='ui')
103
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
104
+
105
+ with patch.object(executor, 'logger'):
106
+ executor.run()
107
+
108
+ # WebDriver should be initialized
109
+ mock_webdriver.assert_called_once()
110
+
111
+ @patch('scythe.core.executor.webdriver.Chrome')
112
+ def test_ui_mode_calls_ui_methods(self, mock_webdriver):
113
+ """Test that UI mode calls execute_step and verify_result."""
114
+ mock_webdriver.return_value = self.mock_driver
115
+
116
+ ttp = MockTTPDualMode(execution_mode='ui')
117
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
118
+
119
+ with patch.object(executor, 'logger'):
120
+ executor.run()
121
+
122
+ # UI methods should be called
123
+ self.assertTrue(ttp.ui_execute_called)
124
+ self.assertTrue(ttp.ui_verify_called)
125
+
126
+ # API methods should NOT be called
127
+ self.assertFalse(ttp.api_execute_called)
128
+ self.assertFalse(ttp.api_verify_called)
129
+
130
+ @patch('scythe.core.executor.webdriver.Chrome')
131
+ def test_ui_mode_driver_cleanup(self, mock_webdriver):
132
+ """Test that WebDriver is properly cleaned up in UI mode."""
133
+ mock_webdriver.return_value = self.mock_driver
134
+
135
+ ttp = MockTTPDualMode(execution_mode='ui')
136
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
137
+
138
+ with patch.object(executor, 'logger'):
139
+ executor.run()
140
+
141
+ # Driver should be quit
142
+ self.mock_driver.quit.assert_called_once()
143
+
144
+
145
+ class TestTTPExecutorAPIMode(unittest.TestCase):
146
+ """Test cases for TTPExecutor in API mode."""
147
+
148
+ def test_api_mode_does_not_initialize_webdriver(self):
149
+ """Test that API mode does NOT initialize WebDriver."""
150
+ ttp = MockTTPDualMode(execution_mode='api')
151
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
152
+
153
+ with patch('scythe.core.executor.webdriver.Chrome') as mock_webdriver:
154
+ with patch.object(executor, 'logger'):
155
+ executor.run()
156
+
157
+ # WebDriver should NOT be initialized in API mode
158
+ mock_webdriver.assert_not_called()
159
+
160
+ def test_api_mode_calls_api_methods(self):
161
+ """Test that API mode calls execute_step_api and verify_result_api."""
162
+ ttp = MockTTPDualMode(execution_mode='api')
163
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
164
+
165
+ with patch.object(executor, 'logger'):
166
+ executor.run()
167
+
168
+ # API methods should be called
169
+ self.assertTrue(ttp.api_execute_called)
170
+ self.assertTrue(ttp.api_verify_called)
171
+
172
+ # UI methods should NOT be called
173
+ self.assertFalse(ttp.ui_execute_called)
174
+ self.assertFalse(ttp.ui_verify_called)
175
+
176
+ def test_api_mode_creates_session(self):
177
+ """Test that API mode creates a requests.Session."""
178
+ ttp = MockTTPDualMode(execution_mode='api')
179
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
180
+
181
+ with patch('scythe.core.executor.requests.Session') as mock_session_class:
182
+ mock_session = Mock()
183
+ mock_session_class.return_value = mock_session
184
+
185
+ with patch.object(executor, 'logger'):
186
+ executor.run()
187
+
188
+ # Session should be created
189
+ mock_session_class.assert_called_once()
190
+
191
+ # Session should be closed
192
+ mock_session.close.assert_called_once()
193
+
194
+ def test_api_mode_processes_payloads(self):
195
+ """Test that API mode processes all payloads."""
196
+ ttp = MockTTPDualMode(execution_mode='api')
197
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
198
+
199
+ with patch.object(executor, 'logger'):
200
+ executor.run()
201
+
202
+ # Should have processed all payloads
203
+ self.assertEqual(len(executor.results), 3)
204
+
205
+ def test_api_mode_context_setup(self):
206
+ """Test that API mode sets up context correctly."""
207
+ ttp = MockTTPDualMode(execution_mode='api')
208
+
209
+ # Track the context passed to execute_step_api
210
+ captured_context = {}
211
+
212
+ def capture_context(session, payload, context):
213
+ captured_context.update(context)
214
+ return ttp.execute_step_api(session, payload, context)
215
+
216
+ with patch.object(ttp, 'execute_step_api', side_effect=capture_context):
217
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
218
+
219
+ with patch.object(executor, 'logger'):
220
+ executor.run()
221
+
222
+ # Context should have required keys
223
+ self.assertIn('target_url', captured_context)
224
+ self.assertEqual(captured_context['target_url'], 'http://test.com')
225
+ self.assertIn('auth_headers', captured_context)
226
+ self.assertIn('rate_limit_resume_at', captured_context)
227
+
228
+ def test_api_mode_extracts_version_header(self):
229
+ """Test that API mode extracts X-SCYTHE-TARGET-VERSION header."""
230
+ ttp = MockTTPDualMode(execution_mode='api')
231
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
232
+
233
+ with patch.object(executor, 'logger'):
234
+ executor.run()
235
+
236
+ # Results should have version info
237
+ for result in executor.results:
238
+ self.assertIn('target_version', result)
239
+ self.assertEqual(result['target_version'], '1.0.0')
240
+
241
+ def test_api_mode_with_expected_results_true(self):
242
+ """Test API mode with expected_result=True."""
243
+ ttp = MockTTPDualMode(execution_mode='api', expected_result=True)
244
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
245
+
246
+ with patch.object(executor, 'logger') as mock_logger:
247
+ executor.run()
248
+
249
+ # Should log expected successes
250
+ self.assertEqual(len(executor.results), 3)
251
+ for result in executor.results:
252
+ self.assertTrue(result['expected'])
253
+ self.assertTrue(result['actual'])
254
+
255
+ def test_api_mode_with_expected_results_false(self):
256
+ """Test API mode with expected_result=False."""
257
+ ttp = MockTTPDualMode(execution_mode='api', expected_result=False)
258
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
259
+
260
+ with patch.object(executor, 'logger') as mock_logger:
261
+ executor.run()
262
+
263
+ # Should log unexpected successes
264
+ self.assertEqual(len(executor.results), 3)
265
+ for result in executor.results:
266
+ self.assertFalse(result['expected'])
267
+ self.assertTrue(result['actual'])
268
+
269
+ # Should mark as test failure
270
+ self.assertTrue(executor.has_test_failures)
271
+
272
+
273
+ class TestTTPExecutorModeSelection(unittest.TestCase):
274
+ """Test cases for TTPExecutor mode selection logic."""
275
+
276
+ def test_executor_detects_ui_mode(self):
277
+ """Test that executor detects UI mode from TTP."""
278
+ ttp = MockTTPDualMode(execution_mode='ui')
279
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
280
+
281
+ self.assertEqual(executor.ttp.execution_mode, 'ui')
282
+
283
+ def test_executor_detects_api_mode(self):
284
+ """Test that executor detects API mode from TTP."""
285
+ ttp = MockTTPDualMode(execution_mode='api')
286
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
287
+
288
+ self.assertEqual(executor.ttp.execution_mode, 'api')
289
+
290
+ def test_executor_logs_execution_mode(self):
291
+ """Test that executor logs the execution mode."""
292
+ ttp_ui = MockTTPDualMode(execution_mode='ui')
293
+ executor_ui = TTPExecutor(ttp=ttp_ui, target_url="http://test.com", headless=True)
294
+
295
+ with patch('scythe.core.executor.webdriver.Chrome') as mock_webdriver:
296
+ mock_driver = Mock()
297
+ mock_driver.current_url = "http://test.com"
298
+ mock_driver.quit = Mock()
299
+ mock_webdriver.return_value = mock_driver
300
+
301
+ with patch.object(executor_ui, 'logger') as mock_logger:
302
+ executor_ui.run()
303
+
304
+ # Should log UI mode
305
+ mock_logger.info.assert_any_call("Execution mode: UI")
306
+
307
+ ttp_api = MockTTPDualMode(execution_mode='api')
308
+ executor_api = TTPExecutor(ttp=ttp_api, target_url="http://test.com", headless=True)
309
+
310
+ with patch.object(executor_api, 'logger') as mock_logger:
311
+ executor_api.run()
312
+
313
+ # Should log API mode
314
+ mock_logger.info.assert_any_call("Execution mode: API")
315
+
316
+ def test_executor_default_mode_is_ui(self):
317
+ """Test that default execution mode is UI."""
318
+ # TTP with no explicit mode should default to UI
319
+ ttp = MockTTPUIOnly()
320
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
321
+
322
+ self.assertEqual(executor.ttp.execution_mode, 'ui')
323
+
324
+
325
+ class TestTTPExecutorAPIErrorHandling(unittest.TestCase):
326
+ """Test cases for error handling in API mode."""
327
+
328
+ def test_api_mode_handles_request_exception(self):
329
+ """Test that API mode handles request exceptions gracefully."""
330
+ ttp = MockTTPDualMode(execution_mode='api')
331
+
332
+ # Make execute_step_api raise an exception
333
+ def raise_exception(session, payload, context):
334
+ raise Exception("Network error")
335
+
336
+ with patch.object(ttp, 'execute_step_api', side_effect=raise_exception):
337
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
338
+
339
+ with patch.object(executor, 'logger') as mock_logger:
340
+ executor.run()
341
+
342
+ # Should log errors but not crash
343
+ self.assertTrue(any('Error during step' in str(call) for call in mock_logger.error.call_args_list))
344
+
345
+ def test_api_mode_continues_after_error(self):
346
+ """Test that API mode continues processing after an error."""
347
+ ttp = MockTTPDualMode(execution_mode='api')
348
+
349
+ call_count = [0]
350
+
351
+ def fail_first_then_succeed(session, payload, context):
352
+ call_count[0] += 1
353
+ if call_count[0] == 1:
354
+ raise Exception("First call fails")
355
+ return ttp.execute_step_api(session, payload, context)
356
+
357
+ with patch.object(ttp, 'execute_step_api', side_effect=fail_first_then_succeed):
358
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
359
+
360
+ with patch.object(executor, 'logger'):
361
+ executor.run()
362
+
363
+ # Should have processed remaining payloads after error
364
+ self.assertEqual(len(executor.results), 2) # 3 payloads - 1 error = 2 results
365
+
366
+
367
+ class TestTTPExecutorAuthenticationAPIMode(unittest.TestCase):
368
+ """Test cases for authentication in API mode."""
369
+
370
+ def test_api_mode_applies_auth_headers(self):
371
+ """Test that API mode applies authentication headers."""
372
+ from scythe.auth.base import Authentication
373
+
374
+ # Create mock authentication
375
+ mock_auth = Mock(spec=Authentication)
376
+ mock_auth.name = "Test Auth"
377
+ mock_auth.get_auth_headers.return_value = {'Authorization': 'Bearer token123'}
378
+
379
+ ttp = MockTTPDualMode(execution_mode='api')
380
+ ttp.authentication = mock_auth
381
+
382
+ captured_session = None
383
+
384
+ def capture_session(session, payload, context):
385
+ nonlocal captured_session
386
+ captured_session = session
387
+ return ttp.execute_step_api(session, payload, context)
388
+
389
+ with patch.object(ttp, 'execute_step_api', side_effect=capture_session):
390
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
391
+
392
+ with patch.object(executor, 'logger'):
393
+ executor.run()
394
+
395
+ # Auth headers should be called
396
+ mock_auth.get_auth_headers.assert_called()
397
+
398
+
399
+ class TestTTPExecutorWasSuccessfulAPIMode(unittest.TestCase):
400
+ """Test was_successful() method in API mode."""
401
+
402
+ def test_was_successful_true_when_results_match_expectations(self):
403
+ """Test was_successful returns True when API results match expectations."""
404
+ ttp = MockTTPDualMode(execution_mode='api', expected_result=True)
405
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
406
+
407
+ with patch.object(executor, 'logger'):
408
+ executor.run()
409
+
410
+ # Should be successful since results matched expectations
411
+ self.assertTrue(executor.was_successful())
412
+
413
+ def test_was_successful_false_when_unexpected_success(self):
414
+ """Test was_successful returns False with unexpected successes in API mode."""
415
+ ttp = MockTTPDualMode(execution_mode='api', expected_result=False)
416
+ executor = TTPExecutor(ttp=ttp, target_url="http://test.com", headless=True)
417
+
418
+ with patch.object(executor, 'logger'):
419
+ executor.run()
420
+
421
+ # Should NOT be successful since we got unexpected successes
422
+ self.assertFalse(executor.was_successful())
423
+
424
+
425
+ class TestTTPExecutorBothModes(unittest.TestCase):
426
+ """Test cases comparing UI and API mode behavior."""
427
+
428
+ @patch('scythe.core.executor.webdriver.Chrome')
429
+ def test_both_modes_process_same_payloads(self, mock_webdriver):
430
+ """Test that both modes process the same payloads."""
431
+ mock_driver = Mock()
432
+ mock_driver.current_url = "http://test.com"
433
+ mock_driver.quit = Mock()
434
+ mock_webdriver.return_value = mock_driver
435
+
436
+ # UI mode
437
+ ttp_ui = MockTTPDualMode(execution_mode='ui')
438
+ executor_ui = TTPExecutor(ttp=ttp_ui, target_url="http://test.com", headless=True)
439
+
440
+ with patch.object(executor_ui, 'logger'):
441
+ executor_ui.run()
442
+
443
+ # API mode
444
+ ttp_api = MockTTPDualMode(execution_mode='api')
445
+ executor_api = TTPExecutor(ttp=ttp_api, target_url="http://test.com", headless=True)
446
+
447
+ with patch.object(executor_api, 'logger'):
448
+ executor_api.run()
449
+
450
+ # Both should process same number of results
451
+ self.assertEqual(len(executor_ui.results), len(executor_api.results))
452
+
453
+ @patch('scythe.core.executor.webdriver.Chrome')
454
+ def test_both_modes_respect_expected_result(self, mock_webdriver):
455
+ """Test that both modes respect expected_result setting."""
456
+ mock_driver = Mock()
457
+ mock_driver.current_url = "http://test.com"
458
+ mock_driver.quit = Mock()
459
+ mock_webdriver.return_value = mock_driver
460
+
461
+ # UI mode with expected_result=False
462
+ ttp_ui = MockTTPDualMode(execution_mode='ui', expected_result=False)
463
+ executor_ui = TTPExecutor(ttp=ttp_ui, target_url="http://test.com", headless=True)
464
+
465
+ with patch.object(executor_ui, 'logger'):
466
+ executor_ui.run()
467
+
468
+ # API mode with expected_result=False
469
+ ttp_api = MockTTPDualMode(execution_mode='api', expected_result=False)
470
+ executor_api = TTPExecutor(ttp=ttp_api, target_url="http://test.com", headless=True)
471
+
472
+ with patch.object(executor_api, 'logger'):
473
+ executor_api.run()
474
+
475
+ # Both should have test failures (unexpected successes)
476
+ self.assertTrue(executor_ui.has_test_failures)
477
+ self.assertTrue(executor_api.has_test_failures)
478
+
479
+
480
+ if __name__ == '__main__':
481
+ unittest.main()
scythe_ttp-0.17.2/VERSION DELETED
@@ -1 +0,0 @@
1
- 0.17.2
File without changes
File without changes
File without changes
File without changes
File without changes