import hashlib import hmac import json from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock import httpx import pytest from src.config import settings from src.utils.queue_payload import WebhookPayload from src.webhooks import webhook_delivery class FakeAsyncClient: def __init__(self, responses: dict[str, httpx.Response | Exception]): self._responses: dict[str, httpx.Response | Exception] = responses self.calls: list[dict[str, Any]] = [] async def __aenter__(self) -> "FakeAsyncClient": return self async def __aexit__( self, exc_type: object, exc: object, tb: object ) -> None: # pragma: no cover - required async CM signature _ = (exc_type, exc, tb) async def post( self, *, url: str, content: str, headers: dict[str, str] ) -> httpx.Response: self.calls.append({"url": url, "content": content, "headers": headers}) result = self._responses[url] if isinstance(result, Exception): raise result return result def test_generate_webhook_signature_uses_secret( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(settings.WEBHOOK, "SECRET", "unit-test-secret") payload = '{"key":"value"}' expected = hmac.new( b"unit-test-secret", payload.encode("utf-8"), hashlib.sha256 ).hexdigest() actual = webhook_delivery._generate_webhook_signature(payload) # pyright: ignore[reportPrivateUsage] assert actual == expected def test_generate_webhook_signature_raises_without_secret( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(settings.WEBHOOK, "SECRET", "") with pytest.raises(ValueError, match="WEBHOOK_SECRET not found"): webhook_delivery._generate_webhook_signature("{}") # pyright: ignore[reportPrivateUsage] @pytest.mark.asyncio async def test_get_webhook_urls_returns_all_endpoint_urls( monkeypatch: pytest.MonkeyPatch, ) -> None: query = object() list_endpoints = AsyncMock(return_value=query) monkeypatch.setattr(webhook_delivery, "list_webhook_endpoints", list_endpoints) endpoint_a = SimpleNamespace(url="https://a.example.com/hook") endpoint_b = SimpleNamespace(url="https://b.example.com/hook") execute_result = MagicMock() execute_result.scalars.return_value.all.return_value = [endpoint_a, endpoint_b] db = AsyncMock() db.execute.return_value = execute_result urls = await webhook_delivery._get_webhook_urls( # pyright: ignore[reportPrivateUsage] db, "workspace-a" ) assert urls == [endpoint_a.url, endpoint_b.url] list_endpoints.assert_awaited_once_with("workspace-a") db.execute.assert_awaited_once_with(query) @pytest.mark.asyncio async def test_get_webhook_urls_returns_empty_list_on_error( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( webhook_delivery, "list_webhook_endpoints", AsyncMock(side_effect=RuntimeError("boom")), ) db = AsyncMock() urls = await webhook_delivery._get_webhook_urls( # pyright: ignore[reportPrivateUsage] db, "workspace-a" ) assert urls == [] db.execute.assert_not_called() @pytest.mark.asyncio async def test_deliver_webhook_skips_when_no_urls( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_client = FakeAsyncClient({}) def async_client_factory(*args: Any, **kwargs: Any) -> FakeAsyncClient: _ = (args, kwargs) return fake_client monkeypatch.setattr(httpx, "AsyncClient", async_client_factory) monkeypatch.setattr( webhook_delivery, "_get_webhook_urls", AsyncMock(return_value=[]), ) payload = WebhookPayload(event_type="peer.created", data={"id": "p_123"}) await webhook_delivery.deliver_webhook(payload, "workspace-a") assert fake_client.calls == [] @pytest.mark.asyncio async def test_deliver_webhook_posts_signed_payload_to_each_endpoint( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(settings.WEBHOOK, "SECRET", "delivery-secret") monkeypatch.setattr(webhook_delivery, "utc_now_iso", lambda: "2026-02-13T00:00:00Z") urls = [ "https://a.example.com/hook", "https://b.example.com/hook", ] monkeypatch.setattr( webhook_delivery, "_get_webhook_urls", AsyncMock(return_value=urls), ) fake_client = FakeAsyncClient( { urls[0]: httpx.Response( status_code=202, request=httpx.Request("POST", urls[0]) ), urls[1]: httpx.ConnectError("connection failed"), } ) def async_client_factory(*args: Any, **kwargs: Any) -> FakeAsyncClient: _ = (args, kwargs) return fake_client monkeypatch.setattr(httpx, "AsyncClient", async_client_factory) payload = WebhookPayload( event_type="message.created", data={"id": "m_1", "workspace": "workspace-a"}, ) await webhook_delivery.deliver_webhook(payload, "workspace-a") expected_event_json = json.dumps( { "type": payload.event_type, "data": payload.data, "timestamp": "2026-02-13T00:00:00Z", }, separators=(",", ":"), sort_keys=True, ) expected_signature = webhook_delivery._generate_webhook_signature( # pyright: ignore[reportPrivateUsage] expected_event_json ) assert len(fake_client.calls) == 2 for call in fake_client.calls: assert call["content"] == expected_event_json assert call["headers"]["Content-Type"] == "application/json" assert call["headers"]["X-Honcho-Signature"] == expected_signature @pytest.mark.asyncio async def test_deliver_webhook_handles_signature_generation_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(settings.WEBHOOK, "SECRET", "") monkeypatch.setattr( webhook_delivery, "_get_webhook_urls", AsyncMock(return_value=["https://a.example.com/hook"]), ) fake_client = FakeAsyncClient( { "https://a.example.com/hook": httpx.Response( status_code=200, request=httpx.Request("POST", "https://a.example.com/hook"), ) } ) def async_client_factory(*args: Any, **kwargs: Any) -> FakeAsyncClient: _ = (args, kwargs) return fake_client monkeypatch.setattr(httpx, "AsyncClient", async_client_factory) payload = WebhookPayload(event_type="workspace.updated", data={"id": "ws_1"}) await webhook_delivery.deliver_webhook(payload, "workspace-a") assert fake_client.calls == [] @pytest.mark.asyncio async def test_deliver_webhook_catches_request_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(settings.WEBHOOK, "SECRET", "delivery-secret") monkeypatch.setattr( webhook_delivery, "_get_webhook_urls", AsyncMock(side_effect=httpx.RequestError("network issue")), ) def async_client_factory(*args: Any, **kwargs: Any) -> FakeAsyncClient: _ = (args, kwargs) return FakeAsyncClient({}) monkeypatch.setattr(httpx, "AsyncClient", async_client_factory) payload = WebhookPayload(event_type="workspace.updated", data={"id": "ws_1"}) await webhook_delivery.deliver_webhook(payload, "workspace-a")