gabriel / musehub public
test_musehub_webhooks.py python
1,195 lines 42.5 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Tests for MuseHub webhook subscription endpoints and dispatch.
2
3 Covers every acceptance criterion:
4 - POST /repos/{repo_id}/webhooks registers a webhook with URL and events
5 - GET /repos/{repo_id}/webhooks lists registered webhooks
6 - DELETE /repos/{repo_id}/webhooks/{webhook_id} removes a webhook
7 - GET /repos/{repo_id}/webhooks/{webhook_id}/deliveries lists delivery history
8 - POST /repos/{repo_id}/webhooks/{webhook_id}/deliveries/{id}/redeliver retries delivery
9 - HMAC-SHA256 signature computation is correct
10 - Webhook dispatch fires for matching events
11 - Delivery logging records success/failure per attempt
12 - Retries attempted on failure (up to _MAX_ATTEMPTS)
13 - Webhooks require valid MSign auth
14
15 All tests use shared ``client``, ``auth_headers``, and ``db_session`` fixtures
16 from conftest.py.
17 """
18 from __future__ import annotations
19
20 import hashlib
21 import hmac
22 import json
23 from unittest.mock import AsyncMock, MagicMock, patch
24
25 import pytest
26 from httpx import AsyncClient
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from musehub.db.musehub_models import MusehubRepo
30 from musehub.models.musehub import IssueEventPayload, PushEventPayload
31 from musehub.services import musehub_webhook_dispatcher
32 from musehub.muse_contracts.json_types import JSONObject, StrDict
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40 async def _ensure_repo(session: AsyncSession, repo_id: str) -> None:
41 """Insert a minimal MusehubRepo with the given ID so FK constraints pass."""
42 repo = MusehubRepo(
43 repo_id=repo_id,
44 name=repo_id,
45 owner="testuser",
46 slug=repo_id,
47 owner_user_id="uid-testuser",
48 )
49 session.add(repo)
50 await session.flush()
51
52
53 async def _create_repo(
54 client: AsyncClient,
55 auth_headers: StrDict,
56 name: str = "webhook-test-repo",
57 ) -> str:
58 resp = await client.post(
59 "/api/repos",
60 json={"name": name, "owner": "testuser"},
61 headers=auth_headers,
62 )
63 assert resp.status_code == 201
64 repo_id: str = resp.json()["repoId"]
65 return repo_id
66
67
68 async def _create_webhook(
69 client: AsyncClient,
70 auth_headers: StrDict,
71 repo_id: str,
72 url: str = "https://example.com/hook",
73 events: list[str] | None = None,
74 secret: str = "",
75 ) -> JSONObject:
76 resp = await client.post(
77 f"/api/repos/{repo_id}/webhooks",
78 json={"url": url, "events": events or ["push"], "secret": secret},
79 headers=auth_headers,
80 )
81 assert resp.status_code == 201
82 data = resp.json()
83 return data
84
85
86 # ---------------------------------------------------------------------------
87 # POST /repos/{repo_id}/webhooks
88 # ---------------------------------------------------------------------------
89
90
91 @pytest.mark.anyio
92 async def test_create_webhook_returns_201(
93 client: AsyncClient,
94 auth_headers: StrDict,
95 ) -> None:
96 """POST /webhooks registers a webhook subscription and returns 201."""
97 repo_id = await _create_repo(client, auth_headers, "create-wh-repo")
98 resp = await client.post(
99 f"/api/repos/{repo_id}/webhooks",
100 json={"url": "https://example.com/hook", "events": ["push", "issue"]},
101 headers=auth_headers,
102 )
103 assert resp.status_code == 201
104 data = resp.json()
105 assert data["repoId"] == repo_id
106 assert data["url"] == "https://example.com/hook"
107 assert set(data["events"]) == {"push", "issue"}
108 assert data["active"] is True
109 assert "webhookId" in data
110
111
112 @pytest.mark.anyio
113 async def test_create_webhook_unknown_event_type_returns_422(
114 client: AsyncClient,
115 auth_headers: StrDict,
116 ) -> None:
117 """POST /webhooks with an unknown event type is rejected with 422."""
118 repo_id = await _create_repo(client, auth_headers, "bad-event-repo")
119 resp = await client.post(
120 f"/api/repos/{repo_id}/webhooks",
121 json={"url": "https://example.com/hook", "events": ["not_a_real_event"]},
122 headers=auth_headers,
123 )
124 assert resp.status_code == 422
125
126
127 @pytest.mark.anyio
128 async def test_create_webhook_unknown_repo_returns_404(
129 client: AsyncClient,
130 auth_headers: StrDict,
131 ) -> None:
132 """POST /webhooks for a non-existent repo returns 404."""
133 resp = await client.post(
134 "/api/repos/does-not-exist/webhooks",
135 json={"url": "https://example.com/hook", "events": ["push"]},
136 headers=auth_headers,
137 )
138 assert resp.status_code == 404
139
140
141 # ---------------------------------------------------------------------------
142 # GET /repos/{repo_id}/webhooks
143 # ---------------------------------------------------------------------------
144
145
146 @pytest.mark.anyio
147 async def test_list_webhooks_returns_registered_webhooks(
148 client: AsyncClient,
149 auth_headers: StrDict,
150 ) -> None:
151 """GET /webhooks returns all registered webhooks for a repo."""
152 repo_id = await _create_repo(client, auth_headers, "list-wh-repo")
153 await _create_webhook(client, auth_headers, repo_id, url="https://a.example.com/hook", events=["push"])
154 await _create_webhook(client, auth_headers, repo_id, url="https://b.example.com/hook", events=["issue"])
155
156 resp = await client.get(
157 f"/api/repos/{repo_id}/webhooks",
158 headers=auth_headers,
159 )
160 assert resp.status_code == 200
161 webhooks = resp.json()["webhooks"]
162 assert len(webhooks) == 2
163 urls = {w["url"] for w in webhooks}
164 assert urls == {"https://a.example.com/hook", "https://b.example.com/hook"}
165
166
167 @pytest.mark.anyio
168 async def test_list_webhooks_empty_repo(
169 client: AsyncClient,
170 auth_headers: StrDict,
171 ) -> None:
172 """GET /webhooks for a repo with no webhooks returns an empty list."""
173 repo_id = await _create_repo(client, auth_headers, "empty-wh-repo")
174 resp = await client.get(
175 f"/api/repos/{repo_id}/webhooks",
176 headers=auth_headers,
177 )
178 assert resp.status_code == 200
179 assert resp.json()["webhooks"] == []
180
181
182 # ---------------------------------------------------------------------------
183 # DELETE /repos/{repo_id}/webhooks/{webhook_id}
184 # ---------------------------------------------------------------------------
185
186
187 @pytest.mark.anyio
188 async def test_delete_webhook_removes_subscription(
189 client: AsyncClient,
190 auth_headers: StrDict,
191 ) -> None:
192 """DELETE /webhooks/{id} removes the webhook and returns 204."""
193 repo_id = await _create_repo(client, auth_headers, "del-wh-repo")
194 wh = await _create_webhook(client, auth_headers, repo_id)
195 webhook_id = wh["webhookId"]
196
197 resp = await client.delete(
198 f"/api/repos/{repo_id}/webhooks/{webhook_id}",
199 headers=auth_headers,
200 )
201 assert resp.status_code == 204
202
203 # Verify it's gone
204 list_resp = await client.get(
205 f"/api/repos/{repo_id}/webhooks",
206 headers=auth_headers,
207 )
208 assert list_resp.json()["webhooks"] == []
209
210
211 @pytest.mark.anyio
212 async def test_delete_webhook_not_found_returns_404(
213 client: AsyncClient,
214 auth_headers: StrDict,
215 ) -> None:
216 """DELETE /webhooks/{id} for a non-existent webhook returns 404."""
217 repo_id = await _create_repo(client, auth_headers, "del-missing-wh-repo")
218 resp = await client.delete(
219 f"/api/repos/{repo_id}/webhooks/does-not-exist",
220 headers=auth_headers,
221 )
222 assert resp.status_code == 404
223
224
225 # ---------------------------------------------------------------------------
226 # GET /repos/{repo_id}/webhooks/{webhook_id}/deliveries
227 # ---------------------------------------------------------------------------
228
229
230 @pytest.mark.anyio
231 async def test_list_deliveries_empty_on_new_webhook(
232 client: AsyncClient,
233 auth_headers: StrDict,
234 ) -> None:
235 """GET /deliveries returns an empty list for a newly created webhook."""
236 repo_id = await _create_repo(client, auth_headers, "deliveries-repo")
237 wh = await _create_webhook(client, auth_headers, repo_id)
238 webhook_id = wh["webhookId"]
239
240 resp = await client.get(
241 f"/api/repos/{repo_id}/webhooks/{webhook_id}/deliveries",
242 headers=auth_headers,
243 )
244 assert resp.status_code == 200
245 assert resp.json()["deliveries"] == []
246
247
248 @pytest.mark.anyio
249 async def test_list_deliveries_not_found_webhook_returns_404(
250 client: AsyncClient,
251 auth_headers: StrDict,
252 ) -> None:
253 """GET /deliveries for a non-existent webhook returns 404."""
254 repo_id = await _create_repo(client, auth_headers, "deliveries-404-repo")
255 resp = await client.get(
256 f"/api/repos/{repo_id}/webhooks/missing-id/deliveries",
257 headers=auth_headers,
258 )
259 assert resp.status_code == 404
260
261
262 # ---------------------------------------------------------------------------
263 # Auth requirements
264 # ---------------------------------------------------------------------------
265
266
267 @pytest.mark.anyio
268 async def test_create_webhook_requires_auth(
269 client: AsyncClient,
270 auth_headers: StrDict,
271 ) -> None:
272 """POST /webhooks without MSign Authorization header returns 401."""
273 from musehub.auth.request_signing import optional_signed_request, require_signed_request
274 from musehub.main import app as _app
275
276 repo_id = await _create_repo(client, auth_headers, "auth-wh-repo")
277 _app.dependency_overrides.pop(require_signed_request, None)
278 _app.dependency_overrides.pop(optional_signed_request, None)
279 resp = await client.post(
280 f"/api/repos/{repo_id}/webhooks",
281 json={"url": "https://example.com/hook", "events": ["push"]},
282 )
283 assert resp.status_code == 401
284
285
286 @pytest.mark.anyio
287 async def test_list_webhooks_requires_auth(
288 client: AsyncClient,
289 auth_headers: StrDict,
290 ) -> None:
291 """GET /webhooks without MSign Authorization header returns 401."""
292 from musehub.auth.request_signing import optional_signed_request, require_signed_request
293 from musehub.main import app as _app
294
295 repo_id = await _create_repo(client, auth_headers, "auth-list-wh-repo")
296 _app.dependency_overrides.pop(require_signed_request, None)
297 _app.dependency_overrides.pop(optional_signed_request, None)
298 resp = await client.get(f"/api/repos/{repo_id}/webhooks")
299 assert resp.status_code == 401
300
301
302 @pytest.mark.anyio
303 async def test_delete_webhook_requires_auth(
304 client: AsyncClient,
305 auth_headers: StrDict,
306 ) -> None:
307 """DELETE /webhooks/{id} without MSign Authorization header returns 401."""
308 from musehub.auth.request_signing import optional_signed_request, require_signed_request
309 from musehub.main import app as _app
310
311 repo_id = await _create_repo(client, auth_headers, "auth-del-wh-repo")
312 wh = await _create_webhook(client, auth_headers, repo_id)
313 _app.dependency_overrides.pop(require_signed_request, None)
314 _app.dependency_overrides.pop(optional_signed_request, None)
315 resp = await client.delete(
316 f"/api/repos/{repo_id}/webhooks/{wh['webhookId']}",
317 )
318 assert resp.status_code == 401
319
320
321 # ---------------------------------------------------------------------------
322 # HMAC-SHA256 signature
323 # ---------------------------------------------------------------------------
324
325
326 def test_webhook_signature_correct() -> None:
327 """_sign_payload computes HMAC-SHA256 matching the reference implementation."""
328 secret = "my-super-secret"
329 body = b'{"repoId": "abc", "event": "push"}'
330 expected_mac = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
331 expected = f"sha256={expected_mac}"
332
333 result = musehub_webhook_dispatcher._sign_payload(secret, body)
334 assert result == expected
335
336
337 def test_webhook_signature_empty_secret_still_signs() -> None:
338 """_sign_payload with empty secret produces a sha256 value (not skipped)."""
339 body = b'{"test": true}'
340 result = musehub_webhook_dispatcher._sign_payload("", body)
341 assert result.startswith("sha256=")
342 assert len(result) > len("sha256=")
343
344
345 # ---------------------------------------------------------------------------
346 # Dispatch logic (unit tests with mocked HTTP)
347 # ---------------------------------------------------------------------------
348
349
350 @pytest.mark.anyio
351 async def test_dispatch_event_delivers_to_matching_webhooks(
352 db_session: AsyncSession,
353 ) -> None:
354 """dispatch_event POSTs to webhooks subscribed to the given event type."""
355 from musehub.services import musehub_webhook_dispatcher as disp
356
357 await _ensure_repo(db_session, "repo-abc")
358 await disp.create_webhook(
359 db_session,
360 repo_id="repo-abc",
361 url="https://example.com/push-hook",
362 events=["push"],
363 secret="",
364 )
365 await disp.create_webhook(
366 db_session,
367 repo_id="repo-abc",
368 url="https://example.com/issue-hook",
369 events=["issue"],
370 secret="",
371 )
372 await db_session.flush()
373
374 posted_urls: list[str] = []
375
376 async def _fake_post(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
377 posted_urls.append(url)
378 mock_resp = MagicMock()
379 mock_resp.is_success = True
380 mock_resp.status_code = 200
381 mock_resp.text = "ok"
382 return mock_resp
383
384 push_payload: PushEventPayload = {
385 "repoId": "repo-abc",
386 "branch": "main",
387 "headCommitId": "abc123",
388 "pushedBy": "test-user",
389 "commitCount": 1,
390 }
391
392 with patch("httpx.AsyncClient") as mock_client_cls:
393 mock_client = AsyncMock()
394 mock_client.post = _fake_post
395 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
396 mock_client.__aexit__ = AsyncMock(return_value=False)
397 mock_client_cls.return_value = mock_client
398
399 await disp.dispatch_event(
400 db_session,
401 repo_id="repo-abc",
402 event_type="push",
403 payload=push_payload,
404 )
405
406 assert posted_urls == ["https://example.com/push-hook"]
407
408
409 @pytest.mark.anyio
410 async def test_dispatch_event_skips_non_matching_event(
411 db_session: AsyncSession,
412 ) -> None:
413 """dispatch_event does not POST when no webhook subscribes to the event type."""
414 from musehub.services import musehub_webhook_dispatcher as disp
415
416 await _ensure_repo(db_session, "repo-xyz")
417 await disp.create_webhook(
418 db_session,
419 repo_id="repo-xyz",
420 url="https://example.com/hook",
421 events=["issue"],
422 secret="",
423 )
424 await db_session.flush()
425
426 posted_urls: list[str] = []
427
428 async def _fake_post(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
429 posted_urls.append(url)
430 mock_resp = MagicMock()
431 mock_resp.is_success = True
432 mock_resp.status_code = 200
433 mock_resp.text = "ok"
434 return mock_resp
435
436 push_payload: PushEventPayload = {
437 "repoId": "repo-xyz",
438 "branch": "main",
439 "headCommitId": "xyz789",
440 "pushedBy": "test-user",
441 "commitCount": 0,
442 }
443
444 with patch("httpx.AsyncClient") as mock_client_cls:
445 mock_client = AsyncMock()
446 mock_client.post = _fake_post
447 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
448 mock_client.__aexit__ = AsyncMock(return_value=False)
449 mock_client_cls.return_value = mock_client
450
451 await disp.dispatch_event(
452 db_session,
453 repo_id="repo-xyz",
454 event_type="push",
455 payload=push_payload,
456 )
457
458 assert posted_urls == []
459
460
461 @pytest.mark.anyio
462 async def test_dispatch_event_logs_delivery_on_success(
463 db_session: AsyncSession,
464 ) -> None:
465 """dispatch_event creates a MusehubWebhookDelivery row on a successful delivery."""
466 from musehub.services import musehub_webhook_dispatcher as disp
467 from musehub.db import musehub_models as db_models
468 from sqlalchemy import select
469
470 await _ensure_repo(db_session, "repo-log")
471 wh = await disp.create_webhook(
472 db_session,
473 repo_id="repo-log",
474 url="https://log.example.com/hook",
475 events=["push"],
476 secret="",
477 )
478 await db_session.flush()
479
480 async def _fake_post(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
481 mock_resp = MagicMock()
482 mock_resp.is_success = True
483 mock_resp.status_code = 200
484 mock_resp.text = "accepted"
485 return mock_resp
486
487 log_payload: PushEventPayload = {
488 "repoId": "repo-log",
489 "branch": "main",
490 "headCommitId": "log123",
491 "pushedBy": "test-user",
492 "commitCount": 1,
493 }
494
495 with (
496 patch("httpx.AsyncClient") as mock_client_cls,
497 patch("musehub.security.ssrf.validate_outbound_url", new_callable=AsyncMock) as mock_ssrf,
498 ):
499 mock_ssrf.return_value = (True, 0, "")
500 mock_client = AsyncMock()
501 mock_client.post = _fake_post
502 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
503 mock_client.__aexit__ = AsyncMock(return_value=False)
504 mock_client_cls.return_value = mock_client
505
506 await disp.dispatch_event(
507 db_session,
508 repo_id="repo-log",
509 event_type="push",
510 payload=log_payload,
511 )
512
513 stmt = select(db_models.MusehubWebhookDelivery).where(
514 db_models.MusehubWebhookDelivery.webhook_id == wh.webhook_id
515 )
516 rows = (await db_session.execute(stmt)).scalars().all()
517 assert len(rows) == 1
518 assert rows[0].success is True
519 assert rows[0].response_status == 200
520 assert rows[0].event_type == "push"
521 assert rows[0].attempt == 1
522
523
524 @pytest.mark.anyio
525 async def test_webhook_retry_on_failure_logs_multiple_attempts(
526 db_session: AsyncSession,
527 ) -> None:
528 """dispatch_event retries up to _MAX_ATTEMPTS and logs each attempt."""
529 from musehub.services import musehub_webhook_dispatcher as disp
530 from musehub.db import musehub_models as db_models
531 from sqlalchemy import select
532
533 await _ensure_repo(db_session, "repo-retry")
534 wh = await disp.create_webhook(
535 db_session,
536 repo_id="repo-retry",
537 url="https://retry.example.com/hook",
538 events=["push"],
539 secret="",
540 )
541 await db_session.flush()
542
543 attempt_count = 0
544
545 async def _always_fail(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
546 nonlocal attempt_count
547 attempt_count += 1
548 mock_resp = MagicMock()
549 mock_resp.is_success = False
550 mock_resp.status_code = 503
551 mock_resp.text = "service unavailable"
552 return mock_resp
553
554 retry_payload: PushEventPayload = {
555 "repoId": "repo-retry",
556 "branch": "main",
557 "headCommitId": "retry123",
558 "pushedBy": "test-user",
559 "commitCount": 1,
560 }
561
562 with (
563 patch("httpx.AsyncClient") as mock_client_cls,
564 patch("asyncio.sleep", new_callable=AsyncMock),
565 patch("musehub.security.ssrf.validate_outbound_url", new_callable=AsyncMock) as mock_ssrf,
566 ):
567 mock_ssrf.return_value = (True, 0, "")
568 mock_client = AsyncMock()
569 mock_client.post = _always_fail
570 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
571 mock_client.__aexit__ = AsyncMock(return_value=False)
572 mock_client_cls.return_value = mock_client
573
574 await disp.dispatch_event(
575 db_session,
576 repo_id="repo-retry",
577 event_type="push",
578 payload=retry_payload,
579 )
580
581 assert attempt_count == disp._MAX_ATTEMPTS
582
583 stmt = select(db_models.MusehubWebhookDelivery).where(
584 db_models.MusehubWebhookDelivery.webhook_id == wh.webhook_id
585 )
586 rows = (await db_session.execute(stmt)).scalars().all()
587 assert len(rows) == disp._MAX_ATTEMPTS
588 for row in rows:
589 assert row.success is False
590 assert row.response_status == 503
591
592
593 @pytest.mark.anyio
594 async def test_webhook_delivery_logging_records_failure_status(
595 db_session: AsyncSession,
596 ) -> None:
597 """Delivery rows record response_status=0 for network-level failures."""
598 import httpx
599 from musehub.services import musehub_webhook_dispatcher as disp
600 from musehub.db import musehub_models as db_models
601 from sqlalchemy import select
602
603 await _ensure_repo(db_session, "repo-net-err")
604 wh = await disp.create_webhook(
605 db_session,
606 repo_id="repo-net-err",
607 url="https://unreachable.example.com/hook",
608 events=["issue"],
609 secret="",
610 )
611 await db_session.flush()
612
613 async def _raise_network_error(url: str, **kwargs: str | bytes | int | None) -> None:
614 raise httpx.ConnectError("Connection refused")
615
616 net_err_payload: IssueEventPayload = {
617 "repoId": "repo-net-err",
618 "action": "opened",
619 "issueId": "issue-001",
620 "number": 1,
621 "title": "Test issue",
622 "state": "open",
623 }
624
625 with (
626 patch("httpx.AsyncClient") as mock_client_cls,
627 patch("asyncio.sleep", new_callable=AsyncMock),
628 ):
629 mock_client = AsyncMock()
630 mock_client.post = _raise_network_error
631 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
632 mock_client.__aexit__ = AsyncMock(return_value=False)
633 mock_client_cls.return_value = mock_client
634
635 await disp.dispatch_event(
636 db_session,
637 repo_id="repo-net-err",
638 event_type="issue",
639 payload=net_err_payload,
640 )
641
642 stmt = select(db_models.MusehubWebhookDelivery).where(
643 db_models.MusehubWebhookDelivery.webhook_id == wh.webhook_id
644 )
645 rows = (await db_session.execute(stmt)).scalars().all()
646 assert len(rows) == disp._MAX_ATTEMPTS
647 for row in rows:
648 assert row.success is False
649 assert row.response_status == 0
650
651
652 # ---------------------------------------------------------------------------
653 # Delivery history via API
654 # ---------------------------------------------------------------------------
655
656
657 @pytest.mark.anyio
658 async def test_list_deliveries_via_api_after_dispatch(
659 client: AsyncClient,
660 auth_headers: StrDict,
661 db_session: AsyncSession,
662 ) -> None:
663 """GET /deliveries reflects delivery rows written by dispatch_event."""
664 from musehub.services import musehub_webhook_dispatcher as disp
665
666 repo_id = await _create_repo(client, auth_headers, "delivery-api-repo")
667 wh_data = await _create_webhook(client, auth_headers, repo_id, events=["push"])
668 webhook_id = wh_data["webhookId"]
669
670 async def _fake_post(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
671 mock_resp = MagicMock()
672 mock_resp.is_success = True
673 mock_resp.status_code = 200
674 mock_resp.text = "ok"
675 return mock_resp
676
677 api_payload: PushEventPayload = {
678 "repoId": repo_id,
679 "branch": "main",
680 "headCommitId": "api123",
681 "pushedBy": "test-user",
682 "commitCount": 1,
683 }
684
685 with patch("httpx.AsyncClient") as mock_client_cls:
686 mock_client = AsyncMock()
687 mock_client.post = _fake_post
688 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
689 mock_client.__aexit__ = AsyncMock(return_value=False)
690 mock_client_cls.return_value = mock_client
691
692 await disp.dispatch_event(
693 db_session,
694 repo_id=repo_id,
695 event_type="push",
696 payload=api_payload,
697 )
698
699 await db_session.commit()
700
701 resp = await client.get(
702 f"/api/repos/{repo_id}/webhooks/{webhook_id}/deliveries",
703 headers=auth_headers,
704 )
705 assert resp.status_code == 200
706 deliveries = resp.json()["deliveries"]
707 assert len(deliveries) == 1
708 assert deliveries[0]["eventType"] == "push"
709 assert deliveries[0]["success"] is True
710 assert deliveries[0]["responseStatus"] == 200
711
712
713 # ---------------------------------------------------------------------------
714 # Webhook secret encryption
715 # ---------------------------------------------------------------------------
716
717
718 def test_encrypt_decrypt_roundtrip_with_key() -> None:
719 """encrypt_secret / decrypt_secret round-trips plaintext correctly when a key is set."""
720 from unittest.mock import patch
721 from cryptography.fernet import Fernet
722 from musehub.services import musehub_webhook_crypto as crypto
723
724 test_key = Fernet.generate_key().decode()
725 # Patch settings so the module picks up the test key on next initialisation.
726 with patch.object(crypto, "_fernet", None), patch.object(crypto, "_fernet_initialised", False):
727 with patch("musehub.services.musehub_webhook_crypto.settings") as mock_settings:
728 mock_settings.webhook_secret_key = test_key
729 plaintext = "super-secret-hmac-key-for-subscriber"
730 ciphertext = crypto.encrypt_secret(plaintext)
731 # Ciphertext must differ from plaintext — we encrypted it.
732 assert ciphertext != plaintext
733 # Round-trip must recover original value.
734 recovered = crypto.decrypt_secret(ciphertext)
735 assert recovered == plaintext
736
737
738 def test_encrypt_decrypt_empty_secret_passthrough() -> None:
739 """Empty secrets are passed through unchanged (no encryption needed)."""
740 from musehub.services import musehub_webhook_crypto as crypto
741
742 assert crypto.encrypt_secret("") == ""
743 assert crypto.decrypt_secret("") == ""
744
745
746 def test_decrypt_invalid_token_raises_value_error() -> None:
747 """decrypt_secret raises ValueError when a Fernet-prefixed token is corrupt/wrong-key.
748
749 Values that *look* like Fernet tokens (prefix "gAAAAAB") but cannot be
750 decrypted are genuine key-mismatch or corruption errors — we surface them
751 rather than silently falling back so operators notice misconfiguration.
752 """
753 from unittest.mock import patch
754 from cryptography.fernet import Fernet
755 from musehub.services import musehub_webhook_crypto as crypto
756
757 test_key = Fernet.generate_key().decode()
758 with patch.object(crypto, "_fernet", None), patch.object(crypto, "_fernet_initialised", False):
759 with patch("musehub.services.musehub_webhook_crypto.settings") as mock_settings:
760 mock_settings.webhook_secret_key = test_key
761 # Encrypt something so fernet is initialised, then pass a corrupted
762 # token that carries the Fernet prefix — this should raise ValueError.
763 crypto.encrypt_secret("seed")
764 corrupt_fernet_token = "gAAAAABthis-looks-like-fernet-but-is-corrupt"
765 with pytest.raises(ValueError, match="Failed to decrypt webhook secret"):
766 crypto.decrypt_secret(corrupt_fernet_token)
767
768
769 def test_decrypt_plaintext_secret_returns_value_when_key_set() -> None:
770 """decrypt_secret returns a plaintext secret as-is when it lacks the Fernet prefix.
771
772 This is the transparent migration fallback: secrets written
773 before MUSE_WEBHOOK_SECRET_KEY was enabled do not start with "gAAAAAB".
774 Rather than raising ValueError and breaking all existing webhooks, we
775 return the plaintext and emit a deprecation warning so operators know they
776 need to run the migration script.
777 """
778 from unittest.mock import patch
779 from cryptography.fernet import Fernet
780 from musehub.services import musehub_webhook_crypto as crypto
781
782 test_key = Fernet.generate_key().decode()
783 with patch.object(crypto, "_fernet", None), patch.object(crypto, "_fernet_initialised", False):
784 with patch("musehub.services.musehub_webhook_crypto.settings") as mock_settings:
785 mock_settings.webhook_secret_key = test_key
786 crypto.encrypt_secret("seed") # initialise singleton
787 plaintext_secret = "pre-migration-plaintext-secret"
788 # Must NOT start with "gAAAAAB" (simulates a legacy row)
789 assert not plaintext_secret.startswith("gAAAAAB")
790 result = crypto.decrypt_secret(plaintext_secret)
791 assert result == plaintext_secret
792
793
794 # ---------------------------------------------------------------------------
795 # POST /repos/{repo_id}/webhooks/{webhook_id}/deliveries/{id}/redeliver
796 # ---------------------------------------------------------------------------
797
798
799 @pytest.mark.anyio
800 async def test_redeliver_delivery_succeeds(
801 client: AsyncClient,
802 auth_headers: StrDict,
803 db_session: AsyncSession,
804 ) -> None:
805 """POST /redeliver replays the original payload and returns success=True on 2xx."""
806 from musehub.services import musehub_webhook_dispatcher as disp
807
808 repo_id = await _create_repo(client, auth_headers, "redeliver-ok-repo")
809 wh_data = await _create_webhook(client, auth_headers, repo_id, events=["push"])
810 webhook_id = wh_data["webhookId"]
811
812 push_payload: PushEventPayload = {
813 "repoId": repo_id,
814 "branch": "main",
815 "headCommitId": "redeliv01",
816 "pushedBy": "test-user",
817 "commitCount": 1,
818 }
819
820 async def _fail_then_ok(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
821 mock_resp = MagicMock()
822 mock_resp.is_success = False
823 mock_resp.status_code = 503
824 mock_resp.text = "unavailable"
825 return mock_resp
826
827 # Create an initial (failed) delivery so we have a delivery_id.
828 with (
829 patch("httpx.AsyncClient") as mock_cls,
830 patch("asyncio.sleep", new_callable=AsyncMock),
831 ):
832 mock_client = AsyncMock()
833 mock_client.post = _fail_then_ok
834 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
835 mock_client.__aexit__ = AsyncMock(return_value=False)
836 mock_cls.return_value = mock_client
837 await disp.dispatch_event(db_session, repo_id=repo_id, event_type="push", payload=push_payload)
838 await db_session.commit()
839
840 # Get the first (failed) delivery ID.
841 deliveries_resp = await client.get(
842 f"/api/repos/{repo_id}/webhooks/{webhook_id}/deliveries",
843 headers=auth_headers,
844 )
845 assert deliveries_resp.status_code == 200
846 deliveries = deliveries_resp.json()["deliveries"]
847 assert len(deliveries) > 0
848 delivery_id = deliveries[0]["deliveryId"]
849
850 # Now redeliver — this time the subscriber returns 200.
851 async def _ok(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
852 mock_resp = MagicMock()
853 mock_resp.is_success = True
854 mock_resp.status_code = 200
855 mock_resp.text = "accepted"
856 return mock_resp
857
858 with patch("httpx.AsyncClient") as mock_cls2:
859 mock_client2 = AsyncMock()
860 mock_client2.post = _ok
861 mock_client2.__aenter__ = AsyncMock(return_value=mock_client2)
862 mock_client2.__aexit__ = AsyncMock(return_value=False)
863 mock_cls2.return_value = mock_client2
864
865 redeliver_resp = await client.post(
866 f"/api/repos/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver",
867 headers=auth_headers,
868 )
869
870 assert redeliver_resp.status_code == 200
871 data = redeliver_resp.json()
872 assert data["originalDeliveryId"] == delivery_id
873 assert data["webhookId"] == webhook_id
874 assert data["success"] is True
875 assert data["responseStatus"] == 200
876
877
878 @pytest.mark.anyio
879 async def test_redeliver_delivery_not_found_returns_404(
880 client: AsyncClient,
881 auth_headers: StrDict,
882 ) -> None:
883 """POST /redeliver for a non-existent delivery_id returns 404."""
884 repo_id = await _create_repo(client, auth_headers, "redeliver-404-repo")
885 wh_data = await _create_webhook(client, auth_headers, repo_id)
886 webhook_id = wh_data["webhookId"]
887
888 resp = await client.post(
889 f"/api/repos/{repo_id}/webhooks/{webhook_id}/deliveries/does-not-exist/redeliver",
890 headers=auth_headers,
891 )
892 assert resp.status_code == 404
893
894
895 @pytest.mark.anyio
896 async def test_redeliver_delivery_wrong_webhook_returns_404(
897 client: AsyncClient,
898 auth_headers: StrDict,
899 ) -> None:
900 """POST /redeliver with a wrong webhook_id returns 404."""
901 repo_id = await _create_repo(client, auth_headers, "redeliver-wrong-wh-repo")
902 resp = await client.post(
903 f"/api/repos/{repo_id}/webhooks/no-such-webhook/deliveries/some-delivery/redeliver",
904 headers=auth_headers,
905 )
906 assert resp.status_code == 404
907
908
909 @pytest.mark.anyio
910 async def test_redeliver_delivery_requires_auth(
911 client: AsyncClient,
912 auth_headers: StrDict,
913 ) -> None:
914 """POST /redeliver without MSign Authorization header returns 401."""
915 from musehub.auth.request_signing import optional_signed_request, require_signed_request
916 from musehub.main import app as _app
917
918 repo_id = await _create_repo(client, auth_headers, "redeliver-auth-repo")
919 wh_data = await _create_webhook(client, auth_headers, repo_id)
920 webhook_id = wh_data["webhookId"]
921 _app.dependency_overrides.pop(require_signed_request, None)
922 _app.dependency_overrides.pop(optional_signed_request, None)
923 resp = await client.post(
924 f"/api/repos/{repo_id}/webhooks/{webhook_id}/deliveries/some-id/redeliver",
925 )
926 assert resp.status_code == 401
927
928
929 @pytest.mark.anyio
930 async def test_redeliver_delivery_stores_new_delivery_row(
931 client: AsyncClient,
932 auth_headers: StrDict,
933 db_session: AsyncSession,
934 ) -> None:
935 """POST /redeliver persists new delivery rows without mutating the original."""
936 from sqlalchemy import select
937 from musehub.db import musehub_models as db_models
938 from musehub.services import musehub_webhook_dispatcher as disp
939
940 repo_id = await _create_repo(client, auth_headers, "redeliver-rows-repo")
941 wh_data = await _create_webhook(client, auth_headers, repo_id, events=["push"])
942 webhook_id = wh_data["webhookId"]
943
944 push_payload: PushEventPayload = {
945 "repoId": repo_id,
946 "branch": "main",
947 "headCommitId": "rows-test",
948 "pushedBy": "test-user",
949 "commitCount": 1,
950 }
951
952 async def _ok(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
953 mock_resp = MagicMock()
954 mock_resp.is_success = True
955 mock_resp.status_code = 200
956 mock_resp.text = "ok"
957 return mock_resp
958
959 # Initial delivery.
960 with patch("httpx.AsyncClient") as mock_cls:
961 mock_client = AsyncMock()
962 mock_client.post = _ok
963 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
964 mock_client.__aexit__ = AsyncMock(return_value=False)
965 mock_cls.return_value = mock_client
966 await disp.dispatch_event(db_session, repo_id=repo_id, event_type="push", payload=push_payload)
967 await db_session.commit()
968
969 deliveries_resp = await client.get(
970 f"/api/repos/{repo_id}/webhooks/{webhook_id}/deliveries",
971 headers=auth_headers,
972 )
973 delivery_id = deliveries_resp.json()["deliveries"][0]["deliveryId"]
974
975 # Redeliver.
976 with patch("httpx.AsyncClient") as mock_cls2:
977 mock_client2 = AsyncMock()
978 mock_client2.post = _ok
979 mock_client2.__aenter__ = AsyncMock(return_value=mock_client2)
980 mock_client2.__aexit__ = AsyncMock(return_value=False)
981 mock_cls2.return_value = mock_client2
982 await client.post(
983 f"/api/repos/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver",
984 headers=auth_headers,
985 )
986
987 await db_session.commit()
988
989 # Two delivery rows should exist: the original + the redeliver attempt.
990 stmt = select(db_models.MusehubWebhookDelivery).where(
991 db_models.MusehubWebhookDelivery.webhook_id == webhook_id
992 )
993 rows = (await db_session.execute(stmt)).scalars().all()
994 assert len(rows) == 2
995
996 # Original row unchanged — the redeliver adds a brand-new row.
997 original = next(r for r in rows if r.delivery_id == delivery_id)
998 assert original.success is True
999
1000 # New row also has the stored payload.
1001 new_row = next(r for r in rows if r.delivery_id != delivery_id)
1002 assert new_row.payload != ""
1003 assert new_row.event_type == "push"
1004
1005
1006 @pytest.mark.anyio
1007 async def test_list_deliveries_includes_payload_field(
1008 client: AsyncClient,
1009 auth_headers: StrDict,
1010 db_session: AsyncSession,
1011 ) -> None:
1012 """GET /deliveries returns a ``payload`` field on each delivery."""
1013 from musehub.services import musehub_webhook_dispatcher as disp
1014
1015 repo_id = await _create_repo(client, auth_headers, "delivery-payload-repo")
1016 wh_data = await _create_webhook(client, auth_headers, repo_id, events=["push"])
1017 webhook_id = wh_data["webhookId"]
1018
1019 push_payload: PushEventPayload = {
1020 "repoId": repo_id,
1021 "branch": "main",
1022 "headCommitId": "pay123",
1023 "pushedBy": "test-user",
1024 "commitCount": 1,
1025 }
1026
1027 async def _ok(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
1028 mock_resp = MagicMock()
1029 mock_resp.is_success = True
1030 mock_resp.status_code = 200
1031 mock_resp.text = "ok"
1032 return mock_resp
1033
1034 with patch("httpx.AsyncClient") as mock_cls:
1035 mock_client = AsyncMock()
1036 mock_client.post = _ok
1037 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
1038 mock_client.__aexit__ = AsyncMock(return_value=False)
1039 mock_cls.return_value = mock_client
1040 await disp.dispatch_event(db_session, repo_id=repo_id, event_type="push", payload=push_payload)
1041 await db_session.commit()
1042
1043 resp = await client.get(
1044 f"/api/repos/{repo_id}/webhooks/{webhook_id}/deliveries",
1045 headers=auth_headers,
1046 )
1047 assert resp.status_code == 200
1048 delivery = resp.json()["deliveries"][0]
1049 assert "payload" in delivery
1050 assert delivery["payload"] != ""
1051
1052
1053 def test_is_fernet_token_detects_prefix() -> None:
1054 """is_fernet_token correctly distinguishes Fernet tokens from plaintext."""
1055 from cryptography.fernet import Fernet
1056 from musehub.services.musehub_webhook_crypto import encrypt_secret, is_fernet_token
1057
1058 from unittest.mock import patch
1059 from musehub.services import musehub_webhook_crypto as crypto
1060
1061 test_key = Fernet.generate_key().decode()
1062 with patch.object(crypto, "_fernet", None), patch.object(crypto, "_fernet_initialised", False):
1063 with patch("musehub.services.musehub_webhook_crypto.settings") as mock_settings:
1064 mock_settings.webhook_secret_key = test_key
1065 token = encrypt_secret("some-secret")
1066
1067 assert is_fernet_token(token)
1068 assert not is_fernet_token("plaintext-secret")
1069 assert not is_fernet_token("")
1070 assert not is_fernet_token("not-starting-with-gAAAAAB")
1071
1072
1073 def test_migrate_webhook_secrets_logic() -> None:
1074 """Core migration logic: plaintext rows are re-encrypted; already-encrypted rows skipped.
1075
1076 This test exercises the detection + re-encryption logic in isolation,
1077 mirroring what scripts/migrate_webhook_secrets.py does in production.
1078 """
1079 from cryptography.fernet import Fernet
1080 from musehub.services.musehub_webhook_crypto import encrypt_secret, is_fernet_token
1081
1082 from unittest.mock import patch
1083 from musehub.services import musehub_webhook_crypto as crypto
1084
1085 test_key = Fernet.generate_key().decode()
1086 plaintext = "legacy-plaintext-hmac-key"
1087 already_fernet: str
1088
1089 with patch.object(crypto, "_fernet", None), patch.object(crypto, "_fernet_initialised", False):
1090 with patch("musehub.services.musehub_webhook_crypto.settings") as mock_settings:
1091 mock_settings.webhook_secret_key = test_key
1092 already_fernet = encrypt_secret("already-encrypted")
1093
1094 # Simulate the per-row migration decision.
1095 secrets = [plaintext, already_fernet, ""]
1096
1097 migrated = []
1098 skipped = []
1099 for secret in secrets:
1100 if not secret or is_fernet_token(secret):
1101 skipped.append(secret)
1102 else:
1103 with patch.object(crypto, "_fernet", None), patch.object(crypto, "_fernet_initialised", False):
1104 with patch("musehub.services.musehub_webhook_crypto.settings") as mock_settings:
1105 mock_settings.webhook_secret_key = test_key
1106 migrated.append(encrypt_secret(secret))
1107
1108 # Plaintext row was migrated; already-encrypted and empty rows were skipped.
1109 assert len(migrated) == 1
1110 assert is_fernet_token(migrated[0])
1111 assert len(skipped) == 2 # already_fernet + empty
1112
1113
1114 def test_encrypt_decrypt_no_key_passthrough() -> None:
1115 """When MUSE_WEBHOOK_SECRET_KEY is absent, encrypt/decrypt are transparent."""
1116 from unittest.mock import patch
1117 from musehub.services import musehub_webhook_crypto as crypto
1118
1119 with patch.object(crypto, "_fernet", None), patch.object(crypto, "_fernet_initialised", False):
1120 with patch("musehub.services.musehub_webhook_crypto.settings") as mock_settings:
1121 mock_settings.webhook_secret_key = None
1122 plaintext = "my-secret"
1123 assert crypto.encrypt_secret(plaintext) == plaintext
1124 assert crypto.decrypt_secret(plaintext) == plaintext
1125
1126
1127 @pytest.mark.anyio
1128 async def test_webhook_delivery_with_encrypted_secret_produces_correct_hmac(
1129 db_session: AsyncSession,
1130 ) -> None:
1131 """dispatch_event decrypts the stored secret before computing the HMAC signature."""
1132 from unittest.mock import patch
1133 from cryptography.fernet import Fernet
1134 from musehub.services import musehub_webhook_dispatcher as disp
1135 from musehub.services import musehub_webhook_crypto as crypto
1136
1137 test_key = Fernet.generate_key().decode()
1138 await _ensure_repo(db_session, "repo-encrypted")
1139
1140 # Reset the module-level singleton so our test key is used.
1141 with patch.object(crypto, "_fernet", None), patch.object(crypto, "_fernet_initialised", False):
1142 with patch("musehub.services.musehub_webhook_crypto.settings") as mock_settings:
1143 mock_settings.webhook_secret_key = test_key
1144
1145 plaintext_secret = "delivery-hmac-secret"
1146 await disp.create_webhook(
1147 db_session,
1148 repo_id="repo-encrypted",
1149 url="https://encrypted.example.com/hook",
1150 events=["push"],
1151 secret=plaintext_secret,
1152 )
1153 await db_session.flush()
1154
1155 received_headers: StrDict = {}
1156
1157 async def _capture_headers(url: str, **kwargs: str | bytes | int | None) -> MagicMock:
1158 received_headers.update(kwargs.get("headers", {}))
1159 mock_resp = MagicMock()
1160 mock_resp.is_success = True
1161 mock_resp.status_code = 200
1162 mock_resp.text = "ok"
1163 return mock_resp
1164
1165 push_payload: PushEventPayload = {
1166 "repoId": "repo-encrypted",
1167 "branch": "main",
1168 "headCommitId": "enc123",
1169 "pushedBy": "test-user",
1170 "commitCount": 1,
1171 }
1172
1173 with (
1174 patch("httpx.AsyncClient") as mock_client_cls,
1175 patch("musehub.security.ssrf.validate_outbound_url", new_callable=AsyncMock) as mock_ssrf,
1176 ):
1177 mock_ssrf.return_value = (True, 0, "")
1178 mock_client = AsyncMock()
1179 mock_client.post = _capture_headers
1180 mock_client.__aenter__ = AsyncMock(return_value=mock_client)
1181 mock_client.__aexit__ = AsyncMock(return_value=False)
1182 mock_client_cls.return_value = mock_client
1183
1184 payload_bytes = json.dumps(push_payload, default=str).encode()
1185 await disp.dispatch_event(
1186 db_session,
1187 repo_id="repo-encrypted",
1188 event_type="push",
1189 payload=push_payload,
1190 )
1191
1192 # The signature header must match what the subscriber computes from the plaintext secret.
1193 expected_mac = hmac.new(plaintext_secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
1194 expected_sig = f"sha256={expected_mac}"
1195 assert received_headers.get("X-MuseHub-Signature") == expected_sig
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago