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