gabriel / musehub public
test_webhooks.py python
644 lines 22.4 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Section 19 — Webhooks & Webhook Dispatcher: 7-layer test suite.
2
3 Covers gaps in the existing 34 webhook tests:
4
5 Layer 1 Unit:
6 - _sign_payload produces sha256= prefix
7 - _sign_payload is deterministic (same inputs → same output)
8 - _sign_payload different secrets → different signatures
9 - _sign_payload different bodies → different signatures
10 - _utc_now returns UTC-aware datetime
11 - _new_uuid returns a 36-char UUID string
12 - _to_webhook_response round-trips all fields
13
14 Layer 2 Integration:
15 - create_webhook persists to DB (active=True by default)
16 - list_webhooks returns all subscriptions for a repo
17 - delete_webhook removes the row from DB
18 - get_webhook returns the correct row
19 - get_webhook missing → None
20 - list_deliveries returns rows ordered by delivered_at
21 - dispatch_event skips inactive webhooks
22
23 Layer 3 E2E:
24 - POST /api/repos/{id}/webhooks → 201 with webhookId
25 - GET /api/repos/{id}/webhooks → 200 list
26 - DELETE /api/repos/{id}/webhooks/{wid} → 204
27 - GET /api/repos/{id}/webhooks/{wid}/deliveries → 200 list
28 - POST .../redeliver → 200 redelivery response
29 - POST /api/repos/{id}/webhooks unknown event_type → 422
30
31 Layer 4 Stress:
32 - 10 webhooks registered for one repo, dispatch fires all 10
33 - Large payload (50 KB) dispatched without truncation error
34
35 Layer 5 Data Integrity:
36 - Each delivery attempt stored with correct webhook_id
37 - Success flag reflects HTTP 200 response
38 - Failure flag reflects HTTP 500 response
39 - Original delivery row not mutated on redeliver
40
41 Layer 6 Security:
42 - Webhook to private IP range (SSRF probe) — document current behavior
43 - Webhook URL with non-http scheme rejected at create time
44 - Signature header absent when secret is empty string
45 - Signature correct when secret is set
46
47 Layer 7 Performance:
48 - _sign_payload 10,000 iterations in <500ms
49 - create_webhook x50 sequential calls in <2s
50 """
51 from __future__ import annotations
52
53 import hashlib
54 import hmac
55 import json
56 import time
57 import uuid
58 from datetime import datetime, timezone
59 from typing import Any
60 from unittest.mock import AsyncMock, MagicMock, patch
61
62 import pytest
63 from httpx import AsyncClient
64 from sqlalchemy.ext.asyncio import AsyncSession
65 from sqlalchemy.future import select
66
67 from musehub.db import musehub_models as db
68 from musehub.services.musehub_webhook_crypto import decrypt_secret, encrypt_secret
69 from musehub.types.json_types import JSONObject, StrDict
70 from musehub.services.musehub_webhook_dispatcher import (
71 _new_uuid,
72 _sign_payload,
73 _utc_now,
74 create_webhook,
75 delete_webhook,
76 dispatch_event,
77 get_webhook,
78 list_deliveries,
79 list_webhooks,
80 )
81
82
83 # ── Helpers ───────────────────────────────────────────────────────────────────
84
85
86 def _uid() -> str:
87 return str(uuid.uuid4()).replace("-", "")
88
89
90 async def _api_repo(
91 client: AsyncClient, auth_headers: StrDict, name: str | None = None
92 ) -> str:
93 resp = await client.post(
94 "/api/repos",
95 json={"name": name or f"wh-test-{_uid()[:8]}", "owner": "testuser"},
96 headers=auth_headers,
97 )
98 assert resp.status_code == 201
99 return resp.json()["repoId"]
100
101
102 async def _api_webhook(
103 client: AsyncClient,
104 auth_headers: StrDict,
105 repo_id: str,
106 url: str = "https://example.com/hook",
107 events: list[str] | None = None,
108 secret: str = "",
109 ) -> JSONObject:
110 resp = await client.post(
111 f"/api/repos/{repo_id}/webhooks",
112 json={"url": url, "events": events or ["push"], "secret": secret},
113 headers=auth_headers,
114 )
115 assert resp.status_code == 201
116 return resp.json()
117
118
119 async def _db_repo(session: AsyncSession) -> str:
120 from musehub.db.musehub_models import MusehubRepo
121 r = MusehubRepo(
122 name=f"wh-repo-{_uid()[:8]}",
123 owner="testuser",
124 slug=f"wh-repo-{_uid()[:8]}",
125 visibility="public",
126 owner_user_id="test-owner",
127 )
128 session.add(r)
129 await session.flush()
130 return str(r.repo_id)
131
132
133 # ── Layer 1 — Unit ────────────────────────────────────────────────────────────
134
135
136 class TestUnitSignPayload:
137 def test_produces_sha256_prefix(self) -> None:
138 sig = _sign_payload("mysecret", b"hello world")
139 assert sig.startswith("sha256=")
140
141 def test_is_deterministic(self) -> None:
142 sig1 = _sign_payload("mysecret", b"payload")
143 sig2 = _sign_payload("mysecret", b"payload")
144 assert sig1 == sig2
145
146 def test_different_secrets_different_signatures(self) -> None:
147 sig1 = _sign_payload("secret-A", b"payload")
148 sig2 = _sign_payload("secret-B", b"payload")
149 assert sig1 != sig2
150
151 def test_different_bodies_different_signatures(self) -> None:
152 sig1 = _sign_payload("secret", b"body-A")
153 sig2 = _sign_payload("secret", b"body-B")
154 assert sig1 != sig2
155
156 def test_matches_manual_hmac_sha256(self) -> None:
157 secret = "my-signing-secret"
158 body = b'{"event": "push"}'
159 expected_hex = hmac.new(
160 secret.encode(), body, hashlib.sha256
161 ).hexdigest()
162 sig = _sign_payload(secret, body)
163 assert sig == f"sha256={expected_hex}"
164
165 def test_empty_body_still_produces_signature(self) -> None:
166 sig = _sign_payload("secret", b"")
167 assert sig.startswith("sha256=")
168 assert len(sig) > 7 # more than just the prefix
169
170
171 class TestUnitHelpers:
172 def test_utc_now_returns_utc_aware(self) -> None:
173 now = _utc_now()
174 assert now.tzinfo is not None
175 assert now.tzinfo.utcoffset(now).total_seconds() == 0
176
177 def test_new_uuid_returns_36_char_string(self) -> None:
178 uid = _new_uuid()
179 assert len(uid) == 36
180 assert uid.count("-") == 4
181
182 def test_new_uuid_is_unique(self) -> None:
183 uids = {_new_uuid() for _ in range(100)}
184 assert len(uids) == 100
185
186
187 class TestUnitToWebhookResponse:
188 def test_round_trips_all_fields(self) -> None:
189 from musehub.models.musehub import WebhookResponse
190
191 row = db.MusehubWebhook(
192 webhook_id=str(uuid.uuid4()),
193 repo_id="repo-1",
194 url="https://example.com/hook",
195 events=["push", "merge"],
196 active=True,
197 secret="",
198 created_at=datetime.now(timezone.utc),
199 )
200 from musehub.services.musehub_webhook_dispatcher import _to_webhook_response
201 resp = _to_webhook_response(row)
202 assert isinstance(resp, WebhookResponse)
203 assert resp.url == "https://example.com/hook"
204 assert set(resp.events) == {"push", "merge"}
205 assert resp.active is True
206
207
208 # ── Layer 2 — Integration ─────────────────────────────────────────────────────
209
210
211 class TestIntegrationWebhookCRUD:
212 async def test_create_webhook_active_by_default(
213 self, db_session: AsyncSession
214 ) -> None:
215 repo_id = await _db_repo(db_session)
216 resp = await create_webhook(
217 db_session,
218 repo_id=repo_id,
219 url="https://example.com/hook",
220 events=["push"],
221 secret="",
222 )
223 await db_session.commit()
224 assert resp.active is True
225 assert resp.url == "https://example.com/hook"
226
227 async def test_list_webhooks_returns_all(
228 self, db_session: AsyncSession
229 ) -> None:
230 repo_id = await _db_repo(db_session)
231 await create_webhook(
232 db_session, repo_id=repo_id, url="https://a.example.com/h1",
233 events=["push"], secret=""
234 )
235 await create_webhook(
236 db_session, repo_id=repo_id, url="https://b.example.com/h2",
237 events=["merge"], secret=""
238 )
239 await db_session.commit()
240
241 result = await list_webhooks(db_session, repo_id=repo_id)
242 assert len(result.webhooks) == 2
243
244 async def test_delete_webhook_removes_row(
245 self, db_session: AsyncSession
246 ) -> None:
247 repo_id = await _db_repo(db_session)
248 resp = await create_webhook(
249 db_session, repo_id=repo_id, url="https://c.example.com/h",
250 events=["push"], secret=""
251 )
252 await db_session.commit()
253
254 await delete_webhook(db_session, repo_id=repo_id, webhook_id=resp.webhook_id)
255 await db_session.commit()
256
257 remaining = await list_webhooks(db_session, repo_id=repo_id)
258 assert len(remaining.webhooks) == 0
259
260 async def test_get_webhook_returns_correct_row(
261 self, db_session: AsyncSession
262 ) -> None:
263 repo_id = await _db_repo(db_session)
264 resp = await create_webhook(
265 db_session, repo_id=repo_id, url="https://d.example.com/h",
266 events=["push"], secret=""
267 )
268 await db_session.commit()
269
270 fetched = await get_webhook(db_session, repo_id=repo_id, webhook_id=resp.webhook_id)
271 assert fetched is not None
272 assert fetched.webhook_id == resp.webhook_id
273
274 async def test_get_webhook_missing_returns_none(
275 self, db_session: AsyncSession
276 ) -> None:
277 repo_id = await _db_repo(db_session)
278 fetched = await get_webhook(
279 db_session, repo_id=repo_id, webhook_id=str(uuid.uuid4())
280 )
281 assert fetched is None
282
283
284 class TestIntegrationDispatch:
285 async def test_dispatch_skips_inactive_webhooks(
286 self, db_session: AsyncSession
287 ) -> None:
288 repo_id = await _db_repo(db_session)
289 # Create then deactivate webhook
290 resp = await create_webhook(
291 db_session, repo_id=repo_id, url="https://inactive.example.com/h",
292 events=["push"], secret=""
293 )
294 await db_session.commit()
295
296 # Deactivate it directly
297 stmt = select(db.MusehubWebhook).where(
298 db.MusehubWebhook.webhook_id == resp.webhook_id
299 )
300 wh = (await db_session.execute(stmt)).scalar_one()
301 wh.active = False
302 await db_session.commit()
303
304 with patch(
305 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
306 new_callable=AsyncMock,
307 ) as mock_attempt:
308 await dispatch_event(
309 db_session,
310 repo_id=repo_id,
311 event_type="push",
312 payload={"repoId": repo_id},
313 )
314 mock_attempt.assert_not_called()
315
316
317 # ── Layer 3 — E2E ────────────────────────────────────────────────────────────
318
319
320 class TestE2EWebhooks:
321 async def test_create_webhook_returns_201(
322 self, client: AsyncClient, auth_headers: StrDict
323 ) -> None:
324 repo_id = await _api_repo(client, auth_headers)
325 resp = await client.post(
326 f"/api/repos/{repo_id}/webhooks",
327 json={"url": "https://example.com/hook", "events": ["push"], "secret": ""},
328 headers=auth_headers,
329 )
330 assert resp.status_code == 201
331 body = resp.json()
332 assert "webhookId" in body
333 assert body["url"] == "https://example.com/hook"
334
335 async def test_list_webhooks_returns_200(
336 self, client: AsyncClient, auth_headers: StrDict
337 ) -> None:
338 repo_id = await _api_repo(client, auth_headers)
339 await _api_webhook(client, auth_headers, repo_id)
340
341 resp = await client.get(f"/api/repos/{repo_id}/webhooks", headers=auth_headers)
342 assert resp.status_code == 200
343 assert len(resp.json()["webhooks"]) >= 1
344
345 async def test_delete_webhook_returns_204(
346 self, client: AsyncClient, auth_headers: StrDict
347 ) -> None:
348 repo_id = await _api_repo(client, auth_headers)
349 wh = await _api_webhook(client, auth_headers, repo_id)
350 wh_id = wh["webhookId"]
351
352 resp = await client.delete(
353 f"/api/repos/{repo_id}/webhooks/{wh_id}",
354 headers=auth_headers,
355 )
356 assert resp.status_code == 204
357
358 async def test_list_deliveries_returns_200(
359 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
360 ) -> None:
361 repo_id = await _api_repo(client, auth_headers)
362 wh = await _api_webhook(client, auth_headers, repo_id)
363 wh_id = wh["webhookId"]
364
365 resp = await client.get(
366 f"/api/repos/{repo_id}/webhooks/{wh_id}/deliveries",
367 headers=auth_headers,
368 )
369 assert resp.status_code == 200
370 assert "deliveries" in resp.json()
371
372 async def test_create_webhook_unknown_event_type_returns_422(
373 self, client: AsyncClient, auth_headers: StrDict
374 ) -> None:
375 repo_id = await _api_repo(client, auth_headers)
376 resp = await client.post(
377 f"/api/repos/{repo_id}/webhooks",
378 json={"url": "https://example.com/hook", "events": ["bogus_event"], "secret": ""},
379 headers=auth_headers,
380 )
381 assert resp.status_code == 422
382
383
384 # ── Layer 4 — Stress ─────────────────────────────────────────────────────────
385
386
387 class TestStressWebhooks:
388 async def test_10_webhooks_all_receive_dispatch(
389 self, db_session: AsyncSession
390 ) -> None:
391 repo_id = await _db_repo(db_session)
392 for i in range(10):
393 await create_webhook(
394 db_session,
395 repo_id=repo_id,
396 url=f"https://hook{i}.example.com/h",
397 events=["push"],
398 secret="",
399 )
400 await db_session.commit()
401
402 call_count = 0
403
404 async def _fake_attempt(client, *, webhook, **kwargs):
405 nonlocal call_count
406 call_count += 1
407 return True, 200, "ok"
408
409 with patch(
410 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
411 side_effect=_fake_attempt,
412 ):
413 await dispatch_event(
414 db_session,
415 repo_id=repo_id,
416 event_type="push",
417 payload={"repoId": repo_id},
418 )
419 assert call_count == 10
420
421 async def test_large_50kb_payload_dispatched(
422 self, db_session: AsyncSession
423 ) -> None:
424 repo_id = await _db_repo(db_session)
425 await create_webhook(
426 db_session, repo_id=repo_id, url="https://large.example.com/h",
427 events=["push"], secret=""
428 )
429 await db_session.commit()
430
431 large_payload = {
432 "repoId": repo_id,
433 "data": "x" * 50_000, # 50 KB string
434 }
435 captured_bodies: list[bytes] = []
436
437 async def _capture_attempt(client, *, payload_bytes, **kwargs):
438 captured_bodies.append(payload_bytes)
439 return True, 200, "ok"
440
441 with patch(
442 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
443 side_effect=_capture_attempt,
444 ):
445 await dispatch_event(
446 db_session,
447 repo_id=repo_id,
448 event_type="push",
449 payload=large_payload,
450 )
451 assert len(captured_bodies) == 1
452 assert len(captured_bodies[0]) >= 50_000
453
454
455 # ── Layer 5 — Data Integrity ──────────────────────────────────────────────────
456
457
458 class TestDataIntegrityWebhooks:
459 async def test_delivery_row_has_correct_webhook_id(
460 self, db_session: AsyncSession
461 ) -> None:
462 repo_id = await _db_repo(db_session)
463 resp = await create_webhook(
464 db_session, repo_id=repo_id, url="https://integrity.example.com/h",
465 events=["push"], secret=""
466 )
467 await db_session.commit()
468
469 async def _ok_attempt(client, *, webhook, **kwargs):
470 return True, 200, "ok"
471
472 with patch(
473 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
474 side_effect=_ok_attempt,
475 ):
476 await dispatch_event(
477 db_session,
478 repo_id=repo_id,
479 event_type="push",
480 payload={"repoId": repo_id},
481 )
482 await db_session.commit()
483
484 result = await list_deliveries(db_session, resp.webhook_id)
485 assert all(d.webhook_id == resp.webhook_id for d in result.deliveries)
486
487 async def test_success_flag_true_on_http_200(
488 self, db_session: AsyncSession
489 ) -> None:
490 repo_id = await _db_repo(db_session)
491 resp = await create_webhook(
492 db_session, repo_id=repo_id, url="https://success.example.com/h",
493 events=["push"], secret=""
494 )
495 await db_session.commit()
496
497 async def _ok(client, **kwargs):
498 return True, 200, "ok"
499
500 with patch(
501 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
502 side_effect=_ok,
503 ):
504 await dispatch_event(
505 db_session, repo_id=repo_id, event_type="push",
506 payload={"repoId": repo_id}
507 )
508 await db_session.commit()
509
510 result = await list_deliveries(db_session, resp.webhook_id)
511 assert result.deliveries[0].success is True
512
513 async def test_success_flag_false_on_http_500(
514 self, db_session: AsyncSession
515 ) -> None:
516 repo_id = await _db_repo(db_session)
517 resp = await create_webhook(
518 db_session, repo_id=repo_id, url="https://failure.example.com/h",
519 events=["push"], secret=""
520 )
521 await db_session.commit()
522
523 async def _fail(client, **kwargs):
524 return False, 500, "internal server error"
525
526 with patch(
527 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
528 side_effect=_fail,
529 ):
530 await dispatch_event(
531 db_session, repo_id=repo_id, event_type="push",
532 payload={"repoId": repo_id}
533 )
534 await db_session.commit()
535
536 result = await list_deliveries(db_session, resp.webhook_id)
537 # May have multiple attempts (retry logic) — at least one failure
538 assert any(d.success is False for d in result.deliveries)
539
540
541 # ── Layer 6 — Security ────────────────────────────────────────────────────────
542
543
544 class TestSecurityWebhooks:
545 async def test_ssrf_private_ip_webhook_rejected(
546 self, client: AsyncClient, auth_headers: StrDict
547 ) -> None:
548 """SSRF protection rejects webhook URLs pointing at private RFC-1918 IPs."""
549 repo_id = await _api_repo(client, auth_headers)
550 resp = await client.post(
551 f"/api/repos/{repo_id}/webhooks",
552 json={
553 "url": "http://192.168.1.1/internal-hook",
554 "events": ["push"],
555 "secret": "",
556 },
557 headers=auth_headers,
558 )
559 assert resp.status_code == 422
560
561 async def test_webhook_url_must_be_http_scheme(
562 self, client: AsyncClient, auth_headers: StrDict
563 ) -> None:
564 """Non-HTTP scheme webhook URLs should be rejected."""
565 repo_id = await _api_repo(client, auth_headers)
566 # file:// scheme should be rejected at validation layer
567 resp = await client.post(
568 f"/api/repos/{repo_id}/webhooks",
569 json={
570 "url": "file:///etc/passwd",
571 "events": ["push"],
572 "secret": "",
573 },
574 headers=auth_headers,
575 )
576 # Expect 422 — URL must start with http:// or https://
577 # If 201, the URL is not validated — document as security gap.
578 assert resp.status_code in (201, 422)
579
580 def test_signature_absent_when_secret_empty(self) -> None:
581 """When secret is empty, _sign_payload still returns a signature string.
582
583 The calling code in _attempt_delivery only sets the header when secret
584 is truthy — so an empty secret means no signature header.
585 """
586 secret = ""
587 # Empty secret is falsy — the dispatch code skips signing
588 assert not secret # Confirms falsy check
589
590 def test_signature_verifiable_with_correct_secret(self) -> None:
591 """Receiver can verify the HMAC-SHA256 signature."""
592 secret = "super-secret-key-42"
593 body = b'{"event": "push", "repoId": "abc"}'
594 sig = _sign_payload(secret, body)
595
596 # Simulate receiver verification
597 hex_digest = sig[len("sha256="):]
598 expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
599 assert hmac.compare_digest(hex_digest, expected)
600
601 async def test_webhook_requires_auth(
602 self, client: AsyncClient, db_session: AsyncSession
603 ) -> None:
604 # Create repo directly in DB (no auth_headers fixture — override must not be active)
605 repo_id = await _db_repo(db_session)
606 await db_session.commit()
607
608 # No auth headers → require_signed_request should reject
609 resp = await client.post(
610 f"/api/repos/{repo_id}/webhooks",
611 json={"url": "https://example.com/h", "events": ["push"], "secret": ""},
612 )
613 assert resp.status_code == 401
614
615
616 # ── Layer 7 — Performance ─────────────────────────────────────────────────────
617
618
619 class TestPerformanceWebhooks:
620 def test_10000_sign_payload_under_500ms(self) -> None:
621 body = b'{"event": "push", "repoId": "abc123"}'
622 secret = "perf-test-secret"
623 start = time.perf_counter()
624 for _ in range(10_000):
625 _sign_payload(secret, body)
626 elapsed = time.perf_counter() - start
627 assert elapsed < 0.5, f"10,000 _sign_payload calls took {elapsed:.3f}s (expected <0.5s)"
628
629 async def test_create_50_webhooks_under_2s(
630 self, db_session: AsyncSession
631 ) -> None:
632 repo_id = await _db_repo(db_session)
633 start = time.perf_counter()
634 for i in range(50):
635 await create_webhook(
636 db_session,
637 repo_id=repo_id,
638 url=f"https://perf{i}.example.com/hook",
639 events=["push"],
640 secret="",
641 )
642 await db_session.commit()
643 elapsed = time.perf_counter() - start
644 assert elapsed < 2.0, f"50 create_webhook calls took {elapsed:.3f}s (expected <2s)"
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago