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