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,301 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thread safety tests for connection management
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from unittest.mock import MagicMock, Mock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from chronos_mcp.accounts import AccountManager
|
|
12
|
+
from chronos_mcp.config import ConfigManager
|
|
13
|
+
from chronos_mcp.models import Account, AccountStatus
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestThreadSafety:
|
|
17
|
+
"""Test thread safety of connection management"""
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def mock_config_with_account(self):
|
|
21
|
+
"""Create a mock config manager with a test account"""
|
|
22
|
+
config_manager = Mock(spec=ConfigManager)
|
|
23
|
+
|
|
24
|
+
# Create a test account
|
|
25
|
+
test_account = Account(
|
|
26
|
+
alias="test_account",
|
|
27
|
+
url="https://caldav.example.com/",
|
|
28
|
+
username="testuser",
|
|
29
|
+
password="testpass",
|
|
30
|
+
display_name="Test Account",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
config_manager.get_account.return_value = test_account
|
|
34
|
+
|
|
35
|
+
# Mock the config attribute and its default_account
|
|
36
|
+
mock_config = Mock()
|
|
37
|
+
mock_config.default_account = "test_account"
|
|
38
|
+
config_manager.config = mock_config
|
|
39
|
+
|
|
40
|
+
return config_manager
|
|
41
|
+
|
|
42
|
+
def test_concurrent_connection_creation(self, mock_config_with_account):
|
|
43
|
+
"""Test that concurrent connection attempts don't create duplicate connections"""
|
|
44
|
+
with (
|
|
45
|
+
patch("chronos_mcp.accounts.DAVClient") as mock_dav_client,
|
|
46
|
+
patch("chronos_mcp.accounts.get_credential_manager") as mock_cred_mgr,
|
|
47
|
+
):
|
|
48
|
+
|
|
49
|
+
# Setup mocks
|
|
50
|
+
mock_client = Mock()
|
|
51
|
+
mock_principal = Mock()
|
|
52
|
+
mock_client.principal.return_value = mock_principal
|
|
53
|
+
mock_dav_client.return_value = mock_client
|
|
54
|
+
|
|
55
|
+
mock_cred_mgr.return_value.get_password.return_value = "testpass"
|
|
56
|
+
|
|
57
|
+
manager = AccountManager(mock_config_with_account)
|
|
58
|
+
|
|
59
|
+
# Track connection attempts
|
|
60
|
+
connection_attempts = []
|
|
61
|
+
original_connect = manager.connect_account
|
|
62
|
+
|
|
63
|
+
def track_connect(alias, request_id=None):
|
|
64
|
+
connection_attempts.append(time.time())
|
|
65
|
+
# Add small delay to increase chance of race condition
|
|
66
|
+
time.sleep(0.01)
|
|
67
|
+
return original_connect(alias, request_id)
|
|
68
|
+
|
|
69
|
+
manager.connect_account = track_connect
|
|
70
|
+
|
|
71
|
+
# Create multiple threads that try to get the same connection
|
|
72
|
+
threads = []
|
|
73
|
+
results = []
|
|
74
|
+
|
|
75
|
+
def get_connection_worker():
|
|
76
|
+
try:
|
|
77
|
+
conn = manager.get_connection("test_account")
|
|
78
|
+
results.append(conn)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
results.append(e)
|
|
81
|
+
|
|
82
|
+
# Start multiple threads simultaneously
|
|
83
|
+
for _ in range(5):
|
|
84
|
+
thread = threading.Thread(target=get_connection_worker)
|
|
85
|
+
threads.append(thread)
|
|
86
|
+
|
|
87
|
+
# Start all threads at roughly the same time
|
|
88
|
+
for thread in threads:
|
|
89
|
+
thread.start()
|
|
90
|
+
|
|
91
|
+
# Wait for all threads to complete
|
|
92
|
+
for thread in threads:
|
|
93
|
+
thread.join(timeout=5.0)
|
|
94
|
+
assert not thread.is_alive(), "Thread took too long to complete"
|
|
95
|
+
|
|
96
|
+
# Verify results
|
|
97
|
+
assert len(results) == 5, "All threads should have completed"
|
|
98
|
+
assert all(
|
|
99
|
+
r is not None for r in results
|
|
100
|
+
), "All threads should have gotten a connection"
|
|
101
|
+
|
|
102
|
+
# Most importantly: only one connection should have been created
|
|
103
|
+
assert len(manager.connections) == 1, "Only one connection should exist"
|
|
104
|
+
assert (
|
|
105
|
+
"test_account" in manager.connections
|
|
106
|
+
), "Connection should be for test_account"
|
|
107
|
+
|
|
108
|
+
# Verify connect was called only once despite multiple concurrent requests
|
|
109
|
+
# (The exact number may vary due to timing, but should be minimal)
|
|
110
|
+
assert (
|
|
111
|
+
len(connection_attempts) <= 2
|
|
112
|
+
), f"Too many connection attempts: {len(connection_attempts)}"
|
|
113
|
+
|
|
114
|
+
def test_concurrent_principal_access(self, mock_config_with_account):
|
|
115
|
+
"""Test that concurrent principal access is thread-safe"""
|
|
116
|
+
with (
|
|
117
|
+
patch("chronos_mcp.accounts.DAVClient") as mock_dav_client,
|
|
118
|
+
patch("chronos_mcp.accounts.get_credential_manager") as mock_cred_mgr,
|
|
119
|
+
):
|
|
120
|
+
|
|
121
|
+
# Setup mocks
|
|
122
|
+
mock_client = Mock()
|
|
123
|
+
mock_principal = Mock()
|
|
124
|
+
mock_client.principal.return_value = mock_principal
|
|
125
|
+
mock_dav_client.return_value = mock_client
|
|
126
|
+
|
|
127
|
+
mock_cred_mgr.return_value.get_password.return_value = "testpass"
|
|
128
|
+
|
|
129
|
+
manager = AccountManager(mock_config_with_account)
|
|
130
|
+
|
|
131
|
+
# Create multiple threads that try to get the same principal
|
|
132
|
+
threads = []
|
|
133
|
+
results = []
|
|
134
|
+
|
|
135
|
+
def get_principal_worker():
|
|
136
|
+
try:
|
|
137
|
+
principal = manager.get_principal("test_account")
|
|
138
|
+
results.append(principal)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
results.append(e)
|
|
141
|
+
|
|
142
|
+
# Start multiple threads simultaneously
|
|
143
|
+
for _ in range(3):
|
|
144
|
+
thread = threading.Thread(target=get_principal_worker)
|
|
145
|
+
threads.append(thread)
|
|
146
|
+
|
|
147
|
+
for thread in threads:
|
|
148
|
+
thread.start()
|
|
149
|
+
|
|
150
|
+
for thread in threads:
|
|
151
|
+
thread.join(timeout=5.0)
|
|
152
|
+
assert not thread.is_alive(), "Thread took too long to complete"
|
|
153
|
+
|
|
154
|
+
# Verify results
|
|
155
|
+
assert len(results) == 3, "All threads should have completed"
|
|
156
|
+
assert all(
|
|
157
|
+
r is not None for r in results
|
|
158
|
+
), "All threads should have gotten a principal"
|
|
159
|
+
|
|
160
|
+
# Only one principal should exist in cache
|
|
161
|
+
assert len(manager.principals) == 1, "Only one principal should exist"
|
|
162
|
+
assert (
|
|
163
|
+
"test_account" in manager.principals
|
|
164
|
+
), "Principal should be for test_account"
|
|
165
|
+
|
|
166
|
+
def test_connection_lock_per_account(self, mock_config_with_account):
|
|
167
|
+
"""Test that different accounts have different locks"""
|
|
168
|
+
with (
|
|
169
|
+
patch("chronos_mcp.accounts.DAVClient") as mock_dav_client,
|
|
170
|
+
patch("chronos_mcp.accounts.get_credential_manager") as mock_cred_mgr,
|
|
171
|
+
):
|
|
172
|
+
|
|
173
|
+
# Setup mocks for multiple accounts
|
|
174
|
+
mock_client = Mock()
|
|
175
|
+
mock_principal = Mock()
|
|
176
|
+
mock_client.principal.return_value = mock_principal
|
|
177
|
+
mock_dav_client.return_value = mock_client
|
|
178
|
+
|
|
179
|
+
mock_cred_mgr.return_value.get_password.return_value = "testpass"
|
|
180
|
+
|
|
181
|
+
# Setup config to return different accounts
|
|
182
|
+
def get_account_side_effect(alias):
|
|
183
|
+
return Account(
|
|
184
|
+
alias=alias,
|
|
185
|
+
url=f"https://{alias}.example.com/",
|
|
186
|
+
username="testuser",
|
|
187
|
+
password="testpass",
|
|
188
|
+
display_name=f"Test Account {alias}",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
mock_config_with_account.get_account.side_effect = get_account_side_effect
|
|
192
|
+
|
|
193
|
+
manager = AccountManager(mock_config_with_account)
|
|
194
|
+
|
|
195
|
+
# Access connections for different accounts concurrently
|
|
196
|
+
results = []
|
|
197
|
+
|
|
198
|
+
def get_connection_worker(account_alias):
|
|
199
|
+
try:
|
|
200
|
+
conn = manager.get_connection(account_alias)
|
|
201
|
+
results.append((account_alias, conn))
|
|
202
|
+
except Exception as e:
|
|
203
|
+
results.append((account_alias, e))
|
|
204
|
+
|
|
205
|
+
threads = []
|
|
206
|
+
account_aliases = ["account1", "account2", "account3"]
|
|
207
|
+
|
|
208
|
+
for alias in account_aliases:
|
|
209
|
+
thread = threading.Thread(target=get_connection_worker, args=(alias,))
|
|
210
|
+
threads.append(thread)
|
|
211
|
+
|
|
212
|
+
for thread in threads:
|
|
213
|
+
thread.start()
|
|
214
|
+
|
|
215
|
+
for thread in threads:
|
|
216
|
+
thread.join(timeout=5.0)
|
|
217
|
+
|
|
218
|
+
# Verify all accounts got connections
|
|
219
|
+
assert len(results) == 3, "All threads should have completed"
|
|
220
|
+
assert (
|
|
221
|
+
len(manager.connections) == 3
|
|
222
|
+
), "Should have connections for all accounts"
|
|
223
|
+
assert (
|
|
224
|
+
len(manager._connection_locks) == 3
|
|
225
|
+
), "Should have locks for all accounts"
|
|
226
|
+
|
|
227
|
+
# Verify different locks for different accounts
|
|
228
|
+
lock_ids = set()
|
|
229
|
+
for alias in account_aliases:
|
|
230
|
+
if alias in manager._connection_locks:
|
|
231
|
+
lock_ids.add(id(manager._connection_locks[alias]))
|
|
232
|
+
|
|
233
|
+
assert len(lock_ids) == 3, "Each account should have its own lock instance"
|
|
234
|
+
|
|
235
|
+
def test_error_handling_in_concurrent_access(self, mock_config_with_account):
|
|
236
|
+
"""Test that errors in one thread don't affect others"""
|
|
237
|
+
with (
|
|
238
|
+
patch("chronos_mcp.accounts.DAVClient") as mock_dav_client,
|
|
239
|
+
patch("chronos_mcp.accounts.get_credential_manager") as mock_cred_mgr,
|
|
240
|
+
):
|
|
241
|
+
|
|
242
|
+
# Setup mock to fail on first call, succeed on others
|
|
243
|
+
call_count = 0
|
|
244
|
+
|
|
245
|
+
def failing_connect(*args, **kwargs):
|
|
246
|
+
nonlocal call_count
|
|
247
|
+
call_count += 1
|
|
248
|
+
if call_count == 1:
|
|
249
|
+
raise Exception("First connection fails")
|
|
250
|
+
|
|
251
|
+
mock_client = Mock()
|
|
252
|
+
mock_principal = Mock()
|
|
253
|
+
mock_client.principal.return_value = mock_principal
|
|
254
|
+
return mock_client
|
|
255
|
+
|
|
256
|
+
mock_dav_client.side_effect = failing_connect
|
|
257
|
+
mock_cred_mgr.return_value.get_password.return_value = "testpass"
|
|
258
|
+
|
|
259
|
+
manager = AccountManager(mock_config_with_account)
|
|
260
|
+
|
|
261
|
+
results = []
|
|
262
|
+
errors = []
|
|
263
|
+
|
|
264
|
+
def get_connection_worker():
|
|
265
|
+
try:
|
|
266
|
+
conn = manager.get_connection("test_account")
|
|
267
|
+
results.append(conn)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
errors.append(e)
|
|
270
|
+
|
|
271
|
+
# Start multiple threads
|
|
272
|
+
threads = []
|
|
273
|
+
for _ in range(3):
|
|
274
|
+
thread = threading.Thread(target=get_connection_worker)
|
|
275
|
+
threads.append(thread)
|
|
276
|
+
thread.start()
|
|
277
|
+
|
|
278
|
+
for thread in threads:
|
|
279
|
+
thread.join(timeout=5.0)
|
|
280
|
+
|
|
281
|
+
# One thread should have failed, others should succeed or get None
|
|
282
|
+
# (Exact behavior depends on timing and error handling)
|
|
283
|
+
total_attempts = len(results) + len(errors)
|
|
284
|
+
assert total_attempts == 3, "All threads should have completed"
|
|
285
|
+
|
|
286
|
+
def test_lock_cleanup_on_disconnect(self, mock_config_with_account):
|
|
287
|
+
"""Test that locks are properly managed when connections are disconnected"""
|
|
288
|
+
manager = AccountManager(mock_config_with_account)
|
|
289
|
+
|
|
290
|
+
# Force creation of locks by accessing _connection_locks
|
|
291
|
+
manager._connection_locks["test_account"] = threading.Lock()
|
|
292
|
+
manager.connections["test_account"] = Mock()
|
|
293
|
+
manager.principals["test_account"] = Mock()
|
|
294
|
+
|
|
295
|
+
# Disconnect should clean up connections and principals
|
|
296
|
+
manager.disconnect_account("test_account")
|
|
297
|
+
|
|
298
|
+
assert "test_account" not in manager.connections
|
|
299
|
+
assert "test_account" not in manager.principals
|
|
300
|
+
# Note: We intentionally keep locks around to avoid lock creation overhead
|
|
301
|
+
# This is acceptable since locks are lightweight
|