Skip to content

Commit 26a59e4

Browse files
dlkakbsteknium1
authored andcommitted
fix(msgraph): normalize webhook dedupe and resource matching
1 parent 2a215de commit 26a59e4

2 files changed

Lines changed: 84 additions & 19 deletions

File tree

gateway/platforms/msgraph_webhook.py

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,15 @@ def _normalize_path(path: Any) -> str:
8080
return raw if raw.startswith("/") else f"/{raw}"
8181

8282
@staticmethod
83-
def _build_receipt_key(notification: Dict[str, Any]) -> str:
83+
def _build_receipt_key(notification: Dict[str, Any]) -> Optional[str]:
8484
explicit_id = str(notification.get("id") or "").strip()
8585
if explicit_id:
8686
return f"id:{explicit_id}"
87-
payload = "|".join(
88-
[
89-
str(notification.get("subscriptionId") or ""),
90-
str(notification.get("changeType") or ""),
91-
str(notification.get("resource") or ""),
92-
json.dumps(notification.get("resourceData") or {}, sort_keys=True),
93-
]
94-
)
95-
return f"sha1:{sha1(payload.encode('utf-8')).hexdigest()}"
87+
return None
88+
89+
@staticmethod
90+
def _normalize_resource_value(resource: str) -> str:
91+
return str(resource or "").strip().strip("/")
9692

9793
def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler]) -> None:
9894
self._notification_scheduler = scheduler
@@ -178,10 +174,11 @@ async def _handle_notification(self, request: "web.Request") -> "web.Response":
178174
continue
179175

180176
receipt_key = self._build_receipt_key(notification)
181-
if self._has_seen_receipt(receipt_key):
182-
duplicates += 1
183-
continue
184-
self._remember_receipt(receipt_key)
177+
if receipt_key is not None:
178+
if self._has_seen_receipt(receipt_key):
179+
duplicates += 1
180+
continue
181+
self._remember_receipt(receipt_key)
185182

186183
accepted += 1
187184
scheduled += 1
@@ -205,10 +202,20 @@ async def _handle_notification(self, request: "web.Request") -> "web.Response":
205202
def _resource_accepted(self, resource: str) -> bool:
206203
if not self._accepted_resources:
207204
return True
205+
normalized_resource = self._normalize_resource_value(resource)
208206
for pattern in self._accepted_resources:
209-
if pattern.endswith("*") and resource.startswith(pattern[:-1]):
210-
return True
211-
if resource == pattern or resource.startswith(f"{pattern}/"):
207+
normalized_pattern = self._normalize_resource_value(pattern)
208+
if not normalized_pattern:
209+
continue
210+
if normalized_pattern.endswith("*"):
211+
prefix = normalized_pattern[:-1].rstrip("/")
212+
if normalized_resource == prefix or normalized_resource.startswith(f"{prefix}/"):
213+
return True
214+
continue
215+
if (
216+
normalized_resource == normalized_pattern
217+
or normalized_resource.startswith(f"{normalized_pattern}/")
218+
):
212219
return True
213220
return False
214221

@@ -232,8 +239,9 @@ def _remember_receipt(self, receipt_key: str) -> None:
232239
def _build_message_event(
233240
self,
234241
notification: Dict[str, Any],
235-
receipt_key: str,
242+
receipt_key: Optional[str],
236243
) -> MessageEvent:
244+
message_id = receipt_key or f"sha1:{sha1(json.dumps(notification, sort_keys=True).encode('utf-8')).hexdigest()}"
237245
source = self.build_source(
238246
chat_id=f"msgraph:{notification.get('subscriptionId', 'unknown')}",
239247
chat_name="msgraph/webhook",
@@ -246,7 +254,7 @@ def _build_message_event(
246254
message_type=MessageType.TEXT,
247255
source=source,
248256
raw_message=notification,
249-
message_id=receipt_key,
257+
message_id=message_id,
250258
internal=True,
251259
)
252260

tests/gateway/test_msgraph_webhook.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,63 @@ async def _capture(notification, event):
186186

187187
assert len(scheduled) == 1
188188

189+
@pytest.mark.anyio
190+
async def test_notifications_without_id_are_not_deduped(self):
191+
adapter = _make_adapter()
192+
scheduled: list[tuple[dict, object]] = []
193+
194+
async def _capture(notification, event):
195+
scheduled.append((notification, event))
196+
197+
adapter.set_notification_scheduler(_capture)
198+
payload = {
199+
"value": [
200+
{
201+
"subscriptionId": "sub-1",
202+
"changeType": "updated",
203+
"resource": "communications/onlineMeetings/meeting-3",
204+
"clientState": "expected-client-state",
205+
"resourceData": {"id": "meeting-3"},
206+
}
207+
]
208+
}
209+
210+
first = await adapter._handle_notification(_FakeRequest(json_payload=payload))
211+
second = await adapter._handle_notification(_FakeRequest(json_payload=payload))
212+
213+
assert first.status == 202
214+
assert second.status == 202
215+
second_data = json.loads(second.text)
216+
assert second_data["accepted"] == 1
217+
assert second_data["duplicates"] == 0
218+
assert second_data["scheduled"] == 1
219+
220+
await asyncio.sleep(0.05)
221+
222+
assert len(scheduled) == 2
223+
224+
@pytest.mark.anyio
225+
async def test_resource_patterns_accept_leading_slash(self):
226+
adapter = _make_adapter(accepted_resources=["/communications/onlineMeetings"])
227+
payload = {
228+
"value": [
229+
{
230+
"id": "notif-slash",
231+
"subscriptionId": "sub-1",
232+
"changeType": "updated",
233+
"resource": "communications/onlineMeetings/meeting-4",
234+
"clientState": "expected-client-state",
235+
}
236+
]
237+
}
238+
239+
resp = await adapter._handle_notification(_FakeRequest(json_payload=payload))
240+
data = json.loads(resp.text)
241+
242+
assert resp.status == 202
243+
assert data["accepted"] == 1
244+
assert data["rejected"] == 0
245+
189246
@pytest.mark.anyio
190247
async def test_seen_receipts_are_bounded(self):
191248
adapter = _make_adapter(max_seen_receipts=2)

0 commit comments

Comments
 (0)