webex-message-handler 0.6.12__tar.gz → 0.6.14__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 (31) hide show
  1. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/PKG-INFO +12 -1
  2. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/README.md +11 -0
  3. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/pyproject.toml +1 -1
  4. webex_message_handler-0.6.14/src/webex_message_handler/device_manager.py +345 -0
  5. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/mention_parser.py +8 -4
  6. webex_message_handler-0.6.14/tests/test_device_manager.py +406 -0
  7. webex_message_handler-0.6.14/tests/test_mention_parser.py +62 -0
  8. webex_message_handler-0.6.12/src/webex_message_handler/device_manager.py +0 -170
  9. webex_message_handler-0.6.12/tests/test_device_manager.py +0 -156
  10. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/.gitignore +0 -0
  11. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/API.md +0 -0
  12. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/LICENSE +0 -0
  13. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/examples/basic_bot.py +0 -0
  14. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/__init__.py +0 -0
  15. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/errors.py +0 -0
  16. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/handler.py +0 -0
  17. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/id_utils.py +0 -0
  18. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/kms_client.py +0 -0
  19. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/logger.py +0 -0
  20. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/mercury_socket.py +0 -0
  21. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/message_decryptor.py +0 -0
  22. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/types.py +0 -0
  23. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/url_validation.py +0 -0
  24. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/test-proxy.py +0 -0
  25. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/__init__.py +0 -0
  26. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/conftest.py +0 -0
  27. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/test_handler.py +0 -0
  28. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/test_id_utils.py +0 -0
  29. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/test_integration.py +0 -0
  30. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/test_kms_client.py +0 -0
  31. {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/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.12
3
+ Version: 0.6.14
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
@@ -33,6 +33,8 @@ Description-Content-Type: text/markdown
33
33
 
34
34
  Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages — no Webex SDK required.
35
35
 
36
+ 📖 **[Documentation & overview →](https://3rg0n.github.io/webex-message-handler/)**
37
+
36
38
  Python port of the [TypeScript webex-message-handler](https://github.com/ecopelan/webex-message-handler).
37
39
 
38
40
  ## Why?
@@ -186,6 +188,15 @@ async def on_message(msg):
186
188
 
187
189
  Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
188
190
 
191
+ ## Region Discovery
192
+
193
+ Webex assigns each org to a service region (e.g. `wdm-a`, `wdm-r`). On connect,
194
+ the library discovers your org's correct WDM endpoint from the Webex U2C service
195
+ catalog and registers there automatically — no configuration needed. Registering
196
+ in the wrong region produces a socket that authorizes and completes the KMS
197
+ handshake but never receives that org's messages, so this is done for you. If
198
+ discovery fails for any reason, it falls back to the default region.
199
+
189
200
  ## Delivery Guarantees
190
201
 
191
202
  This library provides **at-most-once** delivery semantics:
@@ -2,6 +2,8 @@
2
2
 
3
3
  Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages — no Webex SDK required.
4
4
 
5
+ 📖 **[Documentation & overview →](https://3rg0n.github.io/webex-message-handler/)**
6
+
5
7
  Python port of the [TypeScript webex-message-handler](https://github.com/ecopelan/webex-message-handler).
6
8
 
7
9
  ## Why?
@@ -155,6 +157,15 @@ async def on_message(msg):
155
157
 
156
158
  Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
157
159
 
160
+ ## Region Discovery
161
+
162
+ Webex assigns each org to a service region (e.g. `wdm-a`, `wdm-r`). On connect,
163
+ the library discovers your org's correct WDM endpoint from the Webex U2C service
164
+ catalog and registers there automatically — no configuration needed. Registering
165
+ in the wrong region produces a socket that authorizes and completes the KMS
166
+ handshake but never receives that org's messages, so this is done for you. If
167
+ discovery fails for any reason, it falls back to the default region.
168
+
158
169
  ## Delivery Guarantees
159
170
 
160
171
  This library provides **at-most-once** delivery semantics:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webex-message-handler"
7
- version = "0.6.12"
7
+ version = "0.6.14"
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"
@@ -0,0 +1,345 @@
1
+ """WDM device registration, refresh, and unregistration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .errors import AuthError, DeviceRegistrationError
9
+ from .logger import Logger, noop_logger
10
+ from .types import DeviceRegistration, FetchFunction, FetchRequest
11
+ from .url_validation import validate_webex_url
12
+
13
+ WDM_API_BASE = "https://wdm-a.wbx2.com/wdm/api/v1/devices"
14
+ U2C_CATALOG_URL = "https://u2c.wbx2.com/u2c/api/v1/catalog?format=hostmap"
15
+
16
+ _DEVICE_BODY = {
17
+ "deviceName": "webex-message-handler",
18
+ "deviceType": "DESKTOP",
19
+ "localizedModel": "python",
20
+ "model": "python",
21
+ "name": "webex-message-handler",
22
+ "systemName": "webex-message-handler",
23
+ "systemVersion": "1.0.0",
24
+ }
25
+
26
+
27
+ class DeviceManager:
28
+ """Manages WDM device registration lifecycle."""
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ logger: Logger | None = None,
34
+ http_do: FetchFunction,
35
+ ) -> None:
36
+ self._logger: Logger = logger or noop_logger # type: ignore[assignment]
37
+ self._http_do = http_do
38
+ self._device_url: str | None = None
39
+ self._wdm_devices_url: str | None = None
40
+
41
+ async def _discover_wdm_base(self, token: str) -> str:
42
+ """Discover the org-assigned WDM devices endpoint from U2C.
43
+
44
+ Caches the result. On any failure falls back to the hard-coded WDM_API_BASE
45
+ (never fatal), so a U2C outage degrades to today's behavior.
46
+ """
47
+ if self._wdm_devices_url:
48
+ return self._wdm_devices_url
49
+
50
+ try:
51
+ response = await self._http_do(
52
+ FetchRequest(
53
+ url=U2C_CATALOG_URL,
54
+ method="GET",
55
+ headers={"Authorization": f"Bearer {token}"},
56
+ )
57
+ )
58
+
59
+ if not response.ok:
60
+ self._logger.warning(
61
+ f"U2C discovery returned {response.status}; falling back to {WDM_API_BASE}"
62
+ )
63
+ self._wdm_devices_url = WDM_API_BASE
64
+ return self._wdm_devices_url
65
+
66
+ catalog = await response.json()
67
+ wdm = catalog.get("serviceLinks", {}).get("wdm")
68
+
69
+ if not wdm:
70
+ self._logger.warning(
71
+ f"U2C catalog has no wdm service link; falling back to {WDM_API_BASE}"
72
+ )
73
+ self._wdm_devices_url = WDM_API_BASE
74
+ return self._wdm_devices_url
75
+
76
+ try:
77
+ validate_webex_url(wdm, "https")
78
+ except ValueError as exc:
79
+ self._logger.error(
80
+ f'U2C-discovered wdm URL "{wdm}" untrusted ({exc}); falling back to {WDM_API_BASE}'
81
+ )
82
+ self._wdm_devices_url = WDM_API_BASE
83
+ return self._wdm_devices_url
84
+
85
+ self._wdm_devices_url = wdm.rstrip("/") + "/devices"
86
+ self._logger.info(f"Discovered region-correct WDM endpoint: {self._wdm_devices_url}")
87
+ return self._wdm_devices_url
88
+
89
+ except Exception as exc:
90
+ self._logger.warning(
91
+ f"U2C discovery failed ({exc}); falling back to {WDM_API_BASE}"
92
+ )
93
+ self._wdm_devices_url = WDM_API_BASE
94
+ return self._wdm_devices_url
95
+
96
+ def set_wdm_devices_url(self, url: str) -> None:
97
+ """Pre-seed the discovered WDM devices endpoint (test helper)."""
98
+ self._wdm_devices_url = url
99
+
100
+ async def register(self, token: str) -> DeviceRegistration:
101
+ """Register a new device with WDM.
102
+
103
+ To avoid leaking a new device on every Connect() (which eventually trips the
104
+ Webex per-user device cap → HTTP 403), it first lists existing devices and
105
+ reuses/refreshes one matching this client's name+deviceType. Only when no
106
+ reusable device exists does it POST a new one. If registration fails because
107
+ the account already has excessive registrations, it reaps this client's own
108
+ devices and retries once.
109
+ """
110
+ self._logger.debug("Registering device with WDM")
111
+
112
+ # Reuse-before-register: if a device of ours already exists, refresh it.
113
+ existing_url = await self._find_reusable_device(token)
114
+ if existing_url:
115
+ self._device_url = existing_url
116
+ try:
117
+ reg = await self.refresh(token)
118
+ self._logger.info("Reused existing WDM device registration")
119
+ return reg
120
+ except Exception:
121
+ # Refresh failed (device stale/deleted server-side) — fall through to
122
+ # create a fresh one.
123
+ self._device_url = None
124
+
125
+ try:
126
+ reg = await self._create_device(token)
127
+ except DeviceRegistrationError as exc:
128
+ if self._is_excessive_registrations_error(exc):
129
+ msg = "Excessive device registrations detected — reaping this client's devices and retrying"
130
+ self._logger.warning(msg)
131
+ await self._reap_own_devices(token)
132
+ return await self._create_device(token)
133
+ raise
134
+ return reg
135
+
136
+ async def _create_device(self, token: str) -> DeviceRegistration:
137
+ """Perform the raw POST /devices registration."""
138
+ try:
139
+ base = await self._discover_wdm_base(token)
140
+ create_url = f"{base}?includeUpstreamServices=all"
141
+ response = await self._http_do(
142
+ FetchRequest(
143
+ url=create_url,
144
+ method="POST",
145
+ headers={
146
+ "Authorization": f"Bearer {token}",
147
+ "Content-Type": "application/json",
148
+ },
149
+ body=json.dumps(_DEVICE_BODY),
150
+ )
151
+ )
152
+
153
+ if response.status == 401:
154
+ self._logger.error("Device registration failed: Unauthorized")
155
+ raise AuthError("Unauthorized to register device")
156
+
157
+ if not response.ok:
158
+ self._logger.error(f"Device registration failed with status {response.status}")
159
+ raise DeviceRegistrationError("Failed to register device", response.status)
160
+
161
+ data = await response.json()
162
+ self._device_url = data["url"]
163
+ registration = self._parse_device_response(data)
164
+ self._logger.info("Device registered successfully")
165
+ return registration
166
+
167
+ except (AuthError, DeviceRegistrationError):
168
+ raise
169
+ except Exception as exc:
170
+ self._logger.error(f"Device registration error: {exc}")
171
+ raise DeviceRegistrationError("Failed to register device") from exc
172
+
173
+ async def _find_reusable_device(self, token: str) -> str | None:
174
+ """List existing WDM devices and return the URL of one matching this
175
+ client's name+deviceType, or None if none/on any error (in which case the
176
+ caller falls back to creating a new device — best-effort, never fatal).
177
+ """
178
+ try:
179
+ devices = await self._list_devices(token)
180
+ for device in devices:
181
+ device_data: dict[str, Any] = device if isinstance(device, dict) else device.__dict__
182
+ if (
183
+ device_data.get("name") == _DEVICE_BODY["name"]
184
+ and device_data.get("deviceType") == _DEVICE_BODY["deviceType"]
185
+ and device_data.get("url")
186
+ ):
187
+ return device_data["url"]
188
+ except Exception as exc:
189
+ self._logger.debug(f"Could not list existing devices (will create new): {exc}")
190
+ return None
191
+
192
+ async def _reap_own_devices(self, token: str) -> None:
193
+ """Best-effort deletes all WDM devices matching this client's
194
+ name+deviceType, to recover from the per-user device cap. Never fatal.
195
+ """
196
+ try:
197
+ devices = await self._list_devices(token)
198
+ reaped = 0
199
+ for device in devices:
200
+ device_data: dict[str, Any] = device if isinstance(device, dict) else device.__dict__
201
+ if (
202
+ device_data.get("name") != _DEVICE_BODY["name"]
203
+ or device_data.get("deviceType") != _DEVICE_BODY["deviceType"]
204
+ or not device_data.get("url")
205
+ ):
206
+ continue
207
+ try:
208
+ resp = await self._http_do(
209
+ FetchRequest(
210
+ url=device_data["url"],
211
+ method="DELETE",
212
+ headers={"Authorization": f"Bearer {token}"},
213
+ )
214
+ )
215
+ if resp.ok or resp.status == 404:
216
+ reaped += 1
217
+ except Exception as exc:
218
+ self._logger.debug(f"Failed to reap device {device_data.get('url')}: {exc}")
219
+ self._logger.info(f"Reaped {reaped} stale WDM device(s)")
220
+ except Exception as exc:
221
+ self._logger.warning(f"Could not list devices to reap: {exc}")
222
+
223
+ async def _list_devices(self, token: str) -> list[Any]:
224
+ """Fetches the account's current WDM device registrations from the region-correct endpoint."""
225
+ base = await self._discover_wdm_base(token)
226
+ response = await self._http_do(
227
+ FetchRequest(
228
+ url=base,
229
+ method="GET",
230
+ headers={"Authorization": f"Bearer {token}"},
231
+ )
232
+ )
233
+
234
+ if response.status == 401:
235
+ raise AuthError("Unauthorized to list devices")
236
+
237
+ if not response.ok:
238
+ raise DeviceRegistrationError(
239
+ f"Failed to list devices: {response.status}",
240
+ response.status,
241
+ )
242
+
243
+ data = await response.json()
244
+ return data.get("devices", [])
245
+
246
+ @staticmethod
247
+ def _is_excessive_registrations_error(error: Exception) -> bool:
248
+ """Report whether error is the WDM per-user device cap rejection (HTTP 403)."""
249
+ return isinstance(error, DeviceRegistrationError) and error.status_code == 403
250
+
251
+ async def refresh(self, token: str) -> DeviceRegistration:
252
+ """Refresh an existing device registration."""
253
+ if not self._device_url:
254
+ raise DeviceRegistrationError("Device not registered. Call register() first.")
255
+
256
+ self._logger.debug("Refreshing device registration")
257
+
258
+ try:
259
+ response = await self._http_do(
260
+ FetchRequest(
261
+ url=self._device_url,
262
+ method="PUT",
263
+ headers={
264
+ "Authorization": f"Bearer {token}",
265
+ "Content-Type": "application/json",
266
+ },
267
+ body=json.dumps(_DEVICE_BODY),
268
+ )
269
+ )
270
+
271
+ if response.status == 401:
272
+ self._logger.error("Device refresh failed: Unauthorized")
273
+ raise AuthError("Unauthorized to refresh device")
274
+
275
+ if not response.ok:
276
+ self._logger.error(f"Device refresh failed with status {response.status}")
277
+ raise DeviceRegistrationError("Failed to refresh device", response.status)
278
+
279
+ data = await response.json()
280
+ registration = self._parse_device_response(data)
281
+ self._logger.info("Device refreshed successfully")
282
+ return registration
283
+
284
+ except (AuthError, DeviceRegistrationError):
285
+ raise
286
+ except Exception as exc:
287
+ self._logger.error(f"Device refresh error: {exc}")
288
+ raise DeviceRegistrationError("Failed to refresh device") from exc
289
+
290
+ async def unregister(self, token: str) -> None:
291
+ """Unregister the device from WDM."""
292
+ if not self._device_url:
293
+ raise DeviceRegistrationError("Device not registered. Call register() first.")
294
+
295
+ self._logger.debug("Unregistering device")
296
+
297
+ try:
298
+ response = await self._http_do(
299
+ FetchRequest(
300
+ url=self._device_url,
301
+ method="DELETE",
302
+ headers={
303
+ "Authorization": f"Bearer {token}",
304
+ "Content-Type": "application/json",
305
+ },
306
+ )
307
+ )
308
+
309
+ if response.status == 401:
310
+ self._logger.error("Device unregistration failed: Unauthorized")
311
+ raise AuthError("Unauthorized to unregister device")
312
+
313
+ if not response.ok:
314
+ self._logger.error(f"Device unregistration failed with status {response.status}")
315
+ raise DeviceRegistrationError("Failed to unregister device", response.status)
316
+
317
+ self._device_url = None
318
+ self._logger.info("Device unregistered successfully")
319
+
320
+ except (AuthError, DeviceRegistrationError):
321
+ raise
322
+ except Exception as exc:
323
+ self._logger.error(f"Device unregistration error: {exc}")
324
+ raise DeviceRegistrationError("Failed to unregister device") from exc
325
+
326
+ def _parse_device_response(self, data: dict[str, Any]) -> DeviceRegistration:
327
+ services: dict[str, str] = data.get("services", {})
328
+ if not isinstance(services, dict):
329
+ services = {}
330
+
331
+ web_socket_url = data["webSocketUrl"]
332
+ encryption_service_url = services.get("encryptionServiceUrl", "")
333
+
334
+ # Validate URLs from external API response
335
+ validate_webex_url(web_socket_url, "wss")
336
+ if encryption_service_url:
337
+ validate_webex_url(encryption_service_url, "https")
338
+
339
+ return DeviceRegistration(
340
+ web_socket_url=web_socket_url,
341
+ device_url=data["url"],
342
+ user_id=data["userId"],
343
+ services=services,
344
+ encryption_service_url=encryption_service_url,
345
+ )
@@ -14,9 +14,12 @@ class ParsedMentions:
14
14
  mentioned_groups: list[str] = field(default_factory=list)
15
15
 
16
16
 
17
- _MENTION_RE = re.compile(
18
- r'<spark-mention[^>]*data-object-type="([^"]*)"[^>]*>', re.IGNORECASE
19
- )
17
+ # Match the whole opening tag with a single bounded ``[^>]*`` (linear), then
18
+ # pull attributes out of the captured tag. A prior form used two ``[^>]*``
19
+ # around the attribute, which is a polynomial-ReDoS risk under Python's
20
+ # backtracking ``re`` engine on crafted ``<spark-mention…`` input.
21
+ _MENTION_RE = re.compile(r"<spark-mention[^>]*>", re.IGNORECASE)
22
+ _OBJECT_TYPE_RE = re.compile(r'data-object-type="([^"]*)"', re.IGNORECASE)
20
23
  _PERSON_ID_RE = re.compile(r'data-object-id="([^"]*)"', re.IGNORECASE)
21
24
  _GROUP_TYPE_RE = re.compile(r'data-group-type="([^"]*)"', re.IGNORECASE)
22
25
 
@@ -41,7 +44,8 @@ def parse_mentions(html: str | None) -> ParsedMentions:
41
44
 
42
45
  for match in _MENTION_RE.finditer(html):
43
46
  tag = match.group(0)
44
- object_type = match.group(1)
47
+ type_match = _OBJECT_TYPE_RE.search(tag)
48
+ object_type = type_match.group(1) if type_match else ""
45
49
 
46
50
  if object_type == "person":
47
51
  id_match = _PERSON_ID_RE.search(tag)
@@ -0,0 +1,406 @@
1
+ """Tests for DeviceManager."""
2
+
3
+ import pytest
4
+
5
+ from tests.conftest import MockHttpDo
6
+ from webex_message_handler.device_manager import U2C_CATALOG_URL, WDM_API_BASE, DeviceManager
7
+ from webex_message_handler.errors import AuthError, DeviceRegistrationError
8
+
9
+ MOCK_TOKEN = "test-token"
10
+ MOCK_DEVICE_URL = "https://wdm-a.wbx2.com/wdm/api/v1/devices/test-device-id"
11
+ MOCK_WS_URL = "wss://mercury.example.com/socket"
12
+ MOCK_USER_ID = "user-123"
13
+
14
+ MOCK_WDM_RESPONSE = {
15
+ "webSocketUrl": MOCK_WS_URL,
16
+ "url": MOCK_DEVICE_URL,
17
+ "userId": MOCK_USER_ID,
18
+ "services": {
19
+ "encryptionServiceUrl": "https://encryption.example.com",
20
+ "messenger": "https://messenger.example.com",
21
+ },
22
+ }
23
+
24
+
25
+ class TestRegister:
26
+ async def test_successful_registration(self):
27
+ http_do = (
28
+ MockHttpDo()
29
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
30
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
31
+ )
32
+ dm = DeviceManager(http_do=http_do)
33
+ dm.set_wdm_devices_url(WDM_API_BASE)
34
+ result = await dm.register(MOCK_TOKEN)
35
+
36
+ assert result.web_socket_url == MOCK_WS_URL
37
+ assert result.device_url == MOCK_DEVICE_URL
38
+ assert result.user_id == MOCK_USER_ID
39
+ assert result.encryption_service_url == "https://encryption.example.com"
40
+ assert result.services["messenger"] == "https://messenger.example.com"
41
+
42
+ async def test_auth_error_on_401(self):
43
+ http_do = (
44
+ MockHttpDo()
45
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
46
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", status=401) # create fails with 401
47
+ )
48
+ dm = DeviceManager(http_do=http_do)
49
+ dm.set_wdm_devices_url(WDM_API_BASE)
50
+ with pytest.raises(AuthError):
51
+ await dm.register(MOCK_TOKEN)
52
+
53
+ async def test_device_registration_error_on_non_2xx(self):
54
+ http_do = (
55
+ MockHttpDo()
56
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
57
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", status=400) # create fails
58
+ )
59
+ dm = DeviceManager(http_do=http_do)
60
+ dm.set_wdm_devices_url(WDM_API_BASE)
61
+ with pytest.raises(DeviceRegistrationError, match="Failed to register device"):
62
+ await dm.register(MOCK_TOKEN)
63
+
64
+ async def test_device_registration_error_on_network_failure(self):
65
+ http_do = (
66
+ MockHttpDo()
67
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
68
+ .add(
69
+ "POST",
70
+ f"{WDM_API_BASE}?includeUpstreamServices=all",
71
+ exc=ConnectionError("Network error"),
72
+ ) # create fails
73
+ )
74
+ dm = DeviceManager(http_do=http_do)
75
+ dm.set_wdm_devices_url(WDM_API_BASE)
76
+ with pytest.raises(DeviceRegistrationError):
77
+ await dm.register(MOCK_TOKEN)
78
+
79
+
80
+ class TestRefresh:
81
+ async def test_successful_refresh(self):
82
+ refreshed = {**MOCK_WDM_RESPONSE, "webSocketUrl": "wss://mercury-new.example.com/socket"}
83
+ http_do = (
84
+ MockHttpDo()
85
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
86
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
87
+ .add("PUT", MOCK_DEVICE_URL, payload=refreshed) # refresh
88
+ )
89
+ dm = DeviceManager(http_do=http_do)
90
+ dm.set_wdm_devices_url(WDM_API_BASE)
91
+ await dm.register(MOCK_TOKEN)
92
+ result = await dm.refresh(MOCK_TOKEN)
93
+
94
+ assert result.web_socket_url == "wss://mercury-new.example.com/socket"
95
+
96
+ async def test_error_if_not_registered(self):
97
+ http_do = MockHttpDo()
98
+ dm = DeviceManager(http_do=http_do)
99
+ with pytest.raises(DeviceRegistrationError, match="Device not registered"):
100
+ await dm.refresh(MOCK_TOKEN)
101
+
102
+ async def test_auth_error_on_401_during_refresh(self):
103
+ http_do = (
104
+ MockHttpDo()
105
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
106
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
107
+ .add("PUT", MOCK_DEVICE_URL, status=401) # refresh fails
108
+ )
109
+ dm = DeviceManager(http_do=http_do)
110
+ dm.set_wdm_devices_url(WDM_API_BASE)
111
+ await dm.register(MOCK_TOKEN)
112
+ with pytest.raises(AuthError):
113
+ await dm.refresh(MOCK_TOKEN)
114
+
115
+ async def test_device_registration_error_on_refresh_failure(self):
116
+ http_do = (
117
+ MockHttpDo()
118
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
119
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
120
+ .add("PUT", MOCK_DEVICE_URL, status=500) # refresh fails
121
+ )
122
+ dm = DeviceManager(http_do=http_do)
123
+ dm.set_wdm_devices_url(WDM_API_BASE)
124
+ await dm.register(MOCK_TOKEN)
125
+ with pytest.raises(DeviceRegistrationError):
126
+ await dm.refresh(MOCK_TOKEN)
127
+
128
+
129
+ class TestUnregister:
130
+ async def test_successful_unregister(self):
131
+ http_do = (
132
+ MockHttpDo()
133
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
134
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
135
+ .add("DELETE", MOCK_DEVICE_URL, status=204) # unregister
136
+ )
137
+ dm = DeviceManager(http_do=http_do)
138
+ dm.set_wdm_devices_url(WDM_API_BASE)
139
+ await dm.register(MOCK_TOKEN)
140
+ await dm.unregister(MOCK_TOKEN)
141
+
142
+ async def test_error_if_not_registered(self):
143
+ http_do = MockHttpDo()
144
+ dm = DeviceManager(http_do=http_do)
145
+ with pytest.raises(DeviceRegistrationError, match="Device not registered"):
146
+ await dm.unregister(MOCK_TOKEN)
147
+
148
+ async def test_auth_error_on_401_during_unregister(self):
149
+ http_do = (
150
+ MockHttpDo()
151
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
152
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
153
+ .add("DELETE", MOCK_DEVICE_URL, status=401) # unregister fails
154
+ )
155
+ dm = DeviceManager(http_do=http_do)
156
+ dm.set_wdm_devices_url(WDM_API_BASE)
157
+ await dm.register(MOCK_TOKEN)
158
+ with pytest.raises(AuthError):
159
+ await dm.unregister(MOCK_TOKEN)
160
+
161
+ async def test_device_registration_error_on_unregister_failure(self):
162
+ http_do = (
163
+ MockHttpDo()
164
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
165
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
166
+ .add("DELETE", MOCK_DEVICE_URL, status=500) # unregister fails
167
+ )
168
+ dm = DeviceManager(http_do=http_do)
169
+ dm.set_wdm_devices_url(WDM_API_BASE)
170
+ await dm.register(MOCK_TOKEN)
171
+ with pytest.raises(DeviceRegistrationError):
172
+ await dm.unregister(MOCK_TOKEN)
173
+
174
+
175
+ class TestServiceParsing:
176
+ async def test_empty_services(self):
177
+ response = {**MOCK_WDM_RESPONSE, "services": {}}
178
+ http_do = (
179
+ MockHttpDo()
180
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
181
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=response) # create
182
+ )
183
+ dm = DeviceManager(http_do=http_do)
184
+ result = await dm.register(MOCK_TOKEN)
185
+
186
+ assert result.services == {}
187
+ assert result.encryption_service_url == ""
188
+
189
+ async def test_missing_encryption_service_url(self):
190
+ response = {**MOCK_WDM_RESPONSE, "services": {"messenger": "https://messenger.example.com"}}
191
+ http_do = (
192
+ MockHttpDo()
193
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
194
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=response) # create
195
+ )
196
+ dm = DeviceManager(http_do=http_do)
197
+ result = await dm.register(MOCK_TOKEN)
198
+
199
+ assert "messenger" in result.services
200
+ assert result.encryption_service_url == ""
201
+
202
+
203
+ class TestDeviceReuseAndReaping:
204
+ async def test_reuses_existing_device_and_calls_refresh_instead_of_create(self):
205
+ """Verify that when a device matching name+deviceType already exists,
206
+ Register refreshes it (PUT) instead of creating a new one (POST).
207
+ """
208
+ http_do = (
209
+ MockHttpDo()
210
+ .add(
211
+ "GET",
212
+ WDM_API_BASE,
213
+ payload={
214
+ "devices": [
215
+ {
216
+ "webSocketUrl": MOCK_WS_URL,
217
+ "url": MOCK_DEVICE_URL,
218
+ "userId": MOCK_USER_ID,
219
+ "name": "webex-message-handler",
220
+ "deviceType": "DESKTOP",
221
+ "services": {},
222
+ }
223
+ ]
224
+ },
225
+ )
226
+ .add("PUT", MOCK_DEVICE_URL, payload=MOCK_WDM_RESPONSE)
227
+ )
228
+ dm = DeviceManager(http_do=http_do)
229
+ dm.set_wdm_devices_url(WDM_API_BASE)
230
+ result = await dm.register(MOCK_TOKEN)
231
+
232
+ assert result.device_url == MOCK_DEVICE_URL
233
+ # Should be: GET (list) + PUT (refresh)
234
+ assert len(http_do.calls) == 2
235
+ assert http_do.calls[0].method == "GET"
236
+ assert http_do.calls[1].method == "PUT"
237
+
238
+ async def test_reaps_devices_on_403_and_retries_create(self):
239
+ """Verify that a 403 on create triggers reaping of this client's own
240
+ devices followed by a single retry.
241
+ """
242
+ http_do = (
243
+ MockHttpDo()
244
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
245
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", status=403) # first POST fails
246
+ .add(
247
+ "GET",
248
+ WDM_API_BASE,
249
+ payload={
250
+ "devices": [
251
+ {
252
+ "url": f"{MOCK_DEVICE_URL}/old",
253
+ "name": "webex-message-handler",
254
+ "deviceType": "DESKTOP",
255
+ }
256
+ ]
257
+ },
258
+ ) # reap: return device to delete
259
+ .add("DELETE", f"{MOCK_DEVICE_URL}/old", status=204) # delete old device
260
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # retry POST
261
+ )
262
+ dm = DeviceManager(http_do=http_do)
263
+ dm.set_wdm_devices_url(WDM_API_BASE)
264
+ result = await dm.register(MOCK_TOKEN)
265
+
266
+ assert result.device_url == MOCK_DEVICE_URL
267
+ # Should be: GET (reuse) + POST (403) + GET (reap) + DELETE + POST (retry)
268
+ assert len(http_do.calls) == 5
269
+ assert http_do.calls[0].method == "GET"
270
+ assert http_do.calls[1].method == "POST"
271
+ assert http_do.calls[2].method == "GET"
272
+ assert http_do.calls[3].method == "DELETE"
273
+ assert http_do.calls[4].method == "POST"
274
+
275
+ async def test_falls_back_to_create_when_list_fails(self):
276
+ """Verify that when list fails, register falls back to create."""
277
+ http_do = (
278
+ MockHttpDo()
279
+ .add("GET", WDM_API_BASE, status=500) # list fails
280
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create succeeds
281
+ )
282
+ dm = DeviceManager(http_do=http_do)
283
+ dm.set_wdm_devices_url(WDM_API_BASE)
284
+ result = await dm.register(MOCK_TOKEN)
285
+
286
+ assert result.device_url == MOCK_DEVICE_URL
287
+ # Should be: GET (list fails) + POST (create succeeds)
288
+ assert len(http_do.calls) == 2
289
+
290
+
291
+ class TestU2CRegionDiscovery:
292
+ """Tests for U2C region discovery (#27)."""
293
+
294
+ async def test_discovers_region_correct_wdm_endpoint_from_u2c(self):
295
+ """Verify that register resolves the org-assigned WDM region from U2C."""
296
+ u2c_catalog = {"serviceLinks": {"wdm": "https://wdm-r.wbx2.com/wdm/api/v1"}}
297
+ regional_device_url = "https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device-id"
298
+ regional_response = {**MOCK_WDM_RESPONSE, "url": regional_device_url}
299
+
300
+ http_do = (
301
+ MockHttpDo()
302
+ .add("GET", U2C_CATALOG_URL, payload=u2c_catalog) # U2C discovery
303
+ .add("GET", "https://wdm-r.wbx2.com/wdm/api/v1/devices", payload={"devices": []}) # list
304
+ .add(
305
+ "POST",
306
+ "https://wdm-r.wbx2.com/wdm/api/v1/devices?includeUpstreamServices=all",
307
+ payload=regional_response,
308
+ ) # create
309
+ )
310
+ dm = DeviceManager(http_do=http_do)
311
+ result = await dm.register(MOCK_TOKEN)
312
+
313
+ assert result.device_url == regional_device_url
314
+ # Verify POST went to discovered regional endpoint, not hardcoded wdm-a
315
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
316
+ assert "wdm-r.wbx2.com" in post_calls[0].url
317
+
318
+ async def test_caches_discovered_wdm_endpoint(self):
319
+ """Verify that the discovered WDM endpoint is cached."""
320
+ u2c_catalog = {"serviceLinks": {"wdm": "https://wdm-r.wbx2.com/wdm/api/v1"}}
321
+
322
+ http_do = (
323
+ MockHttpDo()
324
+ .add("GET", U2C_CATALOG_URL, payload=u2c_catalog) # U2C discovery (first call)
325
+ .add("GET", "https://wdm-r.wbx2.com/wdm/api/v1/devices", payload={"devices": []}) # list
326
+ .add(
327
+ "POST",
328
+ "https://wdm-r.wbx2.com/wdm/api/v1/devices?includeUpstreamServices=all",
329
+ payload={**MOCK_WDM_RESPONSE, "url": "https://wdm-r.wbx2.com/wdm/api/v1/devices/id1"},
330
+ ) # create
331
+ )
332
+ dm = DeviceManager(http_do=http_do)
333
+
334
+ # Register once; U2C should be called
335
+ await dm.register(MOCK_TOKEN)
336
+ u2c_calls = [c for c in http_do.calls if "u2c.wbx2.com" in c.url]
337
+ assert len(u2c_calls) == 1
338
+
339
+ # Pre-seed the cache
340
+ dm.set_wdm_devices_url("https://wdm-r.wbx2.com/wdm/api/v1/devices")
341
+ # Verify cache doesn't trigger another U2C call (would fail with assertion)
342
+
343
+ async def test_falls_back_to_wdm_a_when_u2c_returns_non_2xx(self):
344
+ """Verify discovery is best-effort: non-2xx U2C falls back to wdm-a."""
345
+ http_do = (
346
+ MockHttpDo()
347
+ .add("GET", U2C_CATALOG_URL, status=500) # U2C discovery fails
348
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # list uses fallback
349
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
350
+ )
351
+ dm = DeviceManager(http_do=http_do)
352
+ result = await dm.register(MOCK_TOKEN)
353
+
354
+ assert result.device_url == MOCK_DEVICE_URL
355
+ # Verify POST went to fallback wdm-a
356
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
357
+ assert "wdm-a.wbx2.com" in post_calls[0].url
358
+
359
+ async def test_falls_back_to_wdm_a_when_u2c_has_no_wdm_link(self):
360
+ """Verify that missing wdm link in U2C response falls back to wdm-a."""
361
+ u2c_catalog = {"serviceLinks": {}} # Missing wdm link
362
+
363
+ http_do = (
364
+ MockHttpDo()
365
+ .add("GET", U2C_CATALOG_URL, payload=u2c_catalog) # U2C discovery
366
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # list uses fallback
367
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
368
+ )
369
+ dm = DeviceManager(http_do=http_do)
370
+ result = await dm.register(MOCK_TOKEN)
371
+
372
+ assert result.device_url == MOCK_DEVICE_URL
373
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
374
+ assert "wdm-a.wbx2.com" in post_calls[0].url
375
+
376
+ async def test_rejects_untrusted_wdm_host_from_u2c_and_falls_back(self):
377
+ """Verify that non-Webex wdm link from U2C is rejected and falls back."""
378
+ u2c_catalog = {"serviceLinks": {"wdm": "https://evil.attacker.com/wdm/api/v1"}}
379
+
380
+ http_do = (
381
+ MockHttpDo()
382
+ .add("GET", U2C_CATALOG_URL, payload=u2c_catalog) # U2C discovery
383
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # list uses fallback
384
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
385
+ )
386
+ dm = DeviceManager(http_do=http_do)
387
+ result = await dm.register(MOCK_TOKEN)
388
+
389
+ assert result.device_url == MOCK_DEVICE_URL
390
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
391
+ assert "wdm-a.wbx2.com" in post_calls[0].url
392
+
393
+ async def test_falls_back_to_wdm_a_on_u2c_fetch_error(self):
394
+ """Verify that U2C fetch errors fall back to wdm-a."""
395
+ http_do = (
396
+ MockHttpDo()
397
+ .add("GET", U2C_CATALOG_URL, exc=ConnectionError("Network error")) # U2C discovery fails
398
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # list uses fallback
399
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
400
+ )
401
+ dm = DeviceManager(http_do=http_do)
402
+ result = await dm.register(MOCK_TOKEN)
403
+
404
+ assert result.device_url == MOCK_DEVICE_URL
405
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
406
+ assert "wdm-a.wbx2.com" in post_calls[0].url
@@ -0,0 +1,62 @@
1
+ """Tests for the mention parser, including a ReDoS regression guard."""
2
+
3
+ import time
4
+
5
+ from webex_message_handler.mention_parser import parse_mentions
6
+
7
+
8
+ def test_empty_inputs():
9
+ for v in (None, ""):
10
+ r = parse_mentions(v)
11
+ assert r.mentioned_people == []
12
+ assert r.mentioned_groups == []
13
+
14
+
15
+ def test_single_person_mention():
16
+ html = '<spark-mention data-object-type="person" data-object-id="uuid-1">Alice</spark-mention>'
17
+ r = parse_mentions(html)
18
+ assert r.mentioned_people == ["uuid-1"]
19
+ assert r.mentioned_groups == []
20
+
21
+
22
+ def test_multiple_person_mentions_dedup():
23
+ html = (
24
+ '<spark-mention data-object-type="person" data-object-id="uuid-1">A</spark-mention>'
25
+ '<spark-mention data-object-type="person" data-object-id="uuid-2">B</spark-mention>'
26
+ '<spark-mention data-object-type="person" data-object-id="uuid-1">A</spark-mention>'
27
+ )
28
+ assert parse_mentions(html).mentioned_people == ["uuid-1", "uuid-2"]
29
+
30
+
31
+ def test_group_mention():
32
+ html = '<spark-mention data-object-type="groupMention" data-group-type="all">All</spark-mention>'
33
+ r = parse_mentions(html)
34
+ assert r.mentioned_people == []
35
+ assert r.mentioned_groups == ["all"]
36
+
37
+
38
+ def test_attribute_order_independent():
39
+ html = (
40
+ '<spark-mention data-object-id="uuid-9" data-object-type="person">X</spark-mention>'
41
+ '<spark-mention data-group-type="all" data-object-type="groupMention">All</spark-mention>'
42
+ )
43
+ r = parse_mentions(html)
44
+ assert r.mentioned_people == ["uuid-9"]
45
+ assert r.mentioned_groups == ["all"]
46
+
47
+
48
+ def test_ignores_non_mention_html():
49
+ r = parse_mentions("<p>hello <b>world</b></p>")
50
+ assert r.mentioned_people == []
51
+ assert r.mentioned_groups == []
52
+
53
+
54
+ def test_no_polynomial_backtracking_redos_guard():
55
+ """Crafted input that made the old two-[^>]* regex backtrack must parse fast."""
56
+ evil = "<spark-mention" + ' data-object-type="' * 50000
57
+ start = time.monotonic()
58
+ r = parse_mentions(evil)
59
+ elapsed = time.monotonic() - start
60
+ assert r.mentioned_people == []
61
+ assert r.mentioned_groups == []
62
+ assert elapsed < 1.0
@@ -1,170 +0,0 @@
1
- """WDM device registration, refresh, and unregistration."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from typing import Any
7
-
8
- from .errors import AuthError, DeviceRegistrationError
9
- from .logger import Logger, noop_logger
10
- from .types import DeviceRegistration, FetchFunction, FetchRequest
11
- from .url_validation import validate_webex_url
12
-
13
- WDM_API_BASE = "https://wdm-a.wbx2.com/wdm/api/v1/devices"
14
-
15
- _DEVICE_BODY = {
16
- "deviceName": "webex-message-handler",
17
- "deviceType": "DESKTOP",
18
- "localizedModel": "python",
19
- "model": "python",
20
- "name": "webex-message-handler",
21
- "systemName": "webex-message-handler",
22
- "systemVersion": "1.0.0",
23
- }
24
-
25
-
26
- class DeviceManager:
27
- """Manages WDM device registration lifecycle."""
28
-
29
- def __init__(
30
- self,
31
- *,
32
- logger: Logger | None = None,
33
- http_do: FetchFunction,
34
- ) -> None:
35
- self._logger: Logger = logger or noop_logger # type: ignore[assignment]
36
- self._http_do = http_do
37
- self._device_url: str | None = None
38
-
39
- async def register(self, token: str) -> DeviceRegistration:
40
- """Register a new device with WDM."""
41
- self._logger.debug("Registering device with WDM")
42
-
43
- try:
44
- response = await self._http_do(
45
- FetchRequest(
46
- url=WDM_API_BASE,
47
- method="POST",
48
- headers={
49
- "Authorization": f"Bearer {token}",
50
- "Content-Type": "application/json",
51
- },
52
- body=json.dumps(_DEVICE_BODY),
53
- )
54
- )
55
-
56
- if response.status == 401:
57
- self._logger.error("Device registration failed: Unauthorized")
58
- raise AuthError("Unauthorized to register device")
59
-
60
- if not response.ok:
61
- self._logger.error(f"Device registration failed with status {response.status}")
62
- raise DeviceRegistrationError("Failed to register device", response.status)
63
-
64
- data = await response.json()
65
- self._device_url = data["url"]
66
- registration = self._parse_device_response(data)
67
- self._logger.info("Device registered successfully")
68
- return registration
69
-
70
- except (AuthError, DeviceRegistrationError):
71
- raise
72
- except Exception as exc:
73
- self._logger.error(f"Device registration error: {exc}")
74
- raise DeviceRegistrationError("Failed to register device") from exc
75
-
76
- async def refresh(self, token: str) -> DeviceRegistration:
77
- """Refresh an existing device registration."""
78
- if not self._device_url:
79
- raise DeviceRegistrationError("Device not registered. Call register() first.")
80
-
81
- self._logger.debug("Refreshing device registration")
82
-
83
- try:
84
- response = await self._http_do(
85
- FetchRequest(
86
- url=self._device_url,
87
- method="PUT",
88
- headers={
89
- "Authorization": f"Bearer {token}",
90
- "Content-Type": "application/json",
91
- },
92
- body=json.dumps(_DEVICE_BODY),
93
- )
94
- )
95
-
96
- if response.status == 401:
97
- self._logger.error("Device refresh failed: Unauthorized")
98
- raise AuthError("Unauthorized to refresh device")
99
-
100
- if not response.ok:
101
- self._logger.error(f"Device refresh failed with status {response.status}")
102
- raise DeviceRegistrationError("Failed to refresh device", response.status)
103
-
104
- data = await response.json()
105
- registration = self._parse_device_response(data)
106
- self._logger.info("Device refreshed successfully")
107
- return registration
108
-
109
- except (AuthError, DeviceRegistrationError):
110
- raise
111
- except Exception as exc:
112
- self._logger.error(f"Device refresh error: {exc}")
113
- raise DeviceRegistrationError("Failed to refresh device") from exc
114
-
115
- async def unregister(self, token: str) -> None:
116
- """Unregister the device from WDM."""
117
- if not self._device_url:
118
- raise DeviceRegistrationError("Device not registered. Call register() first.")
119
-
120
- self._logger.debug("Unregistering device")
121
-
122
- try:
123
- response = await self._http_do(
124
- FetchRequest(
125
- url=self._device_url,
126
- method="DELETE",
127
- headers={
128
- "Authorization": f"Bearer {token}",
129
- "Content-Type": "application/json",
130
- },
131
- )
132
- )
133
-
134
- if response.status == 401:
135
- self._logger.error("Device unregistration failed: Unauthorized")
136
- raise AuthError("Unauthorized to unregister device")
137
-
138
- if not response.ok:
139
- self._logger.error(f"Device unregistration failed with status {response.status}")
140
- raise DeviceRegistrationError("Failed to unregister device", response.status)
141
-
142
- self._device_url = None
143
- self._logger.info("Device unregistered successfully")
144
-
145
- except (AuthError, DeviceRegistrationError):
146
- raise
147
- except Exception as exc:
148
- self._logger.error(f"Device unregistration error: {exc}")
149
- raise DeviceRegistrationError("Failed to unregister device") from exc
150
-
151
- def _parse_device_response(self, data: dict[str, Any]) -> DeviceRegistration:
152
- services: dict[str, str] = data.get("services", {})
153
- if not isinstance(services, dict):
154
- services = {}
155
-
156
- web_socket_url = data["webSocketUrl"]
157
- encryption_service_url = services.get("encryptionServiceUrl", "")
158
-
159
- # Validate URLs from external API response
160
- validate_webex_url(web_socket_url, "wss")
161
- if encryption_service_url:
162
- validate_webex_url(encryption_service_url, "https")
163
-
164
- return DeviceRegistration(
165
- web_socket_url=web_socket_url,
166
- device_url=data["url"],
167
- user_id=data["userId"],
168
- services=services,
169
- encryption_service_url=encryption_service_url,
170
- )
@@ -1,156 +0,0 @@
1
- """Tests for DeviceManager."""
2
-
3
- import pytest
4
-
5
- from tests.conftest import MockHttpDo
6
- from webex_message_handler.device_manager import WDM_API_BASE, DeviceManager
7
- from webex_message_handler.errors import AuthError, DeviceRegistrationError
8
-
9
- MOCK_TOKEN = "test-token"
10
- MOCK_DEVICE_URL = "https://wdm-a.wbx2.com/wdm/api/v1/devices/test-device-id"
11
- MOCK_WS_URL = "wss://mercury.example.com/socket"
12
- MOCK_USER_ID = "user-123"
13
-
14
- MOCK_WDM_RESPONSE = {
15
- "webSocketUrl": MOCK_WS_URL,
16
- "url": MOCK_DEVICE_URL,
17
- "userId": MOCK_USER_ID,
18
- "services": {
19
- "encryptionServiceUrl": "https://encryption.example.com",
20
- "messenger": "https://messenger.example.com",
21
- },
22
- }
23
-
24
-
25
- class TestRegister:
26
- async def test_successful_registration(self):
27
- http_do = MockHttpDo().add("POST", WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
28
- dm = DeviceManager(http_do=http_do)
29
- result = await dm.register(MOCK_TOKEN)
30
-
31
- assert result.web_socket_url == MOCK_WS_URL
32
- assert result.device_url == MOCK_DEVICE_URL
33
- assert result.user_id == MOCK_USER_ID
34
- assert result.encryption_service_url == "https://encryption.example.com"
35
- assert result.services["messenger"] == "https://messenger.example.com"
36
-
37
- async def test_auth_error_on_401(self):
38
- http_do = MockHttpDo().add("POST", WDM_API_BASE, status=401)
39
- dm = DeviceManager(http_do=http_do)
40
- with pytest.raises(AuthError):
41
- await dm.register(MOCK_TOKEN)
42
-
43
- async def test_device_registration_error_on_non_2xx(self):
44
- http_do = MockHttpDo().add("POST", WDM_API_BASE, status=400)
45
- dm = DeviceManager(http_do=http_do)
46
- with pytest.raises(DeviceRegistrationError, match="Failed to register device"):
47
- await dm.register(MOCK_TOKEN)
48
-
49
- async def test_device_registration_error_on_network_failure(self):
50
- http_do = MockHttpDo().add("POST", WDM_API_BASE, exc=ConnectionError("Network error"))
51
- dm = DeviceManager(http_do=http_do)
52
- with pytest.raises(DeviceRegistrationError):
53
- await dm.register(MOCK_TOKEN)
54
-
55
-
56
- class TestRefresh:
57
- async def test_successful_refresh(self):
58
- refreshed = {**MOCK_WDM_RESPONSE, "webSocketUrl": "wss://mercury-new.example.com/socket"}
59
- http_do = (
60
- MockHttpDo()
61
- .add("POST", WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
62
- .add("PUT", MOCK_DEVICE_URL, payload=refreshed)
63
- )
64
- dm = DeviceManager(http_do=http_do)
65
- await dm.register(MOCK_TOKEN)
66
- result = await dm.refresh(MOCK_TOKEN)
67
-
68
- assert result.web_socket_url == "wss://mercury-new.example.com/socket"
69
-
70
- async def test_error_if_not_registered(self):
71
- http_do = MockHttpDo()
72
- dm = DeviceManager(http_do=http_do)
73
- with pytest.raises(DeviceRegistrationError, match="Device not registered"):
74
- await dm.refresh(MOCK_TOKEN)
75
-
76
- async def test_auth_error_on_401_during_refresh(self):
77
- http_do = (
78
- MockHttpDo()
79
- .add("POST", WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
80
- .add("PUT", MOCK_DEVICE_URL, status=401)
81
- )
82
- dm = DeviceManager(http_do=http_do)
83
- await dm.register(MOCK_TOKEN)
84
- with pytest.raises(AuthError):
85
- await dm.refresh(MOCK_TOKEN)
86
-
87
- async def test_device_registration_error_on_refresh_failure(self):
88
- http_do = (
89
- MockHttpDo()
90
- .add("POST", WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
91
- .add("PUT", MOCK_DEVICE_URL, status=500)
92
- )
93
- dm = DeviceManager(http_do=http_do)
94
- await dm.register(MOCK_TOKEN)
95
- with pytest.raises(DeviceRegistrationError):
96
- await dm.refresh(MOCK_TOKEN)
97
-
98
-
99
- class TestUnregister:
100
- async def test_successful_unregister(self):
101
- http_do = (
102
- MockHttpDo()
103
- .add("POST", WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
104
- .add("DELETE", MOCK_DEVICE_URL, status=204)
105
- )
106
- dm = DeviceManager(http_do=http_do)
107
- await dm.register(MOCK_TOKEN)
108
- await dm.unregister(MOCK_TOKEN)
109
-
110
- async def test_error_if_not_registered(self):
111
- http_do = MockHttpDo()
112
- dm = DeviceManager(http_do=http_do)
113
- with pytest.raises(DeviceRegistrationError, match="Device not registered"):
114
- await dm.unregister(MOCK_TOKEN)
115
-
116
- async def test_auth_error_on_401_during_unregister(self):
117
- http_do = (
118
- MockHttpDo()
119
- .add("POST", WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
120
- .add("DELETE", MOCK_DEVICE_URL, status=401)
121
- )
122
- dm = DeviceManager(http_do=http_do)
123
- await dm.register(MOCK_TOKEN)
124
- with pytest.raises(AuthError):
125
- await dm.unregister(MOCK_TOKEN)
126
-
127
- async def test_device_registration_error_on_unregister_failure(self):
128
- http_do = (
129
- MockHttpDo()
130
- .add("POST", WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
131
- .add("DELETE", MOCK_DEVICE_URL, status=500)
132
- )
133
- dm = DeviceManager(http_do=http_do)
134
- await dm.register(MOCK_TOKEN)
135
- with pytest.raises(DeviceRegistrationError):
136
- await dm.unregister(MOCK_TOKEN)
137
-
138
-
139
- class TestServiceParsing:
140
- async def test_empty_services(self):
141
- response = {**MOCK_WDM_RESPONSE, "services": {}}
142
- http_do = MockHttpDo().add("POST", WDM_API_BASE, payload=response)
143
- dm = DeviceManager(http_do=http_do)
144
- result = await dm.register(MOCK_TOKEN)
145
-
146
- assert result.services == {}
147
- assert result.encryption_service_url == ""
148
-
149
- async def test_missing_encryption_service_url(self):
150
- response = {**MOCK_WDM_RESPONSE, "services": {"messenger": "https://messenger.example.com"}}
151
- http_do = MockHttpDo().add("POST", WDM_API_BASE, payload=response)
152
- dm = DeviceManager(http_do=http_do)
153
- result = await dm.register(MOCK_TOKEN)
154
-
155
- assert "messenger" in result.services
156
- assert result.encryption_service_url == ""