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