gabriel / musehub public

test_musehub_webhooks.py file-level

at sha256:3 · View file ↗ · Intel ↗

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