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.
- chronos_mcp/__init__.py +5 -0
- chronos_mcp/__main__.py +9 -0
- chronos_mcp/accounts.py +410 -0
- chronos_mcp/bulk.py +946 -0
- chronos_mcp/caldav_utils.py +149 -0
- chronos_mcp/calendars.py +204 -0
- chronos_mcp/config.py +187 -0
- chronos_mcp/credentials.py +190 -0
- chronos_mcp/events.py +515 -0
- chronos_mcp/exceptions.py +477 -0
- chronos_mcp/journals.py +477 -0
- chronos_mcp/logging_config.py +23 -0
- chronos_mcp/models.py +202 -0
- chronos_mcp/py.typed +0 -0
- chronos_mcp/rrule.py +259 -0
- chronos_mcp/search.py +315 -0
- chronos_mcp/server.py +121 -0
- chronos_mcp/tasks.py +518 -0
- chronos_mcp/tools/__init__.py +29 -0
- chronos_mcp/tools/accounts.py +151 -0
- chronos_mcp/tools/base.py +59 -0
- chronos_mcp/tools/bulk.py +557 -0
- chronos_mcp/tools/calendars.py +142 -0
- chronos_mcp/tools/events.py +698 -0
- chronos_mcp/tools/journals.py +310 -0
- chronos_mcp/tools/tasks.py +414 -0
- chronos_mcp/utils.py +163 -0
- chronos_mcp/validation.py +636 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +91 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_accounts.py +380 -0
- tests/unit/test_accounts_ssrf.py +134 -0
- tests/unit/test_base.py +135 -0
- tests/unit/test_bulk.py +380 -0
- tests/unit/test_bulk_create.py +408 -0
- tests/unit/test_bulk_delete.py +341 -0
- tests/unit/test_bulk_resource_limits.py +74 -0
- tests/unit/test_caldav_utils.py +300 -0
- tests/unit/test_calendars.py +286 -0
- tests/unit/test_config.py +111 -0
- tests/unit/test_config_validation.py +128 -0
- tests/unit/test_credentials_security.py +189 -0
- tests/unit/test_cryptography_security.py +178 -0
- tests/unit/test_events.py +536 -0
- tests/unit/test_exceptions.py +58 -0
- tests/unit/test_journals.py +1097 -0
- tests/unit/test_models.py +95 -0
- tests/unit/test_race_conditions.py +202 -0
- tests/unit/test_recurring_events.py +156 -0
- tests/unit/test_rrule.py +217 -0
- tests/unit/test_search.py +372 -0
- tests/unit/test_search_advanced.py +333 -0
- tests/unit/test_server_input_validation.py +219 -0
- tests/unit/test_ssrf_protection.py +505 -0
- tests/unit/test_tasks.py +918 -0
- tests/unit/test_thread_safety.py +301 -0
- tests/unit/test_tools_journals.py +617 -0
- tests/unit/test_tools_tasks.py +968 -0
- tests/unit/test_url_validation_security.py +234 -0
- tests/unit/test_utils.py +180 -0
- 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
|
+
)
|
tests/unit/test_base.py
ADDED
|
@@ -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
|