webex-message-handler 0.6.11__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 (31) hide show
  1. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/PKG-INFO +5 -4
  2. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/README.md +2 -0
  3. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/pyproject.toml +3 -4
  4. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/device_manager.py +114 -2
  5. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/mention_parser.py +8 -4
  6. webex_message_handler-0.6.13/tests/conftest.py +89 -0
  7. webex_message_handler-0.6.13/tests/test_device_manager.py +275 -0
  8. webex_message_handler-0.6.13/tests/test_mention_parser.py +62 -0
  9. webex_message_handler-0.6.11/tests/conftest.py +0 -40
  10. webex_message_handler-0.6.11/tests/test_device_manager.py +0 -164
  11. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/.gitignore +0 -0
  12. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/API.md +0 -0
  13. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/LICENSE +0 -0
  14. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/examples/basic_bot.py +0 -0
  15. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/__init__.py +0 -0
  16. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/errors.py +0 -0
  17. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/handler.py +0 -0
  18. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/id_utils.py +0 -0
  19. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/kms_client.py +0 -0
  20. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/logger.py +0 -0
  21. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/mercury_socket.py +0 -0
  22. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/message_decryptor.py +0 -0
  23. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/types.py +0 -0
  24. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/src/webex_message_handler/url_validation.py +0 -0
  25. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/test-proxy.py +0 -0
  26. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/tests/__init__.py +0 -0
  27. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/tests/test_handler.py +0 -0
  28. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/tests/test_id_utils.py +0 -0
  29. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/tests/test_integration.py +0 -0
  30. {webex_message_handler-0.6.11 → webex_message_handler-0.6.13}/tests/test_kms_client.py +0 -0
  31. {webex_message_handler-0.6.11 → 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.11
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
@@ -19,11 +19,10 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Topic :: Communications :: Chat
21
21
  Requires-Python: >=3.10
22
- Requires-Dist: aiohttp>=3.13.4
23
- Requires-Dist: cryptography>=46.0.7
22
+ Requires-Dist: aiohttp>=3.14.1
23
+ Requires-Dist: cryptography>=48.0.1
24
24
  Requires-Dist: jwcrypto>=1.5.7
25
25
  Provides-Extra: dev
26
- Requires-Dist: aioresponses>=0.7; extra == 'dev'
27
26
  Requires-Dist: mypy>=1.10; extra == 'dev'
28
27
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
29
28
  Requires-Dist: pytest>=7; extra == 'dev'
@@ -34,6 +33,8 @@ Description-Content-Type: text/markdown
34
33
 
35
34
  Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages — no Webex SDK required.
36
35
 
36
+ 📖 **[Documentation & overview →](https://3rg0n.github.io/webex-message-handler/)**
37
+
37
38
  Python port of the [TypeScript webex-message-handler](https://github.com/ecopelan/webex-message-handler).
38
39
 
39
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.11"
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"
@@ -26,8 +26,8 @@ classifiers = [
26
26
  "Framework :: AsyncIO",
27
27
  ]
28
28
  dependencies = [
29
- "aiohttp>=3.13.4",
30
- "cryptography>=46.0.7",
29
+ "aiohttp>=3.14.1",
30
+ "cryptography>=48.0.1",
31
31
  "jwcrypto>=1.5.7",
32
32
  ]
33
33
 
@@ -57,7 +57,6 @@ strict = true
57
57
  dev = [
58
58
  "pytest>=7",
59
59
  "pytest-asyncio>=0.23",
60
- "aioresponses>=0.7",
61
60
  "ruff>=0.4",
62
61
  "mypy>=1.10",
63
62
  ]
@@ -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,89 @@
1
+ """Pytest configuration and fixtures."""
2
+
3
+ import json as _json
4
+
5
+ import aiohttp
6
+
7
+ from webex_message_handler.types import FetchRequest, FetchResponse
8
+
9
+
10
+ class MockHttpResponse:
11
+ """A canned FetchResponse for the injected http_do adapter."""
12
+
13
+ def __init__(self, status: int = 200, payload=None, *, exc: Exception | None = None):
14
+ self.status = status
15
+ self.ok = 200 <= status < 300
16
+ self._payload = payload
17
+ self._exc = exc
18
+
19
+ async def json(self):
20
+ return self._payload
21
+
22
+ async def text(self):
23
+ return _json.dumps(self._payload) if self._payload is not None else ""
24
+
25
+
26
+ class MockHttpDo:
27
+ """Mocks HTTP at the injected ``http_do`` seam instead of patching aiohttp.
28
+
29
+ Register canned responses keyed by ``(METHOD, url)``; the adapter pops them
30
+ in registration order so repeated calls to the same endpoint can return
31
+ different responses. A registered ``Exception`` is raised to simulate a
32
+ network failure (mirroring the handler's try/except path).
33
+ """
34
+
35
+ def __init__(self):
36
+ self._routes: dict[tuple[str, str], list] = {}
37
+ self.calls: list[FetchRequest] = []
38
+
39
+ def add(self, method: str, url: str, *, status: int = 200, payload=None, exc: Exception | None = None):
40
+ self._routes.setdefault((method.upper(), url), []).append(
41
+ MockHttpResponse(status=status, payload=payload, exc=exc)
42
+ )
43
+ return self
44
+
45
+ async def __call__(self, request: FetchRequest) -> FetchResponse:
46
+ self.calls.append(request)
47
+ key = (request.method.upper(), request.url)
48
+ queue = self._routes.get(key)
49
+ if not queue:
50
+ raise AssertionError(f"unexpected request: {request.method} {request.url}")
51
+ response = queue.pop(0) if len(queue) > 1 else queue[0]
52
+ if response._exc is not None:
53
+ raise response._exc
54
+ return response # type: ignore[return-value]
55
+
56
+
57
+ async def create_test_http_do(connector: aiohttp.BaseConnector | None = None):
58
+ """Create a test HTTP adapter for use in tests."""
59
+ async def http_do(request: FetchRequest) -> FetchResponse:
60
+ async with (
61
+ aiohttp.ClientSession(connector=connector) as session,
62
+ session.request(
63
+ request.method,
64
+ request.url,
65
+ headers=request.headers,
66
+ data=request.body,
67
+ ) as response,
68
+ ):
69
+ # Eagerly read the response body before the context closes
70
+ body_bytes = await response.read()
71
+ status = response.status
72
+ ok = 200 <= status < 300
73
+
74
+ class TestFetchResponse:
75
+ def __init__(self):
76
+ self.status = status
77
+ self.ok = ok
78
+ self._body = body_bytes
79
+
80
+ async def json(self):
81
+ import json
82
+ return json.loads(self._body.decode('utf-8'))
83
+
84
+ async def text(self):
85
+ return self._body.decode('utf-8')
86
+
87
+ return TestFetchResponse() # type: ignore[return-value]
88
+
89
+ return http_do
@@ -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,40 +0,0 @@
1
- """Pytest configuration and fixtures."""
2
-
3
- import aiohttp
4
-
5
- from webex_message_handler.types import FetchRequest, FetchResponse
6
-
7
-
8
- async def create_test_http_do(connector: aiohttp.BaseConnector | None = None):
9
- """Create a test HTTP adapter for use in tests."""
10
- async def http_do(request: FetchRequest) -> FetchResponse:
11
- async with (
12
- aiohttp.ClientSession(connector=connector) as session,
13
- session.request(
14
- request.method,
15
- request.url,
16
- headers=request.headers,
17
- data=request.body,
18
- ) as response,
19
- ):
20
- # Eagerly read the response body before the context closes
21
- body_bytes = await response.read()
22
- status = response.status
23
- ok = 200 <= status < 300
24
-
25
- class TestFetchResponse:
26
- def __init__(self):
27
- self.status = status
28
- self.ok = ok
29
- self._body = body_bytes
30
-
31
- async def json(self):
32
- import json
33
- return json.loads(self._body.decode('utf-8'))
34
-
35
- async def text(self):
36
- return self._body.decode('utf-8')
37
-
38
- return TestFetchResponse() # type: ignore[return-value]
39
-
40
- return http_do
@@ -1,164 +0,0 @@
1
- """Tests for DeviceManager."""
2
-
3
- import pytest
4
- from aioresponses import aioresponses
5
-
6
- from tests.conftest import create_test_http_do
7
- from webex_message_handler.device_manager import WDM_API_BASE, DeviceManager
8
- from webex_message_handler.errors import AuthError, DeviceRegistrationError
9
-
10
- MOCK_TOKEN = "test-token"
11
- MOCK_DEVICE_URL = "https://wdm-a.wbx2.com/wdm/api/v1/devices/test-device-id"
12
- MOCK_WS_URL = "wss://mercury.example.com/socket"
13
- MOCK_USER_ID = "user-123"
14
-
15
- MOCK_WDM_RESPONSE = {
16
- "webSocketUrl": MOCK_WS_URL,
17
- "url": MOCK_DEVICE_URL,
18
- "userId": MOCK_USER_ID,
19
- "services": {
20
- "encryptionServiceUrl": "https://encryption.example.com",
21
- "messenger": "https://messenger.example.com",
22
- },
23
- }
24
-
25
-
26
- class TestRegister:
27
- async def test_successful_registration(self):
28
- http_do = await create_test_http_do()
29
- dm = DeviceManager(http_do=http_do)
30
- with aioresponses() as m:
31
- m.post(WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
32
- result = await dm.register(MOCK_TOKEN)
33
-
34
- assert result.web_socket_url == MOCK_WS_URL
35
- assert result.device_url == MOCK_DEVICE_URL
36
- assert result.user_id == MOCK_USER_ID
37
- assert result.encryption_service_url == "https://encryption.example.com"
38
- assert result.services["messenger"] == "https://messenger.example.com"
39
-
40
- async def test_auth_error_on_401(self):
41
- http_do = await create_test_http_do()
42
- dm = DeviceManager(http_do=http_do)
43
- with aioresponses() as m:
44
- m.post(WDM_API_BASE, status=401)
45
- with pytest.raises(AuthError):
46
- await dm.register(MOCK_TOKEN)
47
-
48
- async def test_device_registration_error_on_non_2xx(self):
49
- http_do = await create_test_http_do()
50
- dm = DeviceManager(http_do=http_do)
51
- with aioresponses() as m:
52
- m.post(WDM_API_BASE, status=400)
53
- with pytest.raises(DeviceRegistrationError, match="Failed to register device"):
54
- await dm.register(MOCK_TOKEN)
55
-
56
- async def test_device_registration_error_on_network_failure(self):
57
- http_do = await create_test_http_do()
58
- dm = DeviceManager(http_do=http_do)
59
- with aioresponses() as m:
60
- m.post(WDM_API_BASE, exception=ConnectionError("Network error"))
61
- with pytest.raises(DeviceRegistrationError):
62
- await dm.register(MOCK_TOKEN)
63
-
64
-
65
- class TestRefresh:
66
- async def test_successful_refresh(self):
67
- http_do = await create_test_http_do()
68
- dm = DeviceManager(http_do=http_do)
69
- with aioresponses() as m:
70
- m.post(WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
71
- await dm.register(MOCK_TOKEN)
72
-
73
- refreshed = {**MOCK_WDM_RESPONSE, "webSocketUrl": "wss://mercury-new.example.com/socket"}
74
- m.put(MOCK_DEVICE_URL, payload=refreshed)
75
- result = await dm.refresh(MOCK_TOKEN)
76
-
77
- assert result.web_socket_url == "wss://mercury-new.example.com/socket"
78
-
79
- async def test_error_if_not_registered(self):
80
- http_do = await create_test_http_do()
81
- dm = DeviceManager(http_do=http_do)
82
- with pytest.raises(DeviceRegistrationError, match="Device not registered"):
83
- await dm.refresh(MOCK_TOKEN)
84
-
85
- async def test_auth_error_on_401_during_refresh(self):
86
- http_do = await create_test_http_do()
87
- dm = DeviceManager(http_do=http_do)
88
- with aioresponses() as m:
89
- m.post(WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
90
- await dm.register(MOCK_TOKEN)
91
- m.put(MOCK_DEVICE_URL, status=401)
92
- with pytest.raises(AuthError):
93
- await dm.refresh(MOCK_TOKEN)
94
-
95
- async def test_device_registration_error_on_refresh_failure(self):
96
- http_do = await create_test_http_do()
97
- dm = DeviceManager(http_do=http_do)
98
- with aioresponses() as m:
99
- m.post(WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
100
- await dm.register(MOCK_TOKEN)
101
- m.put(MOCK_DEVICE_URL, status=500)
102
- with pytest.raises(DeviceRegistrationError):
103
- await dm.refresh(MOCK_TOKEN)
104
-
105
-
106
- class TestUnregister:
107
- async def test_successful_unregister(self):
108
- http_do = await create_test_http_do()
109
- dm = DeviceManager(http_do=http_do)
110
- with aioresponses() as m:
111
- m.post(WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
112
- await dm.register(MOCK_TOKEN)
113
- m.delete(MOCK_DEVICE_URL, status=204)
114
- await dm.unregister(MOCK_TOKEN)
115
-
116
- async def test_error_if_not_registered(self):
117
- http_do = await create_test_http_do()
118
- dm = DeviceManager(http_do=http_do)
119
- with pytest.raises(DeviceRegistrationError, match="Device not registered"):
120
- await dm.unregister(MOCK_TOKEN)
121
-
122
- async def test_auth_error_on_401_during_unregister(self):
123
- http_do = await create_test_http_do()
124
- dm = DeviceManager(http_do=http_do)
125
- with aioresponses() as m:
126
- m.post(WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
127
- await dm.register(MOCK_TOKEN)
128
- m.delete(MOCK_DEVICE_URL, status=401)
129
- with pytest.raises(AuthError):
130
- await dm.unregister(MOCK_TOKEN)
131
-
132
- async def test_device_registration_error_on_unregister_failure(self):
133
- http_do = await create_test_http_do()
134
- dm = DeviceManager(http_do=http_do)
135
- with aioresponses() as m:
136
- m.post(WDM_API_BASE, payload=MOCK_WDM_RESPONSE)
137
- await dm.register(MOCK_TOKEN)
138
- m.delete(MOCK_DEVICE_URL, status=500)
139
- with pytest.raises(DeviceRegistrationError):
140
- await dm.unregister(MOCK_TOKEN)
141
-
142
-
143
- class TestServiceParsing:
144
- async def test_empty_services(self):
145
- http_do = await create_test_http_do()
146
- dm = DeviceManager(http_do=http_do)
147
- response = {**MOCK_WDM_RESPONSE, "services": {}}
148
- with aioresponses() as m:
149
- m.post(WDM_API_BASE, payload=response)
150
- result = await dm.register(MOCK_TOKEN)
151
-
152
- assert result.services == {}
153
- assert result.encryption_service_url == ""
154
-
155
- async def test_missing_encryption_service_url(self):
156
- http_do = await create_test_http_do()
157
- dm = DeviceManager(http_do=http_do)
158
- response = {**MOCK_WDM_RESPONSE, "services": {"messenger": "https://messenger.example.com"}}
159
- with aioresponses() as m:
160
- m.post(WDM_API_BASE, payload=response)
161
- result = await dm.register(MOCK_TOKEN)
162
-
163
- assert "messenger" in result.services
164
- assert result.encryption_service_url == ""