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