webex-message-handler 0.6.7__tar.gz → 0.6.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/PKG-INFO +16 -1
  2. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/README.md +15 -0
  3. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/pyproject.toml +1 -1
  4. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/__init__.py +5 -0
  5. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/handler.py +19 -1
  6. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/kms_client.py +108 -58
  7. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/types.py +26 -0
  8. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/url_validation.py +2 -2
  9. webex_message_handler-0.6.9/tests/test_kms_client.py +103 -0
  10. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/.gitignore +0 -0
  11. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/API.md +0 -0
  12. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/LICENSE +0 -0
  13. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/examples/basic_bot.py +0 -0
  14. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/device_manager.py +0 -0
  15. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/errors.py +0 -0
  16. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/id_utils.py +0 -0
  17. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/logger.py +0 -0
  18. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/mention_parser.py +0 -0
  19. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/mercury_socket.py +0 -0
  20. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/src/webex_message_handler/message_decryptor.py +0 -0
  21. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/test-proxy.py +0 -0
  22. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/__init__.py +0 -0
  23. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/conftest.py +0 -0
  24. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_device_manager.py +0 -0
  25. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_handler.py +0 -0
  26. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_id_utils.py +0 -0
  27. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_integration.py +0 -0
  28. {webex_message_handler-0.6.7 → webex_message_handler-0.6.9}/tests/test_message_decryptor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webex-message-handler
3
- Version: 0.6.7
3
+ Version: 0.6.9
4
4
  Summary: Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages without the full Webex SDK
5
5
  Project-URL: Homepage, https://github.com/3rg0n/webex-message-handler
6
6
  Project-URL: Repository, https://github.com/3rg0n/webex-message-handler
@@ -187,6 +187,20 @@ async def on_message(msg):
187
187
 
188
188
  Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
189
189
 
190
+ ## Delivery Guarantees
191
+
192
+ This library provides **at-most-once** delivery semantics:
193
+
194
+ - Mercury WebSocket acknowledges messages at the protocol level on receipt, before decryption or consumer delivery.
195
+ - If decryption fails (e.g., KMS outage) or your callback throws an error, the message is **not redelivered**.
196
+ - Mercury does not support application-level ACK/NACK — this is an inherent constraint of the Webex platform.
197
+
198
+ **For consumers requiring stronger guarantees:**
199
+
200
+ - Wrap your callback with a persistent queue (e.g., database, Redis, or message broker) to ensure processing completes.
201
+ - Use the `error` event to detect and log decryption failures.
202
+ - The KMS circuit breaker (v0.6.9+) prevents 30-second stalls during KMS outages by failing fast after 3 consecutive failures.
203
+
190
204
  ## API Reference
191
205
 
192
206
  ### `WebexMessageHandler`
@@ -211,6 +225,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
211
225
  | `pong_timeout` | `float` | `14.0` | Pong response timeout (seconds) |
212
226
  | `reconnect_backoff_max` | `float` | `32.0` | Max reconnect backoff (seconds) |
213
227
  | `max_reconnect_attempts` | `int` | `10` | Max reconnect attempts |
228
+ | `metrics_callback` | `MetricsCallback \| None` | `None` | Optional callback for timing metrics (`connect`, `decrypt` events) |
214
229
 
215
230
  #### Methods
216
231
 
@@ -155,6 +155,20 @@ async def on_message(msg):
155
155
 
156
156
  Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
157
157
 
158
+ ## Delivery Guarantees
159
+
160
+ This library provides **at-most-once** delivery semantics:
161
+
162
+ - Mercury WebSocket acknowledges messages at the protocol level on receipt, before decryption or consumer delivery.
163
+ - If decryption fails (e.g., KMS outage) or your callback throws an error, the message is **not redelivered**.
164
+ - Mercury does not support application-level ACK/NACK — this is an inherent constraint of the Webex platform.
165
+
166
+ **For consumers requiring stronger guarantees:**
167
+
168
+ - Wrap your callback with a persistent queue (e.g., database, Redis, or message broker) to ensure processing completes.
169
+ - Use the `error` event to detect and log decryption failures.
170
+ - The KMS circuit breaker (v0.6.9+) prevents 30-second stalls during KMS outages by failing fast after 3 consecutive failures.
171
+
158
172
  ## API Reference
159
173
 
160
174
  ### `WebexMessageHandler`
@@ -179,6 +193,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
179
193
  | `pong_timeout` | `float` | `14.0` | Pong response timeout (seconds) |
180
194
  | `reconnect_backoff_max` | `float` | `32.0` | Max reconnect backoff (seconds) |
181
195
  | `max_reconnect_attempts` | `int` | `10` | Max reconnect attempts |
196
+ | `metrics_callback` | `MetricsCallback \| None` | `None` | Optional callback for timing metrics (`connect`, `decrypt` events) |
182
197
 
183
198
  #### Methods
184
199
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webex-message-handler"
7
- version = "0.6.7"
7
+ version = "0.6.9"
8
8
  description = "Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages without the full Webex SDK"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -28,6 +28,8 @@ from .types import (
28
28
  HandlerStatus,
29
29
  InjectedWebSocket,
30
30
  MembershipActivity,
31
+ MetricsCallback,
32
+ MetricsEvent,
31
33
  MercuryActivity,
32
34
  MercuryActor,
33
35
  MercuryEnvelope,
@@ -88,4 +90,7 @@ __all__ = [
88
90
  "FetchFunction",
89
91
  "InjectedWebSocket",
90
92
  "WebSocketFactory",
93
+ # Metrics types
94
+ "MetricsEvent",
95
+ "MetricsCallback",
91
96
  ]
@@ -27,6 +27,7 @@ from .types import (
27
27
  HandlerStatus,
28
28
  InjectedWebSocket,
29
29
  MembershipActivity,
30
+ MetricsEvent,
30
31
  MercuryActivity,
31
32
  RoomActivity,
32
33
  WebexMessageHandlerConfig,
@@ -125,6 +126,7 @@ class WebexMessageHandler:
125
126
 
126
127
  self._ignore_self_messages = config.ignore_self_messages
127
128
  self._bot_person_id: str | None = None
129
+ self._metrics_callback = config.metrics_callback
128
130
 
129
131
  self._kms_client: KmsClient | None = None
130
132
  self._message_decryptor: MessageDecryptor | None = None
@@ -152,6 +154,12 @@ class WebexMessageHandler:
152
154
 
153
155
  self._setup_mercury_listeners()
154
156
 
157
+ def _report_metric(self, name: str, start_time: float, success: bool, metadata: dict[str, str] | None = None) -> None:
158
+ """Report a timing metric if callback is set."""
159
+ if self._metrics_callback:
160
+ duration_ms = (time.monotonic() - start_time) * 1000
161
+ self._metrics_callback(MetricsEvent(name=name, duration_ms=duration_ms, success=success, metadata=metadata))
162
+
155
163
  def _create_native_http_adapter(
156
164
  self, connector: aiohttp.BaseConnector | None
157
165
  ) -> FetchFunction:
@@ -275,6 +283,7 @@ class WebexMessageHandler:
275
283
  self._logger.info("Connecting to Webex...")
276
284
  self._connecting = True
277
285
 
286
+ connect_start = time.monotonic()
278
287
  try:
279
288
  # Step 1: Register device with WDM
280
289
  self._registration = await self._device_manager.register(self._token)
@@ -314,10 +323,12 @@ class WebexMessageHandler:
314
323
  self._connecting = False
315
324
  self._connected = True
316
325
  self._logger.info("Connected to Webex")
326
+ self._report_metric("connect", connect_start, True)
317
327
  self._emit("connected")
318
328
 
319
329
  except Exception:
320
330
  self._connecting = False
331
+ self._report_metric("connect", connect_start, False)
321
332
  raise
322
333
 
323
334
  async def disconnect(self) -> None:
@@ -468,7 +479,14 @@ class WebexMessageHandler:
468
479
  self._logger.warning("Received activity but decryptor not initialized")
469
480
  return
470
481
 
471
- decrypted = await self._message_decryptor.decrypt_activity(activity)
482
+ decrypt_start = time.monotonic()
483
+ try:
484
+ decrypted = await self._message_decryptor.decrypt_activity(activity)
485
+ self._report_metric("decrypt", decrypt_start, True)
486
+ except Exception:
487
+ self._report_metric("decrypt", decrypt_start, False)
488
+ raise
489
+
472
490
  mentions = parse_mentions(decrypted.object.content)
473
491
  message = DecryptedMessage(
474
492
  id=decrypted.id,
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import asyncio
15
15
  import json
16
+ import time
16
17
  import uuid
17
18
  from datetime import datetime, timedelta, timezone
18
19
  from typing import Any
@@ -26,6 +27,8 @@ from .types import FetchFunction, FetchRequest
26
27
  from .url_validation import validate_webex_url
27
28
 
28
29
  KMS_RESPONSE_TIMEOUT = 30.0 # seconds
30
+ CB_FAILURE_THRESHOLD = 3
31
+ CB_COOLDOWN_SECONDS = 30.0
29
32
 
30
33
 
31
34
  class KmsClient:
@@ -64,6 +67,11 @@ class KmsClient:
64
67
  # Lock to serialize KMS HTTP requests
65
68
  self._kms_request_lock = asyncio.Lock()
66
69
 
70
+ # Circuit breaker state
71
+ self._cb_state: str = "closed" # "closed", "open", "half_open"
72
+ self._cb_failures: int = 0
73
+ self._cb_last_failure: float = 0.0
74
+
67
75
  def handle_kms_message(self, data: dict[str, Any]) -> None:
68
76
  """Handle a KMS response that arrived via Mercury WebSocket.
69
77
 
@@ -106,8 +114,8 @@ class KmsClient:
106
114
  kms_details = await response.json()
107
115
 
108
116
  self._kms_cluster = kms_details["kmsCluster"]
109
- # Validate KMS cluster URL
110
- validate_webex_url(self._kms_cluster, "https")
117
+ # Validate KMS cluster URL — Webex returns kms:// scheme for this field
118
+ validate_webex_url(self._kms_cluster, "kms")
111
119
 
112
120
  rsa_public_key_raw = kms_details["rsaPublicKey"]
113
121
  if isinstance(rsa_public_key_raw, str):
@@ -231,69 +239,91 @@ class KmsClient:
231
239
  if not self._initialized or not self._ephemeral_key:
232
240
  raise KmsError("KMS context not initialized")
233
241
 
234
- # Build retrieve request
235
- request_id = str(uuid.uuid4())
236
- retrieve_body = {
237
- "client": {
238
- "clientId": self._device_url,
239
- "credential": {
240
- "userId": self._user_id,
241
- "bearer": self._token,
242
- },
243
- },
244
- "method": "retrieve",
245
- "uri": key_uri,
246
- "requestId": request_id,
247
- }
248
-
249
- # Wrap with ephemeral key (dir + A256GCM — key is CEK directly)
250
- wrapped = _jwe_encrypt(
251
- plaintext=json.dumps(retrieve_body).encode(),
252
- key=self._ephemeral_key,
253
- alg="dir",
254
- enc="A256GCM",
255
- )
256
-
257
- # POST and wait for Mercury response
258
- wrapped_response = await self._send_kms_request(request_id, wrapped)
259
-
260
- # Unwrap response
261
- response_body = _unwrap_kms_response(wrapped_response, self._ephemeral_key)
262
- response_data = json.loads(response_body)
263
-
264
- # Extract the content key
265
- key_data = (
266
- response_data.get("body", {}).get("key", {}).get("jwk")
267
- or response_data.get("key", {}).get("jwk")
268
- )
269
- if (
270
- not key_data
271
- and "body" in response_data
272
- and "key" in response_data["body"]
273
- ):
274
- key_obj = response_data["body"]["key"]
275
- key_data = key_obj["jwk"] if isinstance(key_obj, dict) and "jwk" in key_obj else key_obj
276
-
277
- if not key_data:
278
- raise KmsError("No key found in KMS response")
279
-
280
- if isinstance(key_data, str):
281
- key_data = json.loads(key_data)
242
+ # Check circuit breaker state
243
+ if self._cb_state == "open":
244
+ if time.monotonic() - self._cb_last_failure >= CB_COOLDOWN_SECONDS:
245
+ self._cb_state = "half_open"
246
+ self._logger.info("KMS circuit breaker transitioned to half_open — attempting recovery")
247
+ else:
248
+ raise KmsError("KMS circuit breaker is open — failing fast")
249
+
250
+ # Build retrieve request with retry logic
251
+ max_attempts = 2
252
+ for attempt in range(1, max_attempts + 1):
253
+ try:
254
+ request_id = str(uuid.uuid4())
255
+ retrieve_body = {
256
+ "client": {
257
+ "clientId": self._device_url,
258
+ "credential": {
259
+ "userId": self._user_id,
260
+ "bearer": self._token,
261
+ },
262
+ },
263
+ "method": "retrieve",
264
+ "uri": key_uri,
265
+ "requestId": request_id,
266
+ }
267
+
268
+ # Wrap with ephemeral key (dir + A256GCM — key is CEK directly)
269
+ wrapped = _jwe_encrypt(
270
+ plaintext=json.dumps(retrieve_body).encode(),
271
+ key=self._ephemeral_key,
272
+ alg="dir",
273
+ enc="A256GCM",
274
+ )
282
275
 
283
- content_key = jwk.JWK(**key_data)
276
+ # POST and wait for Mercury response
277
+ wrapped_response = await self._send_kms_request(request_id, wrapped)
284
278
 
285
- # Bound key cache size
286
- if len(self._key_cache) > 100:
287
- self._logger.warning(f"Key cache size exceeded (size={len(self._key_cache)}), clearing cache")
288
- self._key_cache.clear()
279
+ # Unwrap response
280
+ response_body = _unwrap_kms_response(wrapped_response, self._ephemeral_key)
281
+ response_data = json.loads(response_body)
289
282
 
290
- self._key_cache[key_uri] = content_key
291
- self._logger.info(f"Key retrieved and cached: {key_uri}")
292
- return content_key
283
+ # Extract the content key
284
+ key_data = (
285
+ response_data.get("body", {}).get("key", {}).get("jwk")
286
+ or response_data.get("key", {}).get("jwk")
287
+ )
288
+ if (
289
+ not key_data
290
+ and "body" in response_data
291
+ and "key" in response_data["body"]
292
+ ):
293
+ key_obj = response_data["body"]["key"]
294
+ key_data = key_obj["jwk"] if isinstance(key_obj, dict) and "jwk" in key_obj else key_obj
295
+
296
+ if not key_data:
297
+ raise KmsError("No key found in KMS response")
298
+
299
+ if isinstance(key_data, str):
300
+ key_data = json.loads(key_data)
301
+
302
+ content_key = jwk.JWK(**key_data)
303
+
304
+ # Bound key cache size
305
+ if len(self._key_cache) > 100:
306
+ self._logger.warning(f"Key cache size exceeded (size={len(self._key_cache)}), clearing cache")
307
+ self._key_cache.clear()
308
+
309
+ self._key_cache[key_uri] = content_key
310
+ self._logger.info(f"Key retrieved and cached: {key_uri}")
311
+ self._cb_reset()
312
+ return content_key
313
+ except KmsError as exc:
314
+ if attempt < max_attempts and self._is_retryable_kms_error(exc):
315
+ self._logger.warning(
316
+ f"KMS key fetch failed (attempt {attempt}/{max_attempts}), retrying in 1s: {exc}"
317
+ )
318
+ await asyncio.sleep(1.0)
319
+ continue
320
+ self._cb_record_failure()
321
+ raise
293
322
 
294
323
  except KmsError:
295
324
  raise
296
325
  except Exception as exc:
326
+ self._cb_record_failure()
297
327
  raise KmsError(f"Failed to get key: {exc}") from exc
298
328
 
299
329
  async def _send_kms_request(self, request_id: str, wrapped: str) -> str:
@@ -355,6 +385,26 @@ class KmsClient:
355
385
  buffer = timedelta(seconds=30)
356
386
  return datetime.now(timezone.utc) > (self._context_expiration - buffer)
357
387
 
388
+ def _is_retryable_kms_error(self, error: Exception) -> bool:
389
+ """Check if a KMS error is retryable (transient failure)."""
390
+ msg = str(error).lower()
391
+ return "timed out" in msg or "http request failed" in msg
392
+
393
+ def _cb_reset(self) -> None:
394
+ """Reset circuit breaker to closed state (KMS recovered)."""
395
+ if self._cb_state != "closed":
396
+ self._logger.info("KMS circuit breaker closed — KMS recovered")
397
+ self._cb_state = "closed"
398
+ self._cb_failures = 0
399
+
400
+ def _cb_record_failure(self) -> None:
401
+ """Record a KMS failure and update circuit breaker state."""
402
+ self._cb_failures += 1
403
+ self._cb_last_failure = time.monotonic()
404
+ if self._cb_failures >= CB_FAILURE_THRESHOLD:
405
+ self._cb_state = "open"
406
+ self._logger.warning(f"KMS circuit breaker opened after {self._cb_failures} consecutive failures")
407
+
358
408
 
359
409
  class _PendingRequest:
360
410
  """A pending KMS request awaiting Mercury response."""
@@ -107,6 +107,9 @@ class WebexMessageHandlerConfig:
107
107
  ignore_self_messages: bool = True
108
108
  """Automatically filter out messages sent by this bot to prevent loops (default: True)."""
109
109
 
110
+ metrics_callback: "MetricsCallback | None" = None
111
+ """Optional metrics callback for timing events (no overhead if not set)."""
112
+
110
113
 
111
114
  # --- Device Registration ---
112
115
 
@@ -359,3 +362,26 @@ class HandlerStatus:
359
362
 
360
363
  reconnect_attempt: int
361
364
  """Current auto-reconnect attempt number (0 if not reconnecting)."""
365
+
366
+
367
+ # --- Metrics ---
368
+
369
+
370
+ @dataclass
371
+ class MetricsEvent:
372
+ """A timing metric event."""
373
+
374
+ name: str
375
+ """Metric name: 'connect', 'kms_fetch', or 'decrypt'."""
376
+
377
+ duration_ms: float
378
+ """Duration in milliseconds."""
379
+
380
+ success: bool
381
+ """Whether the operation succeeded."""
382
+
383
+ metadata: dict[str, str] | None = None
384
+ """Optional context metadata (e.g., key URI for kms_fetch)."""
385
+
386
+
387
+ MetricsCallback = Callable[["MetricsEvent"], None]
@@ -2,7 +2,7 @@
2
2
 
3
3
  from urllib.parse import urlparse
4
4
 
5
- ALLOWED_DOMAIN_SUFFIXES = ('.webex.com', '.wbx2.com', '.ciscospark.com', '.example.com')
5
+ ALLOWED_WEBEX_DOMAINS = ("webex.com", "wbx2.com", "ciscospark.com", "example.com")
6
6
 
7
7
 
8
8
  def validate_webex_url(raw_url: str, required_scheme: str) -> None:
@@ -19,5 +19,5 @@ def validate_webex_url(raw_url: str, required_scheme: str) -> None:
19
19
  if parsed.scheme != required_scheme:
20
20
  raise ValueError(f"URL scheme must be {required_scheme}, got {parsed.scheme}")
21
21
  host = (parsed.hostname or "").lower()
22
- if not any(host.endswith(suffix) for suffix in ALLOWED_DOMAIN_SUFFIXES):
22
+ if not any(host == domain or host.endswith(f".{domain}") for domain in ALLOWED_WEBEX_DOMAINS):
23
23
  raise ValueError(f"URL host {host} is not a recognized Webex domain")
@@ -0,0 +1,103 @@
1
+ """Tests for KmsClient."""
2
+
3
+ import json
4
+ from unittest.mock import AsyncMock
5
+
6
+ import pytest
7
+ from jwcrypto import jwk
8
+ from jwcrypto.common import base64url_encode
9
+
10
+ from webex_message_handler.errors import KmsError
11
+ from webex_message_handler.kms_client import KmsClient
12
+ from webex_message_handler.types import FetchRequest
13
+
14
+
15
+ class _FakeFetchResponse:
16
+ def __init__(self, *, status: int, ok: bool, payload: dict[str, object]) -> None:
17
+ self.status = status
18
+ self.ok = ok
19
+ self._payload = payload
20
+
21
+ async def json(self) -> dict[str, object]:
22
+ return self._payload
23
+
24
+ async def text(self) -> str:
25
+ return json.dumps(self._payload)
26
+
27
+
28
+ def _make_kms_response_token(payload: dict[str, object]) -> str:
29
+ header = base64url_encode(b'{"alg":"none"}')
30
+ body = base64url_encode(json.dumps(payload).encode("utf-8"))
31
+ signature = base64url_encode(b"signature")
32
+ return f"{header}.{body}.{signature}"
33
+
34
+
35
+ def _build_kms_details(*, kms_cluster: str) -> dict[str, object]:
36
+ rsa_public_key = jwk.JWK.generate(kty="RSA", size=2048)
37
+ return {
38
+ "kmsCluster": kms_cluster,
39
+ "rsaPublicKey": json.loads(rsa_public_key.export_public()),
40
+ }
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_initialize_accepts_kms_cluster_url() -> None:
45
+ requests: list[FetchRequest] = []
46
+ kms_details = _build_kms_details(kms_cluster="kms://ciscospark.com/keys")
47
+ remote_key = jwk.JWK.generate(kty="EC", crv="P-256")
48
+ wrapped_response = _make_kms_response_token(
49
+ {
50
+ "body": {
51
+ "key": {
52
+ "jwk": json.loads(remote_key.export_public()),
53
+ "uri": "kms://ciscospark.com/keys/key/123",
54
+ "expirationDate": "2026-01-01T00:00:00Z",
55
+ }
56
+ }
57
+ }
58
+ )
59
+
60
+ async def http_do(request: FetchRequest) -> _FakeFetchResponse:
61
+ requests.append(request)
62
+ return _FakeFetchResponse(status=200, ok=True, payload=kms_details)
63
+
64
+ client = KmsClient(
65
+ token="test-token",
66
+ device_url="https://device.example.com",
67
+ user_id="user-123",
68
+ encryption_service_url="https://encryption.example.com",
69
+ http_do=http_do,
70
+ )
71
+ client._send_kms_request = AsyncMock(return_value=wrapped_response) # type: ignore[method-assign]
72
+
73
+ await client.initialize()
74
+
75
+ assert client._initialized is True
76
+ assert client._kms_cluster == "kms://ciscospark.com/keys"
77
+ assert client._ephemeral_key is not None
78
+ assert client._ephemeral_key.get("kid") == "kms://ciscospark.com/keys/key/123"
79
+ assert len(requests) == 1
80
+ assert requests[0].method == "GET"
81
+ assert requests[0].url == "https://encryption.example.com/kms/user-123"
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_initialize_rejects_https_kms_cluster_url() -> None:
86
+ kms_details = _build_kms_details(kms_cluster="https://ciscospark.com/keys")
87
+
88
+ async def http_do(request: FetchRequest) -> _FakeFetchResponse:
89
+ return _FakeFetchResponse(status=200, ok=True, payload=kms_details)
90
+
91
+ client = KmsClient(
92
+ token="test-token",
93
+ device_url="https://device.example.com",
94
+ user_id="user-123",
95
+ encryption_service_url="https://encryption.example.com",
96
+ http_do=http_do,
97
+ )
98
+ client._send_kms_request = AsyncMock() # type: ignore[method-assign]
99
+
100
+ with pytest.raises(KmsError, match="URL scheme must be kms, got https"):
101
+ await client.initialize()
102
+
103
+ client._send_kms_request.assert_not_called()