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.
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/PKG-INFO +12 -1
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/README.md +11 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/pyproject.toml +1 -1
- webex_message_handler-0.6.14/src/webex_message_handler/device_manager.py +345 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/mention_parser.py +8 -4
- webex_message_handler-0.6.14/tests/test_device_manager.py +406 -0
- webex_message_handler-0.6.14/tests/test_mention_parser.py +62 -0
- webex_message_handler-0.6.12/src/webex_message_handler/device_manager.py +0 -170
- webex_message_handler-0.6.12/tests/test_device_manager.py +0 -156
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/.gitignore +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/API.md +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/LICENSE +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/examples/basic_bot.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/__init__.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/errors.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/handler.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/id_utils.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/kms_client.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/logger.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/mercury_socket.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/message_decryptor.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/types.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/url_validation.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/test-proxy.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/__init__.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/conftest.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/test_handler.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/test_id_utils.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/test_integration.py +0 -0
- {webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/test_kms_client.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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 == ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/__init__.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/errors.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/handler.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/id_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/logger.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/src/webex_message_handler/types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.12 → webex_message_handler-0.6.14}/tests/test_message_decryptor.py
RENAMED
|
File without changes
|