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
chronos_mcp/__init__.py
ADDED
chronos_mcp/__main__.py
ADDED
chronos_mcp/accounts.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Account management for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
import caldav
|
|
13
|
+
from caldav import DAVClient, Principal
|
|
14
|
+
|
|
15
|
+
from .config import ConfigManager
|
|
16
|
+
from .credentials import get_credential_manager
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
AccountAuthenticationError,
|
|
19
|
+
AccountConnectionError,
|
|
20
|
+
AccountNotFoundError,
|
|
21
|
+
ChronosError,
|
|
22
|
+
ErrorHandler,
|
|
23
|
+
ErrorSanitizer,
|
|
24
|
+
)
|
|
25
|
+
from .logging_config import setup_logging
|
|
26
|
+
from .models import AccountStatus
|
|
27
|
+
|
|
28
|
+
logger = setup_logging()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CircuitBreakerState(Enum):
|
|
32
|
+
"""Circuit breaker states"""
|
|
33
|
+
|
|
34
|
+
CLOSED = "closed"
|
|
35
|
+
OPEN = "open"
|
|
36
|
+
HALF_OPEN = "half_open"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CircuitBreaker:
|
|
41
|
+
"""Circuit breaker for connection failures"""
|
|
42
|
+
|
|
43
|
+
failure_count: int = 0
|
|
44
|
+
failure_threshold: int = 5
|
|
45
|
+
recovery_timeout: int = 60 # seconds
|
|
46
|
+
last_failure_time: float = 0
|
|
47
|
+
state: CircuitBreakerState = CircuitBreakerState.CLOSED
|
|
48
|
+
|
|
49
|
+
def should_allow_request(self) -> bool:
|
|
50
|
+
"""Check if request should be allowed through circuit breaker"""
|
|
51
|
+
if self.state == CircuitBreakerState.CLOSED:
|
|
52
|
+
return True
|
|
53
|
+
elif self.state == CircuitBreakerState.OPEN:
|
|
54
|
+
if time.time() - self.last_failure_time >= self.recovery_timeout:
|
|
55
|
+
self.state = CircuitBreakerState.HALF_OPEN
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
else: # HALF_OPEN
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def record_success(self):
|
|
62
|
+
"""Record successful operation"""
|
|
63
|
+
self.failure_count = 0
|
|
64
|
+
self.state = CircuitBreakerState.CLOSED
|
|
65
|
+
|
|
66
|
+
def record_failure(self):
|
|
67
|
+
"""Record failed operation"""
|
|
68
|
+
self.failure_count += 1
|
|
69
|
+
self.last_failure_time = time.time()
|
|
70
|
+
|
|
71
|
+
if self.failure_count >= self.failure_threshold:
|
|
72
|
+
self.state = CircuitBreakerState.OPEN
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ConnectionHealth:
|
|
77
|
+
"""Track connection health metrics"""
|
|
78
|
+
|
|
79
|
+
total_attempts: int = 0
|
|
80
|
+
successful_connections: int = 0
|
|
81
|
+
failed_connections: int = 0
|
|
82
|
+
last_success_time: float = 0
|
|
83
|
+
last_failure_time: float = 0
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def success_rate(self) -> float:
|
|
87
|
+
if self.total_attempts == 0:
|
|
88
|
+
return 1.0
|
|
89
|
+
return self.successful_connections / self.total_attempts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AccountManager:
|
|
93
|
+
"""Manage CalDAV account connections with lifecycle management"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, config_manager: ConfigManager):
|
|
96
|
+
self.config = config_manager
|
|
97
|
+
self.connections: Dict[str, DAVClient] = {}
|
|
98
|
+
self.principals: Dict[str, Principal] = {}
|
|
99
|
+
self._connection_locks: Dict[str, threading.Lock] = {}
|
|
100
|
+
self._connection_timestamps: Dict[str, float] = {}
|
|
101
|
+
self._connection_ttl_minutes: int = 30 # Connection TTL in minutes
|
|
102
|
+
|
|
103
|
+
# Connection pool limits and health tracking
|
|
104
|
+
self._max_connections_per_account: int = 3
|
|
105
|
+
self._connection_timeout: int = 30 # Connection timeout in seconds
|
|
106
|
+
self._max_retries: int = 3
|
|
107
|
+
self._base_retry_delay: float = 1.0 # Base delay for exponential backoff
|
|
108
|
+
|
|
109
|
+
# Circuit breaker and health tracking
|
|
110
|
+
self._circuit_breakers: Dict[str, CircuitBreaker] = {}
|
|
111
|
+
self._connection_health: Dict[str, ConnectionHealth] = {}
|
|
112
|
+
|
|
113
|
+
def connect_account(self, alias: str, request_id: Optional[str] = None) -> bool:
|
|
114
|
+
"""Connect to a CalDAV account with circuit breaker and retry logic"""
|
|
115
|
+
request_id = request_id or str(uuid.uuid4())
|
|
116
|
+
|
|
117
|
+
account = self.config.get_account(alias)
|
|
118
|
+
if not account:
|
|
119
|
+
raise AccountNotFoundError(alias, request_id=request_id)
|
|
120
|
+
|
|
121
|
+
# Check connection pool limits
|
|
122
|
+
if (
|
|
123
|
+
alias in self.connections
|
|
124
|
+
and len([k for k in self.connections.keys() if k == alias])
|
|
125
|
+
>= self._max_connections_per_account
|
|
126
|
+
):
|
|
127
|
+
logger.warning(f"Connection pool limit reached for account '{alias}'")
|
|
128
|
+
# Clean up stale connections first
|
|
129
|
+
self._cleanup_stale_connection(alias)
|
|
130
|
+
|
|
131
|
+
# Initialize circuit breaker and health tracking if needed
|
|
132
|
+
if alias not in self._circuit_breakers:
|
|
133
|
+
self._circuit_breakers[alias] = CircuitBreaker()
|
|
134
|
+
if alias not in self._connection_health:
|
|
135
|
+
self._connection_health[alias] = ConnectionHealth()
|
|
136
|
+
|
|
137
|
+
circuit_breaker = self._circuit_breakers[alias]
|
|
138
|
+
health = self._connection_health[alias]
|
|
139
|
+
|
|
140
|
+
# Check circuit breaker
|
|
141
|
+
if not circuit_breaker.should_allow_request():
|
|
142
|
+
health.total_attempts += 1
|
|
143
|
+
health.failed_connections += 1
|
|
144
|
+
logger.error(
|
|
145
|
+
f"Circuit breaker OPEN for account '{alias}' - rejecting connection attempt",
|
|
146
|
+
extra={"request_id": request_id},
|
|
147
|
+
)
|
|
148
|
+
raise AccountConnectionError(
|
|
149
|
+
alias,
|
|
150
|
+
original_error=Exception("Circuit breaker is OPEN"),
|
|
151
|
+
request_id=request_id,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Get password from keyring or fallback to config
|
|
155
|
+
credential_manager = get_credential_manager()
|
|
156
|
+
password = credential_manager.get_password(
|
|
157
|
+
alias, fallback_password=account.password
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if not password:
|
|
161
|
+
raise AccountAuthenticationError(alias, request_id=request_id)
|
|
162
|
+
|
|
163
|
+
# Retry logic with exponential backoff
|
|
164
|
+
last_exception = None
|
|
165
|
+
for attempt in range(self._max_retries):
|
|
166
|
+
health.total_attempts += 1
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
client = DAVClient(
|
|
170
|
+
url=str(account.url),
|
|
171
|
+
username=account.username,
|
|
172
|
+
password=password,
|
|
173
|
+
timeout=self._connection_timeout,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Test connection by getting principal with timeout
|
|
177
|
+
principal = client.principal()
|
|
178
|
+
|
|
179
|
+
# Store connection with timestamp
|
|
180
|
+
self.connections[alias] = client
|
|
181
|
+
self.principals[alias] = principal
|
|
182
|
+
self._connection_timestamps[alias] = time.time()
|
|
183
|
+
|
|
184
|
+
# Ensure lock exists for this connection
|
|
185
|
+
if alias not in self._connection_locks:
|
|
186
|
+
self._connection_locks[alias] = threading.Lock()
|
|
187
|
+
|
|
188
|
+
# Record success
|
|
189
|
+
circuit_breaker.record_success()
|
|
190
|
+
health.successful_connections += 1
|
|
191
|
+
health.last_success_time = time.time()
|
|
192
|
+
|
|
193
|
+
account.status = AccountStatus.CONNECTED
|
|
194
|
+
logger.info(
|
|
195
|
+
f"Successfully connected to account '{alias}' on attempt {attempt + 1}",
|
|
196
|
+
extra={"request_id": request_id},
|
|
197
|
+
)
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
except caldav.lib.error.AuthorizationError as e:
|
|
201
|
+
last_exception = e
|
|
202
|
+
circuit_breaker.record_failure()
|
|
203
|
+
health.failed_connections += 1
|
|
204
|
+
health.last_failure_time = time.time()
|
|
205
|
+
|
|
206
|
+
account.status = AccountStatus.ERROR
|
|
207
|
+
logger.error(
|
|
208
|
+
f"Authentication failed for '{alias}' on attempt {attempt + 1}: {e}",
|
|
209
|
+
extra={"request_id": request_id},
|
|
210
|
+
)
|
|
211
|
+
# Don't retry auth errors
|
|
212
|
+
raise AccountAuthenticationError(alias, request_id=request_id)
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
last_exception = e
|
|
216
|
+
logger.warning(
|
|
217
|
+
f"Connection attempt {attempt + 1} failed for '{alias}': {e}",
|
|
218
|
+
extra={"request_id": request_id},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if attempt < self._max_retries - 1:
|
|
222
|
+
delay = self._base_retry_delay * (2**attempt)
|
|
223
|
+
logger.debug(f"Retrying in {delay} seconds...")
|
|
224
|
+
time.sleep(delay)
|
|
225
|
+
else:
|
|
226
|
+
# All retries exhausted
|
|
227
|
+
circuit_breaker.record_failure()
|
|
228
|
+
health.failed_connections += 1
|
|
229
|
+
health.last_failure_time = time.time()
|
|
230
|
+
|
|
231
|
+
account.status = AccountStatus.ERROR
|
|
232
|
+
logger.error(
|
|
233
|
+
f"All {self._max_retries} connection attempts failed for '{alias}'",
|
|
234
|
+
extra={"request_id": request_id},
|
|
235
|
+
)
|
|
236
|
+
raise AccountConnectionError(
|
|
237
|
+
alias, original_error=last_exception, request_id=request_id
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Should never reach here, but just in case
|
|
241
|
+
raise AccountConnectionError(
|
|
242
|
+
alias, original_error=last_exception, request_id=request_id
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def disconnect_account(self, alias: str):
|
|
246
|
+
"""Disconnect from an account and clean up resources
|
|
247
|
+
|
|
248
|
+
Thread-safety: This method MUST be called while holding self._connection_locks[alias].
|
|
249
|
+
All callers (get_connection, get_principal) acquire lock before calling this method.
|
|
250
|
+
"""
|
|
251
|
+
if alias in self.connections:
|
|
252
|
+
del self.connections[alias]
|
|
253
|
+
if alias in self.principals:
|
|
254
|
+
del self.principals[alias]
|
|
255
|
+
if alias in self._connection_timestamps:
|
|
256
|
+
del self._connection_timestamps[alias]
|
|
257
|
+
# Keep lock for reuse - don't delete self._connection_locks[alias]
|
|
258
|
+
# Reusing locks avoids race where Thread A deletes lock while Thread B tries to acquire it
|
|
259
|
+
# Note: Keep circuit breaker and health data for future connections
|
|
260
|
+
|
|
261
|
+
account = self.config.get_account(alias)
|
|
262
|
+
if account:
|
|
263
|
+
account.status = AccountStatus.DISCONNECTED
|
|
264
|
+
|
|
265
|
+
logger.debug(f"Disconnected and cleaned up resources for account '{alias}'")
|
|
266
|
+
|
|
267
|
+
def _cleanup_stale_connection(self, alias: str):
|
|
268
|
+
"""Clean up a specific stale connection"""
|
|
269
|
+
if alias in self._connection_timestamps:
|
|
270
|
+
age_minutes = (time.time() - self._connection_timestamps[alias]) / 60
|
|
271
|
+
if age_minutes > self._connection_ttl_minutes:
|
|
272
|
+
logger.debug(
|
|
273
|
+
f"Cleaning up stale connection for '{alias}' (age: {age_minutes:.1f} min)"
|
|
274
|
+
)
|
|
275
|
+
self.disconnect_account(alias)
|
|
276
|
+
return True
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
def get_connection_health(self, alias: str) -> Optional[ConnectionHealth]:
|
|
280
|
+
"""Get connection health metrics for an account"""
|
|
281
|
+
return self._connection_health.get(alias)
|
|
282
|
+
|
|
283
|
+
def get_circuit_breaker_status(self, alias: str) -> Optional[CircuitBreakerState]:
|
|
284
|
+
"""Get circuit breaker status for an account"""
|
|
285
|
+
breaker = self._circuit_breakers.get(alias)
|
|
286
|
+
return breaker.state if breaker else None
|
|
287
|
+
|
|
288
|
+
def cleanup_stale_connections(self, max_age_minutes: Optional[int] = None):
|
|
289
|
+
"""Remove connections older than max_age_minutes"""
|
|
290
|
+
max_age = max_age_minutes or self._connection_ttl_minutes
|
|
291
|
+
current_time = time.time()
|
|
292
|
+
stale_aliases = []
|
|
293
|
+
|
|
294
|
+
for alias, timestamp in self._connection_timestamps.items():
|
|
295
|
+
age_minutes = (current_time - timestamp) / 60
|
|
296
|
+
if age_minutes > max_age:
|
|
297
|
+
stale_aliases.append(alias)
|
|
298
|
+
|
|
299
|
+
for alias in stale_aliases:
|
|
300
|
+
age_minutes = (current_time - self._connection_timestamps[alias]) / 60
|
|
301
|
+
logger.debug(
|
|
302
|
+
f"Cleaning up stale connection for account '{alias}' (age: {age_minutes:.1f} minutes)"
|
|
303
|
+
)
|
|
304
|
+
self.disconnect_account(alias)
|
|
305
|
+
|
|
306
|
+
if stale_aliases:
|
|
307
|
+
logger.info(f"Cleaned up {len(stale_aliases)} stale connections")
|
|
308
|
+
|
|
309
|
+
def _is_connection_stale(self, alias: str) -> bool:
|
|
310
|
+
"""Check if a connection is stale"""
|
|
311
|
+
if alias not in self._connection_timestamps:
|
|
312
|
+
return True
|
|
313
|
+
|
|
314
|
+
age_minutes = (time.time() - self._connection_timestamps[alias]) / 60
|
|
315
|
+
return age_minutes > self._connection_ttl_minutes
|
|
316
|
+
|
|
317
|
+
@ErrorHandler.safe_operation(logger, default_return=None)
|
|
318
|
+
def get_connection(self, alias: Optional[str] = None) -> Optional[DAVClient]:
|
|
319
|
+
"""Get connection for an account - internal utility method
|
|
320
|
+
|
|
321
|
+
Thread-safe connection management with proper TOCTOU prevention.
|
|
322
|
+
Staleness check MUST happen inside lock to prevent race conditions.
|
|
323
|
+
"""
|
|
324
|
+
if not alias:
|
|
325
|
+
alias = self.config.config.default_account
|
|
326
|
+
|
|
327
|
+
if not alias:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
# Ensure lock exists before checking staleness
|
|
331
|
+
if alias not in self._connection_locks:
|
|
332
|
+
self._connection_locks[alias] = threading.Lock()
|
|
333
|
+
|
|
334
|
+
with self._connection_locks[alias]:
|
|
335
|
+
# Check staleness INSIDE lock to prevent TOCTOU race
|
|
336
|
+
# Race scenario without this: Thread A checks stale=True outside lock,
|
|
337
|
+
# Thread B connects, Thread A disconnects fresh connection
|
|
338
|
+
if alias not in self.connections or self._is_connection_stale(alias):
|
|
339
|
+
# Clean up stale connection if it exists
|
|
340
|
+
if alias in self.connections:
|
|
341
|
+
logger.debug(f"Connection for '{alias}' is stale, reconnecting")
|
|
342
|
+
self.disconnect_account(alias)
|
|
343
|
+
|
|
344
|
+
# Create new connection
|
|
345
|
+
self.connect_account(alias)
|
|
346
|
+
|
|
347
|
+
return self.connections.get(alias)
|
|
348
|
+
|
|
349
|
+
@ErrorHandler.safe_operation(logger, default_return=None)
|
|
350
|
+
def get_principal(self, alias: Optional[str] = None) -> Optional[Principal]:
|
|
351
|
+
"""Get principal for an account - internal utility method
|
|
352
|
+
|
|
353
|
+
Thread-safe principal access with proper TOCTOU prevention.
|
|
354
|
+
Staleness check MUST happen inside lock to prevent race conditions.
|
|
355
|
+
"""
|
|
356
|
+
if not alias:
|
|
357
|
+
alias = self.config.config.default_account
|
|
358
|
+
|
|
359
|
+
if not alias:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
# Ensure lock exists before checking staleness
|
|
363
|
+
if alias not in self._connection_locks:
|
|
364
|
+
self._connection_locks[alias] = threading.Lock()
|
|
365
|
+
|
|
366
|
+
with self._connection_locks[alias]:
|
|
367
|
+
# Check staleness INSIDE lock to prevent TOCTOU race
|
|
368
|
+
# Same pattern as get_connection() for consistency
|
|
369
|
+
if alias not in self.principals or self._is_connection_stale(alias):
|
|
370
|
+
# Clean up stale connection if it exists
|
|
371
|
+
if alias in self.principals:
|
|
372
|
+
logger.debug(f"Principal for '{alias}' is stale, reconnecting")
|
|
373
|
+
self.disconnect_account(alias)
|
|
374
|
+
|
|
375
|
+
# Create new connection (also updates principals)
|
|
376
|
+
self.connect_account(alias)
|
|
377
|
+
|
|
378
|
+
return self.principals.get(alias)
|
|
379
|
+
|
|
380
|
+
def test_account(
|
|
381
|
+
self, alias: str, request_id: Optional[str] = None
|
|
382
|
+
) -> Dict[str, Any]:
|
|
383
|
+
"""Test account connectivity and return structured result"""
|
|
384
|
+
result = {"alias": alias, "connected": False, "calendars": 0, "error": None}
|
|
385
|
+
|
|
386
|
+
request_id = request_id or str(uuid.uuid4())
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
if self.connect_account(alias, request_id=request_id):
|
|
390
|
+
principal = self.principals.get(alias)
|
|
391
|
+
if principal:
|
|
392
|
+
calendars = principal.calendars()
|
|
393
|
+
result["connected"] = True
|
|
394
|
+
result["calendars"] = len(calendars)
|
|
395
|
+
except ChronosError as e:
|
|
396
|
+
# Use sanitized error message for user response
|
|
397
|
+
result["error"] = ErrorSanitizer.get_user_friendly_message(e)
|
|
398
|
+
logger.error(f"Test account failed: {e}", extra={"request_id": request_id})
|
|
399
|
+
except Exception as e:
|
|
400
|
+
# Unexpected error - wrap and sanitize
|
|
401
|
+
wrapped_error = AccountConnectionError(
|
|
402
|
+
alias, original_error=e, request_id=request_id
|
|
403
|
+
)
|
|
404
|
+
result["error"] = ErrorSanitizer.get_user_friendly_message(wrapped_error)
|
|
405
|
+
logger.error(
|
|
406
|
+
f"Test account failed with unexpected error: {wrapped_error}",
|
|
407
|
+
extra={"request_id": request_id},
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
return result
|