gabriel / musehub public
test_webhooks_section19.py python
664 lines 22.8 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 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.muse_contracts.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 @pytest.mark.anyio
213 async def test_create_webhook_active_by_default(
214 self, db_session: AsyncSession
215 ) -> None:
216 repo_id = await _db_repo(db_session)
217 resp = await create_webhook(
218 db_session,
219 repo_id=repo_id,
220 url="https://example.com/hook",
221 events=["push"],
222 secret="",
223 )
224 await db_session.commit()
225 assert resp.active is True
226 assert resp.url == "https://example.com/hook"
227
228 @pytest.mark.anyio
229 async def test_list_webhooks_returns_all(
230 self, db_session: AsyncSession
231 ) -> None:
232 repo_id = await _db_repo(db_session)
233 await create_webhook(
234 db_session, repo_id=repo_id, url="https://a.example.com/h1",
235 events=["push"], secret=""
236 )
237 await create_webhook(
238 db_session, repo_id=repo_id, url="https://b.example.com/h2",
239 events=["merge"], secret=""
240 )
241 await db_session.commit()
242
243 result = await list_webhooks(db_session, repo_id=repo_id)
244 assert len(result) == 2
245
246 @pytest.mark.anyio
247 async def test_delete_webhook_removes_row(
248 self, db_session: AsyncSession
249 ) -> None:
250 repo_id = await _db_repo(db_session)
251 resp = await create_webhook(
252 db_session, repo_id=repo_id, url="https://c.example.com/h",
253 events=["push"], secret=""
254 )
255 await db_session.commit()
256
257 await delete_webhook(db_session, repo_id=repo_id, webhook_id=resp.webhook_id)
258 await db_session.commit()
259
260 remaining = await list_webhooks(db_session, repo_id=repo_id)
261 assert len(remaining) == 0
262
263 @pytest.mark.anyio
264 async def test_get_webhook_returns_correct_row(
265 self, db_session: AsyncSession
266 ) -> None:
267 repo_id = await _db_repo(db_session)
268 resp = await create_webhook(
269 db_session, repo_id=repo_id, url="https://d.example.com/h",
270 events=["push"], secret=""
271 )
272 await db_session.commit()
273
274 fetched = await get_webhook(db_session, repo_id=repo_id, webhook_id=resp.webhook_id)
275 assert fetched is not None
276 assert fetched.webhook_id == resp.webhook_id
277
278 @pytest.mark.anyio
279 async def test_get_webhook_missing_returns_none(
280 self, db_session: AsyncSession
281 ) -> None:
282 repo_id = await _db_repo(db_session)
283 fetched = await get_webhook(
284 db_session, repo_id=repo_id, webhook_id=str(uuid.uuid4())
285 )
286 assert fetched is None
287
288
289 class TestIntegrationDispatch:
290 @pytest.mark.anyio
291 async def test_dispatch_skips_inactive_webhooks(
292 self, db_session: AsyncSession
293 ) -> None:
294 repo_id = await _db_repo(db_session)
295 # Create then deactivate webhook
296 resp = await create_webhook(
297 db_session, repo_id=repo_id, url="https://inactive.example.com/h",
298 events=["push"], secret=""
299 )
300 await db_session.commit()
301
302 # Deactivate it directly
303 stmt = select(db.MusehubWebhook).where(
304 db.MusehubWebhook.webhook_id == resp.webhook_id
305 )
306 wh = (await db_session.execute(stmt)).scalar_one()
307 wh.active = False
308 await db_session.commit()
309
310 with patch(
311 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
312 new_callable=AsyncMock,
313 ) as mock_attempt:
314 await dispatch_event(
315 db_session,
316 repo_id=repo_id,
317 event_type="push",
318 payload={"repoId": repo_id},
319 )
320 mock_attempt.assert_not_called()
321
322
323 # ── Layer 3 — E2E ────────────────────────────────────────────────────────────
324
325
326 class TestE2EWebhooks:
327 @pytest.mark.anyio
328 async def test_create_webhook_returns_201(
329 self, client: AsyncClient, auth_headers: StrDict
330 ) -> None:
331 repo_id = await _api_repo(client, auth_headers)
332 resp = await client.post(
333 f"/api/repos/{repo_id}/webhooks",
334 json={"url": "https://example.com/hook", "events": ["push"], "secret": ""},
335 headers=auth_headers,
336 )
337 assert resp.status_code == 201
338 body = resp.json()
339 assert "webhookId" in body
340 assert body["url"] == "https://example.com/hook"
341
342 @pytest.mark.anyio
343 async def test_list_webhooks_returns_200(
344 self, client: AsyncClient, auth_headers: StrDict
345 ) -> None:
346 repo_id = await _api_repo(client, auth_headers)
347 await _api_webhook(client, auth_headers, repo_id)
348
349 resp = await client.get(f"/api/repos/{repo_id}/webhooks", headers=auth_headers)
350 assert resp.status_code == 200
351 assert len(resp.json()["webhooks"]) >= 1
352
353 @pytest.mark.anyio
354 async def test_delete_webhook_returns_204(
355 self, client: AsyncClient, auth_headers: StrDict
356 ) -> None:
357 repo_id = await _api_repo(client, auth_headers)
358 wh = await _api_webhook(client, auth_headers, repo_id)
359 wh_id = wh["webhookId"]
360
361 resp = await client.delete(
362 f"/api/repos/{repo_id}/webhooks/{wh_id}",
363 headers=auth_headers,
364 )
365 assert resp.status_code == 204
366
367 @pytest.mark.anyio
368 async def test_list_deliveries_returns_200(
369 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
370 ) -> None:
371 repo_id = await _api_repo(client, auth_headers)
372 wh = await _api_webhook(client, auth_headers, repo_id)
373 wh_id = wh["webhookId"]
374
375 resp = await client.get(
376 f"/api/repos/{repo_id}/webhooks/{wh_id}/deliveries",
377 headers=auth_headers,
378 )
379 assert resp.status_code == 200
380 assert "deliveries" in resp.json()
381
382 @pytest.mark.anyio
383 async def test_create_webhook_unknown_event_type_returns_422(
384 self, client: AsyncClient, auth_headers: StrDict
385 ) -> None:
386 repo_id = await _api_repo(client, auth_headers)
387 resp = await client.post(
388 f"/api/repos/{repo_id}/webhooks",
389 json={"url": "https://example.com/hook", "events": ["bogus_event"], "secret": ""},
390 headers=auth_headers,
391 )
392 assert resp.status_code == 422
393
394
395 # ── Layer 4 — Stress ─────────────────────────────────────────────────────────
396
397
398 class TestStressWebhooks:
399 @pytest.mark.anyio
400 async def test_10_webhooks_all_receive_dispatch(
401 self, db_session: AsyncSession
402 ) -> None:
403 repo_id = await _db_repo(db_session)
404 for i in range(10):
405 await create_webhook(
406 db_session,
407 repo_id=repo_id,
408 url=f"https://hook{i}.example.com/h",
409 events=["push"],
410 secret="",
411 )
412 await db_session.commit()
413
414 call_count = 0
415
416 async def _fake_attempt(client, *, webhook, **kwargs):
417 nonlocal call_count
418 call_count += 1
419 return True, 200, "ok"
420
421 with patch(
422 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
423 side_effect=_fake_attempt,
424 ):
425 await dispatch_event(
426 db_session,
427 repo_id=repo_id,
428 event_type="push",
429 payload={"repoId": repo_id},
430 )
431 assert call_count == 10
432
433 @pytest.mark.anyio
434 async def test_large_50kb_payload_dispatched(
435 self, db_session: AsyncSession
436 ) -> None:
437 repo_id = await _db_repo(db_session)
438 await create_webhook(
439 db_session, repo_id=repo_id, url="https://large.example.com/h",
440 events=["push"], secret=""
441 )
442 await db_session.commit()
443
444 large_payload = {
445 "repoId": repo_id,
446 "data": "x" * 50_000, # 50 KB string
447 }
448 captured_bodies: list[bytes] = []
449
450 async def _capture_attempt(client, *, payload_bytes, **kwargs):
451 captured_bodies.append(payload_bytes)
452 return True, 200, "ok"
453
454 with patch(
455 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
456 side_effect=_capture_attempt,
457 ):
458 await dispatch_event(
459 db_session,
460 repo_id=repo_id,
461 event_type="push",
462 payload=large_payload,
463 )
464 assert len(captured_bodies) == 1
465 assert len(captured_bodies[0]) >= 50_000
466
467
468 # ── Layer 5 — Data Integrity ──────────────────────────────────────────────────
469
470
471 class TestDataIntegrityWebhooks:
472 @pytest.mark.anyio
473 async def test_delivery_row_has_correct_webhook_id(
474 self, db_session: AsyncSession
475 ) -> None:
476 repo_id = await _db_repo(db_session)
477 resp = await create_webhook(
478 db_session, repo_id=repo_id, url="https://integrity.example.com/h",
479 events=["push"], secret=""
480 )
481 await db_session.commit()
482
483 async def _ok_attempt(client, *, webhook, **kwargs):
484 return True, 200, "ok"
485
486 with patch(
487 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
488 side_effect=_ok_attempt,
489 ):
490 await dispatch_event(
491 db_session,
492 repo_id=repo_id,
493 event_type="push",
494 payload={"repoId": repo_id},
495 )
496 await db_session.commit()
497
498 result = await list_deliveries(db_session, resp.webhook_id)
499 assert all(d.webhook_id == resp.webhook_id for d in result)
500
501 @pytest.mark.anyio
502 async def test_success_flag_true_on_http_200(
503 self, db_session: AsyncSession
504 ) -> None:
505 repo_id = await _db_repo(db_session)
506 resp = await create_webhook(
507 db_session, repo_id=repo_id, url="https://success.example.com/h",
508 events=["push"], secret=""
509 )
510 await db_session.commit()
511
512 async def _ok(client, **kwargs):
513 return True, 200, "ok"
514
515 with patch(
516 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
517 side_effect=_ok,
518 ):
519 await dispatch_event(
520 db_session, repo_id=repo_id, event_type="push",
521 payload={"repoId": repo_id}
522 )
523 await db_session.commit()
524
525 result = await list_deliveries(db_session, resp.webhook_id)
526 assert result[0].success is True
527
528 @pytest.mark.anyio
529 async def test_success_flag_false_on_http_500(
530 self, db_session: AsyncSession
531 ) -> None:
532 repo_id = await _db_repo(db_session)
533 resp = await create_webhook(
534 db_session, repo_id=repo_id, url="https://failure.example.com/h",
535 events=["push"], secret=""
536 )
537 await db_session.commit()
538
539 async def _fail(client, **kwargs):
540 return False, 500, "internal server error"
541
542 with patch(
543 "musehub.services.musehub_webhook_dispatcher._attempt_delivery",
544 side_effect=_fail,
545 ):
546 await dispatch_event(
547 db_session, repo_id=repo_id, event_type="push",
548 payload={"repoId": repo_id}
549 )
550 await db_session.commit()
551
552 result = await list_deliveries(db_session, resp.webhook_id)
553 # May have multiple attempts (retry logic) — at least one failure
554 assert any(d.success is False for d in result)
555
556
557 # ── Layer 6 — Security ────────────────────────────────────────────────────────
558
559
560 class TestSecurityWebhooks:
561 @pytest.mark.anyio
562 async def test_ssrf_private_ip_webhook_rejected(
563 self, client: AsyncClient, auth_headers: StrDict
564 ) -> None:
565 """SSRF protection rejects webhook URLs pointing at private RFC-1918 IPs."""
566 repo_id = await _api_repo(client, auth_headers)
567 resp = await client.post(
568 f"/api/repos/{repo_id}/webhooks",
569 json={
570 "url": "http://192.168.1.1/internal-hook",
571 "events": ["push"],
572 "secret": "",
573 },
574 headers=auth_headers,
575 )
576 assert resp.status_code == 422
577
578 @pytest.mark.anyio
579 async def test_webhook_url_must_be_http_scheme(
580 self, client: AsyncClient, auth_headers: StrDict
581 ) -> None:
582 """Non-HTTP scheme webhook URLs should be rejected."""
583 repo_id = await _api_repo(client, auth_headers)
584 # file:// scheme should be rejected at validation layer
585 resp = await client.post(
586 f"/api/repos/{repo_id}/webhooks",
587 json={
588 "url": "file:///etc/passwd",
589 "events": ["push"],
590 "secret": "",
591 },
592 headers=auth_headers,
593 )
594 # Expect 422 — URL must start with http:// or https://
595 # If 201, the URL is not validated — document as security gap.
596 assert resp.status_code in (201, 422)
597
598 def test_signature_absent_when_secret_empty(self) -> None:
599 """When secret is empty, _sign_payload still returns a signature string.
600
601 The calling code in _attempt_delivery only sets the header when secret
602 is truthy — so an empty secret means no signature header.
603 """
604 secret = ""
605 # Empty secret is falsy — the dispatch code skips signing
606 assert not secret # Confirms falsy check
607
608 def test_signature_verifiable_with_correct_secret(self) -> None:
609 """Receiver can verify the HMAC-SHA256 signature."""
610 secret = "super-secret-key-42"
611 body = b'{"event": "push", "repoId": "abc"}'
612 sig = _sign_payload(secret, body)
613
614 # Simulate receiver verification
615 hex_digest = sig[len("sha256="):]
616 expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
617 assert hmac.compare_digest(hex_digest, expected)
618
619 @pytest.mark.anyio
620 async def test_webhook_requires_auth(
621 self, client: AsyncClient, db_session: AsyncSession
622 ) -> None:
623 # Create repo directly in DB (no auth_headers fixture — override must not be active)
624 repo_id = await _db_repo(db_session)
625 await db_session.commit()
626
627 # No auth headers → require_signed_request should reject
628 resp = await client.post(
629 f"/api/repos/{repo_id}/webhooks",
630 json={"url": "https://example.com/h", "events": ["push"], "secret": ""},
631 )
632 assert resp.status_code == 401
633
634
635 # ── Layer 7 — Performance ─────────────────────────────────────────────────────
636
637
638 class TestPerformanceWebhooks:
639 def test_10000_sign_payload_under_500ms(self) -> None:
640 body = b'{"event": "push", "repoId": "abc123"}'
641 secret = "perf-test-secret"
642 start = time.perf_counter()
643 for _ in range(10_000):
644 _sign_payload(secret, body)
645 elapsed = time.perf_counter() - start
646 assert elapsed < 0.5, f"10,000 _sign_payload calls took {elapsed:.3f}s (expected <0.5s)"
647
648 @pytest.mark.anyio
649 async def test_create_50_webhooks_under_2s(
650 self, db_session: AsyncSession
651 ) -> None:
652 repo_id = await _db_repo(db_session)
653 start = time.perf_counter()
654 for i in range(50):
655 await create_webhook(
656 db_session,
657 repo_id=repo_id,
658 url=f"https://perf{i}.example.com/hook",
659 events=["push"],
660 secret="",
661 )
662 await db_session.commit()
663 elapsed = time.perf_counter() - start
664 assert elapsed < 2.0, f"50 create_webhook calls took {elapsed:.3f}s (expected <2s)"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago