iflow-mcp_democratize-technology-chronos-mcp 2.0.0__py3-none-any.whl

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.
Files changed (68) hide show
  1. chronos_mcp/__init__.py +5 -0
  2. chronos_mcp/__main__.py +9 -0
  3. chronos_mcp/accounts.py +410 -0
  4. chronos_mcp/bulk.py +946 -0
  5. chronos_mcp/caldav_utils.py +149 -0
  6. chronos_mcp/calendars.py +204 -0
  7. chronos_mcp/config.py +187 -0
  8. chronos_mcp/credentials.py +190 -0
  9. chronos_mcp/events.py +515 -0
  10. chronos_mcp/exceptions.py +477 -0
  11. chronos_mcp/journals.py +477 -0
  12. chronos_mcp/logging_config.py +23 -0
  13. chronos_mcp/models.py +202 -0
  14. chronos_mcp/py.typed +0 -0
  15. chronos_mcp/rrule.py +259 -0
  16. chronos_mcp/search.py +315 -0
  17. chronos_mcp/server.py +121 -0
  18. chronos_mcp/tasks.py +518 -0
  19. chronos_mcp/tools/__init__.py +29 -0
  20. chronos_mcp/tools/accounts.py +151 -0
  21. chronos_mcp/tools/base.py +59 -0
  22. chronos_mcp/tools/bulk.py +557 -0
  23. chronos_mcp/tools/calendars.py +142 -0
  24. chronos_mcp/tools/events.py +698 -0
  25. chronos_mcp/tools/journals.py +310 -0
  26. chronos_mcp/tools/tasks.py +414 -0
  27. chronos_mcp/utils.py +163 -0
  28. chronos_mcp/validation.py +636 -0
  29. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
  30. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
  31. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
  32. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
  33. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
  34. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
  35. tests/__init__.py +0 -0
  36. tests/conftest.py +91 -0
  37. tests/unit/__init__.py +0 -0
  38. tests/unit/test_accounts.py +380 -0
  39. tests/unit/test_accounts_ssrf.py +134 -0
  40. tests/unit/test_base.py +135 -0
  41. tests/unit/test_bulk.py +380 -0
  42. tests/unit/test_bulk_create.py +408 -0
  43. tests/unit/test_bulk_delete.py +341 -0
  44. tests/unit/test_bulk_resource_limits.py +74 -0
  45. tests/unit/test_caldav_utils.py +300 -0
  46. tests/unit/test_calendars.py +286 -0
  47. tests/unit/test_config.py +111 -0
  48. tests/unit/test_config_validation.py +128 -0
  49. tests/unit/test_credentials_security.py +189 -0
  50. tests/unit/test_cryptography_security.py +178 -0
  51. tests/unit/test_events.py +536 -0
  52. tests/unit/test_exceptions.py +58 -0
  53. tests/unit/test_journals.py +1097 -0
  54. tests/unit/test_models.py +95 -0
  55. tests/unit/test_race_conditions.py +202 -0
  56. tests/unit/test_recurring_events.py +156 -0
  57. tests/unit/test_rrule.py +217 -0
  58. tests/unit/test_search.py +372 -0
  59. tests/unit/test_search_advanced.py +333 -0
  60. tests/unit/test_server_input_validation.py +219 -0
  61. tests/unit/test_ssrf_protection.py +505 -0
  62. tests/unit/test_tasks.py +918 -0
  63. tests/unit/test_thread_safety.py +301 -0
  64. tests/unit/test_tools_journals.py +617 -0
  65. tests/unit/test_tools_tasks.py +968 -0
  66. tests/unit/test_url_validation_security.py +234 -0
  67. tests/unit/test_utils.py +180 -0
  68. tests/unit/test_validation.py +983 -0
@@ -0,0 +1,380 @@
1
+ """
2
+ Unit tests for account management
3
+ """
4
+
5
+ import time
6
+ from unittest.mock import Mock, patch
7
+
8
+ import pytest
9
+ from caldav.lib.error import AuthorizationError
10
+
11
+ from chronos_mcp.accounts import AccountManager, CircuitBreaker, CircuitBreakerState
12
+ from chronos_mcp.exceptions import (
13
+ AccountAuthenticationError,
14
+ AccountConnectionError,
15
+ AccountNotFoundError,
16
+ )
17
+ from chronos_mcp.models import AccountStatus
18
+
19
+
20
+ class TestAccountManager:
21
+ def test_init(self, mock_config_manager):
22
+ """Test AccountManager initialization"""
23
+ mgr = AccountManager(mock_config_manager)
24
+ assert mgr.config == mock_config_manager
25
+ assert mgr.connections == {}
26
+ assert mgr.principals == {}
27
+
28
+ def test_connect_account_not_found(self, mock_config_manager):
29
+ """Test connecting to non-existent account"""
30
+ # Use real config manager - account doesn't exist
31
+ mgr = AccountManager(mock_config_manager)
32
+
33
+ # Should raise AccountNotFoundError
34
+ with pytest.raises(AccountNotFoundError) as exc_info:
35
+ mgr.connect_account("nonexistent")
36
+
37
+ assert "nonexistent" in str(exc_info.value)
38
+ assert exc_info.value.details["alias"] == "nonexistent"
39
+
40
+ @patch("chronos_mcp.accounts.DAVClient")
41
+ def test_connect_account_success(
42
+ self, mock_dav_client, mock_config_manager, sample_account
43
+ ):
44
+ """Test successful connection to an account"""
45
+ # Add account to config manager first
46
+ mock_config_manager.add_account(sample_account)
47
+ mgr = AccountManager(mock_config_manager)
48
+
49
+ # Mock successful connection
50
+ mock_client = Mock()
51
+ mock_dav_client.return_value = mock_client
52
+ mock_principal = Mock()
53
+ mock_client.principal.return_value = mock_principal
54
+
55
+ result = mgr.connect_account("test_account")
56
+ assert result is True
57
+ assert "test_account" in mgr.connections
58
+ assert "test_account" in mgr.principals
59
+ assert sample_account.status == AccountStatus.CONNECTED
60
+
61
+ @patch("chronos_mcp.accounts.DAVClient")
62
+ def test_connect_account_failure(
63
+ self, mock_dav_client, mock_config_manager, sample_account
64
+ ):
65
+ """Test connection failure"""
66
+ # Add account to config manager first
67
+ mock_config_manager.add_account(sample_account)
68
+ mgr = AccountManager(mock_config_manager)
69
+
70
+ # Mock connection failure
71
+ mock_dav_client.side_effect = AuthorizationError("Invalid credentials")
72
+
73
+ # Should raise AccountAuthenticationError
74
+ with pytest.raises(AccountAuthenticationError) as exc_info:
75
+ mgr.connect_account("test_account")
76
+
77
+ assert exc_info.value.details["alias"] == "test_account"
78
+ assert sample_account.status == AccountStatus.ERROR
79
+
80
+ def test_disconnect_account(self, mock_config_manager, sample_account):
81
+ """Test disconnecting an account"""
82
+ # Add account to config manager first
83
+ mock_config_manager.add_account(sample_account)
84
+ mgr = AccountManager(mock_config_manager)
85
+
86
+ # Simulate connected account
87
+ mgr.connections["test_account"] = Mock()
88
+ mgr.principals["test_account"] = Mock()
89
+
90
+ mgr.disconnect_account("test_account")
91
+ assert "test_account" not in mgr.connections
92
+ assert "test_account" not in mgr.principals
93
+ assert sample_account.status == AccountStatus.DISCONNECTED
94
+
95
+ def test_get_connection_not_connected(self, mock_config_manager, sample_account):
96
+ """Test getting connection when not connected - should auto-connect"""
97
+ mock_config_manager.config.default_account = None
98
+ mock_config_manager.add_account(sample_account)
99
+ mgr = AccountManager(mock_config_manager)
100
+
101
+ # Mock connect_account to fail
102
+ with patch.object(mgr, "connect_account", return_value=False):
103
+ connection = mgr.get_connection("test_account")
104
+ assert connection is None
105
+
106
+ @patch("chronos_mcp.accounts.DAVClient")
107
+ def test_test_account(self, mock_dav_client, mock_config_manager, sample_account):
108
+ """Test testing account connectivity"""
109
+ # Add account to config manager first
110
+ mock_config_manager.add_account(sample_account)
111
+ mgr = AccountManager(mock_config_manager)
112
+
113
+ # Mock successful connection
114
+ mock_client = Mock()
115
+ mock_dav_client.return_value = mock_client
116
+ mock_principal = Mock()
117
+ mock_client.principal.return_value = mock_principal
118
+ mock_calendars = [Mock(), Mock()]
119
+ mock_principal.calendars.return_value = mock_calendars
120
+
121
+ result = mgr.test_account("test_account")
122
+ assert result["alias"] == "test_account"
123
+ assert result["connected"] is True
124
+ assert result["calendars"] == 2
125
+ assert result["error"] is None
126
+
127
+ def test_get_principal(self, mock_config_manager):
128
+ """Test getting principal for an account"""
129
+ mgr = AccountManager(mock_config_manager)
130
+
131
+ # No principal when not connected
132
+ principal = mgr.get_principal("nonexistent")
133
+ assert principal is None
134
+
135
+ # Should return principal when connected
136
+ mock_principal = Mock()
137
+ mgr.principals["test_account"] = mock_principal
138
+ # Add timestamp to prevent stale connection check
139
+ mgr._connection_timestamps["test_account"] = time.time()
140
+
141
+ principal = mgr.get_principal("test_account")
142
+ assert principal == mock_principal
143
+
144
+ @patch("chronos_mcp.accounts.DAVClient")
145
+ def test_get_connection_with_default(
146
+ self, mock_dav_client, mock_config_manager, sample_account
147
+ ):
148
+ """Test getting connection uses default account when no alias provided"""
149
+ # Set up default account
150
+ mock_config_manager.add_account(sample_account)
151
+ mock_config_manager.config.default_account = "test_account"
152
+
153
+ mgr = AccountManager(mock_config_manager)
154
+
155
+ # Mock successful connection
156
+ mock_client = Mock()
157
+ mock_dav_client.return_value = mock_client
158
+ mock_principal = Mock()
159
+ mock_client.principal.return_value = mock_principal
160
+
161
+ # Get connection without specifying alias
162
+ mgr.get_connection()
163
+
164
+ # Should have connected to default account
165
+ assert "test_account" in mgr.connections
166
+
167
+ def test_circuit_breaker_functionality(self, mock_config_manager, sample_account):
168
+ """Test circuit breaker opens after repeated failures"""
169
+ mock_config_manager.add_account(sample_account)
170
+ mgr = AccountManager(mock_config_manager)
171
+
172
+ # Trigger multiple failures to open circuit breaker
173
+ with patch("chronos_mcp.accounts.DAVClient") as mock_dav_client:
174
+ mock_dav_client.side_effect = Exception("Connection failed")
175
+
176
+ # Try to connect multiple times to trigger circuit breaker
177
+ for _ in range(6): # Exceeds failure threshold of 5
178
+ try:
179
+ mgr.connect_account("test_account")
180
+ except AccountConnectionError:
181
+ pass
182
+
183
+ # Circuit breaker should be OPEN
184
+ breaker_state = mgr.get_circuit_breaker_status("test_account")
185
+ assert breaker_state == CircuitBreakerState.OPEN
186
+
187
+ # Next connection attempt should be rejected immediately
188
+ with pytest.raises(AccountConnectionError) as exc_info:
189
+ mgr.connect_account("test_account")
190
+ # Check that the error details contain the circuit breaker message
191
+ assert "Circuit breaker is OPEN" in exc_info.value.details.get(
192
+ "original_error", ""
193
+ )
194
+
195
+ def test_connection_health_tracking(self, mock_config_manager, sample_account):
196
+ """Test connection health metrics are tracked"""
197
+ mock_config_manager.add_account(sample_account)
198
+ mgr = AccountManager(mock_config_manager)
199
+
200
+ # Test successful connection updates health
201
+ with patch("chronos_mcp.accounts.DAVClient") as mock_dav_client:
202
+ mock_client = Mock()
203
+ mock_dav_client.return_value = mock_client
204
+ mock_principal = Mock()
205
+ mock_client.principal.return_value = mock_principal
206
+
207
+ mgr.connect_account("test_account")
208
+
209
+ health = mgr.get_connection_health("test_account")
210
+ assert health is not None
211
+ assert health.total_attempts == 1
212
+ assert health.successful_connections == 1
213
+ assert health.failed_connections == 0
214
+ assert health.success_rate == 1.0
215
+
216
+ def test_connection_retry_logic(self, mock_config_manager, sample_account):
217
+ """Test connection retry with exponential backoff"""
218
+ mock_config_manager.add_account(sample_account)
219
+ mgr = AccountManager(mock_config_manager)
220
+ mgr._max_retries = 3
221
+
222
+ call_count = 0
223
+
224
+ def mock_dav_client_side_effect(*args, **kwargs):
225
+ nonlocal call_count
226
+ call_count += 1
227
+ if call_count < 3: # Fail first 2 attempts
228
+ raise Exception("Temporary failure")
229
+ # Succeed on 3rd attempt
230
+ mock_client = Mock()
231
+ mock_client.principal.return_value = Mock()
232
+ return mock_client
233
+
234
+ with patch(
235
+ "chronos_mcp.accounts.DAVClient", side_effect=mock_dav_client_side_effect
236
+ ):
237
+ with patch("time.sleep") as mock_sleep: # Mock sleep for testing
238
+ result = mgr.connect_account("test_account")
239
+
240
+ assert result is True
241
+ assert call_count == 3 # Should have retried
242
+ assert mock_sleep.call_count == 2 # 2 sleeps before success
243
+
244
+ def test_connection_timeout_configuration(
245
+ self, mock_config_manager, sample_account
246
+ ):
247
+ """Test connection timeout is properly configured"""
248
+ mock_config_manager.add_account(sample_account)
249
+ mgr = AccountManager(mock_config_manager)
250
+
251
+ with patch("chronos_mcp.accounts.DAVClient") as mock_dav_client:
252
+ mock_client = Mock()
253
+ mock_dav_client.return_value = mock_client
254
+ mock_principal = Mock()
255
+ mock_client.principal.return_value = mock_principal
256
+
257
+ mgr.connect_account("test_account")
258
+
259
+ # Verify DAVClient was called with timeout
260
+ mock_dav_client.assert_called_with(
261
+ url=str(sample_account.url),
262
+ username=sample_account.username,
263
+ password=sample_account.password,
264
+ timeout=30, # Default timeout
265
+ )
266
+
267
+ def test_password_validation_in_add_account(self):
268
+ """Test that password is validated when adding an account"""
269
+ from chronos_mcp.validation import InputValidator
270
+ from chronos_mcp.exceptions import ValidationError
271
+
272
+ # Test with control characters in password - should be rejected
273
+ with pytest.raises(ValidationError) as exc_info:
274
+ InputValidator.validate_text_field("pass\x00word", "password", required=True)
275
+ assert "potentially dangerous content" in str(exc_info.value).lower()
276
+
277
+ # Test with excessively long password - should be rejected
278
+ with pytest.raises(ValidationError) as exc_info:
279
+ InputValidator.validate_text_field("a" * 11000, "password", required=True)
280
+ assert "maximum" in str(exc_info.value).lower()
281
+
282
+ # Test with valid password - should pass
283
+ result = InputValidator.validate_text_field("ValidP@ssw0rd!", "password", required=True)
284
+ assert result == "ValidP@ssw0rd!"
285
+
286
+ @patch("chronos_mcp.tools.accounts._managers")
287
+ def test_add_account_validates_password(self, mock_managers_dict, mock_config_manager):
288
+ """Test that add_account tool validates password input"""
289
+ import asyncio
290
+ from chronos_mcp.tools.accounts import add_account
291
+
292
+ # Setup managers
293
+ mock_managers_dict.__getitem__.return_value = mock_config_manager
294
+ mock_account_manager = Mock()
295
+ mock_account_manager.test_account.return_value = {
296
+ "alias": "test",
297
+ "connected": True,
298
+ "calendars": 2,
299
+ "error": None
300
+ }
301
+
302
+ def manager_getter(key):
303
+ if key == "config_manager":
304
+ return mock_config_manager
305
+ elif key == "account_manager":
306
+ return mock_account_manager
307
+ raise KeyError(key)
308
+
309
+ mock_managers_dict.__getitem__.side_effect = manager_getter
310
+
311
+ # Test with invalid password (control character)
312
+ # The decorator catches exceptions and returns error dict
313
+ result = asyncio.run(add_account(
314
+ alias="test",
315
+ url="https://example.com",
316
+ username="user",
317
+ password="pass\x00word", # Null byte
318
+ allow_local=True
319
+ ))
320
+
321
+ # Should return error response
322
+ assert result["success"] is False
323
+ assert "error_code" in result
324
+ assert result["error_code"] == "ValidationError"
325
+ assert "dangerous content" in result["error"].lower()
326
+
327
+ def test_circuit_breaker_recovery(self, mock_config_manager, sample_account):
328
+ """Test circuit breaker transitions to HALF_OPEN after recovery timeout"""
329
+ mock_config_manager.add_account(sample_account)
330
+ mgr = AccountManager(mock_config_manager)
331
+
332
+ # Manually create and configure circuit breaker for testing
333
+ breaker = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
334
+ mgr._circuit_breakers["test_account"] = breaker
335
+
336
+ # Trigger failures to open circuit breaker
337
+ breaker.record_failure()
338
+ breaker.record_failure()
339
+ assert breaker.state == CircuitBreakerState.OPEN
340
+
341
+ # Initially should not allow requests
342
+ assert not breaker.should_allow_request()
343
+
344
+ # Fast-forward time past recovery timeout
345
+ breaker.last_failure_time = time.time() - 2 # 2 seconds ago
346
+
347
+ # Should now allow request (HALF_OPEN)
348
+ assert breaker.should_allow_request()
349
+ assert breaker.state == CircuitBreakerState.HALF_OPEN
350
+
351
+ # Successful operation should close circuit
352
+ breaker.record_success()
353
+ assert breaker.state == CircuitBreakerState.CLOSED
354
+
355
+ @patch("chronos_mcp.accounts.DAVClient")
356
+ def test_cleanup_stale_connection(
357
+ self, mock_dav_client, mock_config_manager, sample_account
358
+ ):
359
+ """Test cleanup of stale connections"""
360
+ mock_config_manager.add_account(sample_account)
361
+ mgr = AccountManager(mock_config_manager)
362
+ mgr._connection_ttl_minutes = 0.01 # Very short TTL for testing
363
+
364
+ # Mock successful connection
365
+ mock_client = Mock()
366
+ mock_dav_client.return_value = mock_client
367
+ mock_principal = Mock()
368
+ mock_client.principal.return_value = mock_principal
369
+
370
+ # Connect and then wait for it to become stale
371
+ mgr.connect_account("test_account")
372
+ assert "test_account" in mgr.connections
373
+
374
+ # Make connection timestamp old
375
+ mgr._connection_timestamps["test_account"] = time.time() - 60 # 1 minute ago
376
+
377
+ # Cleanup should remove stale connection
378
+ result = mgr._cleanup_stale_connection("test_account")
379
+ assert result is True
380
+ assert "test_account" not in mgr.connections
@@ -0,0 +1,134 @@
1
+ """
2
+ Test SSRF protection in accounts module
3
+ """
4
+
5
+ import pytest
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ from chronos_mcp.exceptions import ValidationError
9
+ from chronos_mcp.tools import accounts
10
+
11
+
12
+ class TestAccountsSSRFProtection:
13
+ """Test that the accounts module properly uses SSRF protection"""
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_add_account_blocks_private_ips_by_default(self):
17
+ """Test that add_account blocks private IPs by default"""
18
+
19
+ # Directly set managers in the accounts module
20
+ accounts._managers["config_manager"] = MagicMock()
21
+ accounts._managers["account_manager"] = MagicMock()
22
+
23
+ # Try to add account with localhost URL
24
+ result = await accounts.add_account(
25
+ alias="local-test",
26
+ url="https://localhost:8443/caldav",
27
+ username="user",
28
+ password="pass",
29
+ )
30
+
31
+ # The decorator catches ValidationError and returns error response
32
+ assert result["success"] is False
33
+ assert "not allowed for security reasons" in result["error"]
34
+
35
+ # Try with private IP
36
+ result = await accounts.add_account(
37
+ alias="private-test",
38
+ url="https://192.168.1.100/caldav",
39
+ username="user",
40
+ password="pass",
41
+ )
42
+
43
+ assert result["success"] is False
44
+ assert "not allowed for security reasons" in result["error"]
45
+
46
+ @pytest.mark.asyncio
47
+ async def test_add_account_allows_private_ips_with_flag(self):
48
+ """Test that add_account allows private IPs when explicitly enabled"""
49
+
50
+ # Mock the managers
51
+ mock_config_manager = MagicMock()
52
+ mock_account_manager = MagicMock()
53
+ mock_account_manager.test_account.return_value = {
54
+ "connected": True,
55
+ "calendars": [],
56
+ }
57
+
58
+ accounts._managers["config_manager"] = mock_config_manager
59
+ accounts._managers["account_manager"] = mock_account_manager
60
+
61
+ # Should work with allow_local=True
62
+ result = await accounts.add_account(
63
+ alias="local-dev",
64
+ url="https://localhost:8443/caldav",
65
+ username="user",
66
+ password="pass",
67
+ allow_local=True, # Explicitly allow local IPs
68
+ )
69
+
70
+ assert result["success"] is True
71
+ assert "local-dev" in result["message"]
72
+
73
+ # Verify the account was added
74
+ assert mock_config_manager.add_account.called
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_add_account_allows_public_urls(self):
78
+ """Test that add_account allows public URLs by default"""
79
+
80
+ # Mock the managers
81
+ mock_config_manager = MagicMock()
82
+ mock_account_manager = MagicMock()
83
+ mock_account_manager.test_account.return_value = {
84
+ "connected": True,
85
+ "calendars": ["Calendar1"],
86
+ }
87
+
88
+ accounts._managers["config_manager"] = mock_config_manager
89
+ accounts._managers["account_manager"] = mock_account_manager
90
+
91
+ # Mock DNS resolution to return public IP
92
+ with patch("socket.getaddrinfo") as mock_getaddrinfo:
93
+ # Mock public IP resolution
94
+ mock_getaddrinfo.return_value = [(2, 1, 6, "", ("93.184.216.34", 443))]
95
+
96
+ result = await accounts.add_account(
97
+ alias="public-caldav",
98
+ url="https://caldav.example.com/dav",
99
+ username="user",
100
+ password="pass",
101
+ )
102
+
103
+ assert result["success"] is True
104
+ assert "public-caldav" in result["message"]
105
+ assert mock_config_manager.add_account.called
106
+
107
+ @pytest.mark.asyncio
108
+ async def test_add_account_validates_url_format(self):
109
+ """Test that add_account validates URL format"""
110
+
111
+ accounts._managers["config_manager"] = MagicMock()
112
+ accounts._managers["account_manager"] = MagicMock()
113
+
114
+ # Invalid URL format
115
+ result = await accounts.add_account(
116
+ alias="bad-url", url="not-a-url", username="user", password="pass"
117
+ )
118
+
119
+ assert result["success"] is False
120
+ assert "Invalid URL format" in result["error"]
121
+
122
+ # HTTP instead of HTTPS
123
+ result = await accounts.add_account(
124
+ alias="http-url",
125
+ url="http://example.com/caldav",
126
+ username="user",
127
+ password="pass",
128
+ )
129
+
130
+ assert result["success"] is False
131
+ assert (
132
+ "Invalid URL format" in result["error"]
133
+ or "Must be a valid HTTPS URL" in result["error"]
134
+ )
@@ -0,0 +1,135 @@
1
+ """
2
+ Unit tests for base tool utilities
3
+ """
4
+
5
+ import pytest
6
+
7
+ from chronos_mcp.exceptions import ChronosError
8
+ from chronos_mcp.tools.base import handle_tool_errors
9
+
10
+
11
+ class TestHandleToolErrors:
12
+ """Test error handling decorator"""
13
+
14
+ @pytest.mark.asyncio
15
+ async def test_chronos_error_sanitization(self):
16
+ """Test that ChronosError exceptions are properly sanitized"""
17
+
18
+ @handle_tool_errors
19
+ async def tool_with_chronos_error(**kwargs):
20
+ raise ChronosError("Error with password=secret123 in message")
21
+
22
+ result = await tool_with_chronos_error()
23
+
24
+ assert result["success"] is False
25
+ assert "password=secret123" not in result["error"]
26
+ assert "password=***" in result["error"]
27
+ assert result["error_code"] == "ChronosError"
28
+ assert "request_id" in result
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_generic_exception_sanitization(self):
32
+ """
33
+ Test that generic exceptions are properly sanitized.
34
+
35
+ SECURITY TEST: This test verifies that unexpected exceptions containing
36
+ sensitive information (passwords, tokens, API keys) are sanitized before
37
+ being returned to the client. This prevents information disclosure via
38
+ error messages.
39
+
40
+ BUG: Currently FAILING - generic exceptions bypass ErrorSanitizer
41
+ Location: chronos_mcp/tools/base.py:42
42
+ """
43
+
44
+ @handle_tool_errors
45
+ async def tool_with_generic_error(**kwargs):
46
+ # Simulate an unexpected exception with sensitive data
47
+ raise ValueError("Database connection failed: password=mysecret123 token=abc-xyz-789")
48
+
49
+ result = await tool_with_generic_error()
50
+
51
+ # Verify response structure
52
+ assert result["success"] is False
53
+ assert result["error_code"] == "ValueError"
54
+ assert "request_id" in result
55
+
56
+ # CRITICAL SECURITY CHECK: Sensitive data MUST be redacted
57
+ error_message = result["error"]
58
+ assert "password=mysecret123" not in error_message, \
59
+ "SECURITY FAILURE: Password leaked in generic exception"
60
+ assert "token=abc-xyz-789" not in error_message, \
61
+ "SECURITY FAILURE: Token leaked in generic exception"
62
+
63
+ # Should contain sanitized versions
64
+ assert "password=***" in error_message or "password" not in error_message.lower()
65
+ assert "token=***" in error_message or "token" not in error_message.lower()
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_generic_exception_with_url_credentials(self):
69
+ """Test that URLs with embedded credentials are sanitized in generic exceptions"""
70
+
71
+ @handle_tool_errors
72
+ async def tool_with_url_error(**kwargs):
73
+ raise ConnectionError("Failed to connect to https://user:pass@example.com/api")
74
+
75
+ result = await tool_with_url_error()
76
+
77
+ assert result["success"] is False
78
+ error_message = result["error"]
79
+
80
+ # CRITICAL: URL credentials must be redacted
81
+ assert "user:pass" not in error_message, \
82
+ "SECURITY FAILURE: URL credentials leaked in generic exception"
83
+ assert "***:***@" in error_message or "user" not in error_message
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_generic_exception_with_api_key(self):
87
+ """Test that API keys are sanitized in generic exceptions"""
88
+
89
+ @handle_tool_errors
90
+ async def tool_with_api_key_error(**kwargs):
91
+ raise RuntimeError("API request failed: api_key=sk_live_abc123xyz789")
92
+
93
+ result = await tool_with_api_key_error()
94
+
95
+ assert result["success"] is False
96
+ error_message = result["error"]
97
+
98
+ # CRITICAL: API key must be redacted
99
+ assert "sk_live_abc123xyz789" not in error_message, \
100
+ "SECURITY FAILURE: API key leaked in generic exception"
101
+ assert "api_key=***" in error_message or "api_key" not in error_message
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_success_path_no_error(self):
105
+ """Test that successful tool execution returns normally"""
106
+
107
+ @handle_tool_errors
108
+ async def successful_tool(**kwargs):
109
+ return {"success": True, "data": "test"}
110
+
111
+ result = await successful_tool()
112
+
113
+ assert result["success"] is True
114
+ assert result["data"] == "test"
115
+ # Note: request_id is only added to error responses, not success responses
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_request_id_propagation(self):
119
+ """Test that request_id is properly injected as a kwarg to the tool function"""
120
+
121
+ @handle_tool_errors
122
+ async def tool_that_uses_request_id(request_id=None, **kwargs):
123
+ # Tool should receive the injected request_id
124
+ assert request_id is not None
125
+ # Return it so we can verify it was passed
126
+ return {"success": True, "received_id": request_id}
127
+
128
+ result = await tool_that_uses_request_id()
129
+
130
+ # Tool function received the request_id
131
+ assert result["success"] is True
132
+ assert "received_id" in result
133
+ # Verify it's a valid UUID string
134
+ import uuid
135
+ uuid.UUID(result["received_id"]) # Will raise if invalid