webex-message-handler 0.6.12__tar.gz → 0.6.13__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 (30) hide show
  1. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/PKG-INFO +3 -1
  2. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/README.md +2 -0
  3. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/pyproject.toml +1 -1
  4. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/device_manager.py +114 -2
  5. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/mention_parser.py +8 -4
  6. webex_message_handler-0.6.13/tests/test_device_manager.py +275 -0
  7. webex_message_handler-0.6.13/tests/test_mention_parser.py +62 -0
  8. webex_message_handler-0.6.12/tests/test_device_manager.py +0 -156
  9. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/.gitignore +0 -0
  10. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/API.md +0 -0
  11. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/LICENSE +0 -0
  12. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/examples/basic_bot.py +0 -0
  13. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/__init__.py +0 -0
  14. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/errors.py +0 -0
  15. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/handler.py +0 -0
  16. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/id_utils.py +0 -0
  17. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/kms_client.py +0 -0
  18. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/logger.py +0 -0
  19. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/mercury_socket.py +0 -0
  20. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/message_decryptor.py +0 -0
  21. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/types.py +0 -0
  22. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/src/webex_message_handler/url_validation.py +0 -0
  23. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/test-proxy.py +0 -0
  24. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/tests/__init__.py +0 -0
  25. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/tests/conftest.py +0 -0
  26. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/tests/test_handler.py +0 -0
  27. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/tests/test_id_utils.py +0 -0
  28. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/tests/test_integration.py +0 -0
  29. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/tests/test_kms_client.py +0 -0
  30. {webex_message_handler-0.6.12 → webex_message_handler-0.6.13}/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.13
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?
@@ -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?
@@ -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.13"
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"
@@ -37,13 +37,48 @@ class DeviceManager:
37
37
  self._device_url: str | None = None
38
38
 
39
39
  async def register(self, token: str) -> DeviceRegistration:
40
- """Register a new device with WDM."""
40
+ """Register a new device with WDM.
41
+
42
+ To avoid leaking a new device on every Connect() (which eventually trips the
43
+ Webex per-user device cap → HTTP 403), it first lists existing devices and
44
+ reuses/refreshes one matching this client's name+deviceType. Only when no
45
+ reusable device exists does it POST a new one. If registration fails because
46
+ the account already has excessive registrations, it reaps this client's own
47
+ devices and retries once.
48
+ """
41
49
  self._logger.debug("Registering device with WDM")
42
50
 
51
+ # Reuse-before-register: if a device of ours already exists, refresh it.
52
+ existing_url = await self._find_reusable_device(token)
53
+ if existing_url:
54
+ self._device_url = existing_url
55
+ try:
56
+ reg = await self.refresh(token)
57
+ self._logger.info("Reused existing WDM device registration")
58
+ return reg
59
+ except Exception:
60
+ # Refresh failed (device stale/deleted server-side) — fall through to
61
+ # create a fresh one.
62
+ self._device_url = None
63
+
43
64
  try:
65
+ reg = await self._create_device(token)
66
+ except DeviceRegistrationError as exc:
67
+ if self._is_excessive_registrations_error(exc):
68
+ msg = "Excessive device registrations detected — reaping this client's devices and retrying"
69
+ self._logger.warning(msg)
70
+ await self._reap_own_devices(token)
71
+ return await self._create_device(token)
72
+ raise
73
+ return reg
74
+
75
+ async def _create_device(self, token: str) -> DeviceRegistration:
76
+ """Perform the raw POST /devices registration."""
77
+ try:
78
+ create_url = f"{WDM_API_BASE}?includeUpstreamServices=all"
44
79
  response = await self._http_do(
45
80
  FetchRequest(
46
- url=WDM_API_BASE,
81
+ url=create_url,
47
82
  method="POST",
48
83
  headers={
49
84
  "Authorization": f"Bearer {token}",
@@ -73,6 +108,83 @@ class DeviceManager:
73
108
  self._logger.error(f"Device registration error: {exc}")
74
109
  raise DeviceRegistrationError("Failed to register device") from exc
75
110
 
111
+ async def _find_reusable_device(self, token: str) -> str | None:
112
+ """List existing WDM devices and return the URL of one matching this
113
+ client's name+deviceType, or None if none/on any error (in which case the
114
+ caller falls back to creating a new device — best-effort, never fatal).
115
+ """
116
+ try:
117
+ devices = await self._list_devices(token)
118
+ for device in devices:
119
+ device_data: dict[str, Any] = device if isinstance(device, dict) else device.__dict__
120
+ if (
121
+ device_data.get("name") == _DEVICE_BODY["name"]
122
+ and device_data.get("deviceType") == _DEVICE_BODY["deviceType"]
123
+ and device_data.get("url")
124
+ ):
125
+ return device_data["url"]
126
+ except Exception as exc:
127
+ self._logger.debug(f"Could not list existing devices (will create new): {exc}")
128
+ return None
129
+
130
+ async def _reap_own_devices(self, token: str) -> None:
131
+ """Best-effort deletes all WDM devices matching this client's
132
+ name+deviceType, to recover from the per-user device cap. Never fatal.
133
+ """
134
+ try:
135
+ devices = await self._list_devices(token)
136
+ reaped = 0
137
+ for device in devices:
138
+ device_data: dict[str, Any] = device if isinstance(device, dict) else device.__dict__
139
+ if (
140
+ device_data.get("name") != _DEVICE_BODY["name"]
141
+ or device_data.get("deviceType") != _DEVICE_BODY["deviceType"]
142
+ or not device_data.get("url")
143
+ ):
144
+ continue
145
+ try:
146
+ resp = await self._http_do(
147
+ FetchRequest(
148
+ url=device_data["url"],
149
+ method="DELETE",
150
+ headers={"Authorization": f"Bearer {token}"},
151
+ )
152
+ )
153
+ if resp.ok or resp.status == 404:
154
+ reaped += 1
155
+ except Exception as exc:
156
+ self._logger.debug(f"Failed to reap device {device_data.get('url')}: {exc}")
157
+ self._logger.info(f"Reaped {reaped} stale WDM device(s)")
158
+ except Exception as exc:
159
+ self._logger.warning(f"Could not list devices to reap: {exc}")
160
+
161
+ async def _list_devices(self, token: str) -> list[Any]:
162
+ """Fetches the account's current WDM device registrations."""
163
+ response = await self._http_do(
164
+ FetchRequest(
165
+ url=WDM_API_BASE,
166
+ method="GET",
167
+ headers={"Authorization": f"Bearer {token}"},
168
+ )
169
+ )
170
+
171
+ if response.status == 401:
172
+ raise AuthError("Unauthorized to list devices")
173
+
174
+ if not response.ok:
175
+ raise DeviceRegistrationError(
176
+ f"Failed to list devices: {response.status}",
177
+ response.status,
178
+ )
179
+
180
+ data = await response.json()
181
+ return data.get("devices", [])
182
+
183
+ @staticmethod
184
+ def _is_excessive_registrations_error(error: Exception) -> bool:
185
+ """Report whether error is the WDM per-user device cap rejection (HTTP 403)."""
186
+ return isinstance(error, DeviceRegistrationError) and error.status_code == 403
187
+
76
188
  async def refresh(self, token: str) -> DeviceRegistration:
77
189
  """Refresh an existing device registration."""
78
190
  if not self._device_url:
@@ -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,275 @@
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 = (
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
+ result = await dm.register(MOCK_TOKEN)
34
+
35
+ assert result.web_socket_url == MOCK_WS_URL
36
+ assert result.device_url == MOCK_DEVICE_URL
37
+ assert result.user_id == MOCK_USER_ID
38
+ assert result.encryption_service_url == "https://encryption.example.com"
39
+ assert result.services["messenger"] == "https://messenger.example.com"
40
+
41
+ async def test_auth_error_on_401(self):
42
+ http_do = (
43
+ MockHttpDo()
44
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
45
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", status=401) # create fails with 401
46
+ )
47
+ dm = DeviceManager(http_do=http_do)
48
+ with pytest.raises(AuthError):
49
+ await dm.register(MOCK_TOKEN)
50
+
51
+ async def test_device_registration_error_on_non_2xx(self):
52
+ http_do = (
53
+ MockHttpDo()
54
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
55
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", status=400) # create fails
56
+ )
57
+ dm = DeviceManager(http_do=http_do)
58
+ with pytest.raises(DeviceRegistrationError, match="Failed to register device"):
59
+ await dm.register(MOCK_TOKEN)
60
+
61
+ async def test_device_registration_error_on_network_failure(self):
62
+ http_do = (
63
+ MockHttpDo()
64
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
65
+ .add(
66
+ "POST",
67
+ f"{WDM_API_BASE}?includeUpstreamServices=all",
68
+ exc=ConnectionError("Network error"),
69
+ ) # create fails
70
+ )
71
+ dm = DeviceManager(http_do=http_do)
72
+ with pytest.raises(DeviceRegistrationError):
73
+ await dm.register(MOCK_TOKEN)
74
+
75
+
76
+ class TestRefresh:
77
+ async def test_successful_refresh(self):
78
+ refreshed = {**MOCK_WDM_RESPONSE, "webSocketUrl": "wss://mercury-new.example.com/socket"}
79
+ http_do = (
80
+ MockHttpDo()
81
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
82
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
83
+ .add("PUT", MOCK_DEVICE_URL, payload=refreshed) # refresh
84
+ )
85
+ dm = DeviceManager(http_do=http_do)
86
+ await dm.register(MOCK_TOKEN)
87
+ result = await dm.refresh(MOCK_TOKEN)
88
+
89
+ assert result.web_socket_url == "wss://mercury-new.example.com/socket"
90
+
91
+ async def test_error_if_not_registered(self):
92
+ http_do = MockHttpDo()
93
+ dm = DeviceManager(http_do=http_do)
94
+ with pytest.raises(DeviceRegistrationError, match="Device not registered"):
95
+ await dm.refresh(MOCK_TOKEN)
96
+
97
+ async def test_auth_error_on_401_during_refresh(self):
98
+ http_do = (
99
+ MockHttpDo()
100
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
101
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
102
+ .add("PUT", MOCK_DEVICE_URL, status=401) # refresh fails
103
+ )
104
+ dm = DeviceManager(http_do=http_do)
105
+ await dm.register(MOCK_TOKEN)
106
+ with pytest.raises(AuthError):
107
+ await dm.refresh(MOCK_TOKEN)
108
+
109
+ async def test_device_registration_error_on_refresh_failure(self):
110
+ http_do = (
111
+ MockHttpDo()
112
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
113
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
114
+ .add("PUT", MOCK_DEVICE_URL, status=500) # refresh fails
115
+ )
116
+ dm = DeviceManager(http_do=http_do)
117
+ await dm.register(MOCK_TOKEN)
118
+ with pytest.raises(DeviceRegistrationError):
119
+ await dm.refresh(MOCK_TOKEN)
120
+
121
+
122
+ class TestUnregister:
123
+ async def test_successful_unregister(self):
124
+ http_do = (
125
+ MockHttpDo()
126
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
127
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
128
+ .add("DELETE", MOCK_DEVICE_URL, status=204) # unregister
129
+ )
130
+ dm = DeviceManager(http_do=http_do)
131
+ await dm.register(MOCK_TOKEN)
132
+ await dm.unregister(MOCK_TOKEN)
133
+
134
+ async def test_error_if_not_registered(self):
135
+ http_do = MockHttpDo()
136
+ dm = DeviceManager(http_do=http_do)
137
+ with pytest.raises(DeviceRegistrationError, match="Device not registered"):
138
+ await dm.unregister(MOCK_TOKEN)
139
+
140
+ async def test_auth_error_on_401_during_unregister(self):
141
+ http_do = (
142
+ MockHttpDo()
143
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
144
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
145
+ .add("DELETE", MOCK_DEVICE_URL, status=401) # unregister fails
146
+ )
147
+ dm = DeviceManager(http_do=http_do)
148
+ await dm.register(MOCK_TOKEN)
149
+ with pytest.raises(AuthError):
150
+ await dm.unregister(MOCK_TOKEN)
151
+
152
+ async def test_device_registration_error_on_unregister_failure(self):
153
+ http_do = (
154
+ MockHttpDo()
155
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
156
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
157
+ .add("DELETE", MOCK_DEVICE_URL, status=500) # unregister fails
158
+ )
159
+ dm = DeviceManager(http_do=http_do)
160
+ await dm.register(MOCK_TOKEN)
161
+ with pytest.raises(DeviceRegistrationError):
162
+ await dm.unregister(MOCK_TOKEN)
163
+
164
+
165
+ class TestServiceParsing:
166
+ async def test_empty_services(self):
167
+ response = {**MOCK_WDM_RESPONSE, "services": {}}
168
+ http_do = (
169
+ MockHttpDo()
170
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
171
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=response) # create
172
+ )
173
+ dm = DeviceManager(http_do=http_do)
174
+ result = await dm.register(MOCK_TOKEN)
175
+
176
+ assert result.services == {}
177
+ assert result.encryption_service_url == ""
178
+
179
+ async def test_missing_encryption_service_url(self):
180
+ response = {**MOCK_WDM_RESPONSE, "services": {"messenger": "https://messenger.example.com"}}
181
+ http_do = (
182
+ MockHttpDo()
183
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
184
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=response) # create
185
+ )
186
+ dm = DeviceManager(http_do=http_do)
187
+ result = await dm.register(MOCK_TOKEN)
188
+
189
+ assert "messenger" in result.services
190
+ assert result.encryption_service_url == ""
191
+
192
+
193
+ class TestDeviceReuseAndReaping:
194
+ async def test_reuses_existing_device_and_calls_refresh_instead_of_create(self):
195
+ """Verify that when a device matching name+deviceType already exists,
196
+ Register refreshes it (PUT) instead of creating a new one (POST).
197
+ """
198
+ http_do = (
199
+ MockHttpDo()
200
+ .add(
201
+ "GET",
202
+ WDM_API_BASE,
203
+ payload={
204
+ "devices": [
205
+ {
206
+ "webSocketUrl": MOCK_WS_URL,
207
+ "url": MOCK_DEVICE_URL,
208
+ "userId": MOCK_USER_ID,
209
+ "name": "webex-message-handler",
210
+ "deviceType": "DESKTOP",
211
+ "services": {},
212
+ }
213
+ ]
214
+ },
215
+ )
216
+ .add("PUT", MOCK_DEVICE_URL, payload=MOCK_WDM_RESPONSE)
217
+ )
218
+ dm = DeviceManager(http_do=http_do)
219
+ result = await dm.register(MOCK_TOKEN)
220
+
221
+ assert result.device_url == MOCK_DEVICE_URL
222
+ # Should be: GET (list) + PUT (refresh)
223
+ assert len(http_do.calls) == 2
224
+ assert http_do.calls[0].method == "GET"
225
+ assert http_do.calls[1].method == "PUT"
226
+
227
+ async def test_reaps_devices_on_403_and_retries_create(self):
228
+ """Verify that a 403 on create triggers reaping of this client's own
229
+ devices followed by a single retry.
230
+ """
231
+ http_do = (
232
+ MockHttpDo()
233
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # reuse check: empty
234
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", status=403) # first POST fails
235
+ .add(
236
+ "GET",
237
+ WDM_API_BASE,
238
+ payload={
239
+ "devices": [
240
+ {
241
+ "url": f"{MOCK_DEVICE_URL}/old",
242
+ "name": "webex-message-handler",
243
+ "deviceType": "DESKTOP",
244
+ }
245
+ ]
246
+ },
247
+ ) # reap: return device to delete
248
+ .add("DELETE", f"{MOCK_DEVICE_URL}/old", status=204) # delete old device
249
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # retry POST
250
+ )
251
+ dm = DeviceManager(http_do=http_do)
252
+ result = await dm.register(MOCK_TOKEN)
253
+
254
+ assert result.device_url == MOCK_DEVICE_URL
255
+ # Should be: GET (reuse) + POST (403) + GET (reap) + DELETE + POST (retry)
256
+ assert len(http_do.calls) == 5
257
+ assert http_do.calls[0].method == "GET"
258
+ assert http_do.calls[1].method == "POST"
259
+ assert http_do.calls[2].method == "GET"
260
+ assert http_do.calls[3].method == "DELETE"
261
+ assert http_do.calls[4].method == "POST"
262
+
263
+ async def test_falls_back_to_create_when_list_fails(self):
264
+ """Verify that when list fails, register falls back to create."""
265
+ http_do = (
266
+ MockHttpDo()
267
+ .add("GET", WDM_API_BASE, status=500) # list fails
268
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create succeeds
269
+ )
270
+ dm = DeviceManager(http_do=http_do)
271
+ result = await dm.register(MOCK_TOKEN)
272
+
273
+ assert result.device_url == MOCK_DEVICE_URL
274
+ # Should be: GET (list fails) + POST (create succeeds)
275
+ assert len(http_do.calls) == 2
@@ -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,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 == ""