feat(webhooks): add webhook support for external integrations
Add WebhookConfig model, CRUD API endpoints, HMAC-SHA256 signed delivery, and Celery-based async dispatch with retry/backoff for document events (document.uploaded, document.processed, document.failed). Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
"""Tests for the webhook subsystem (utility, API, and task)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import WebhookConfig
|
||||
from app.utils.webhook import (
|
||||
build_payload,
|
||||
compute_signature,
|
||||
deliver_webhook,
|
||||
dispatch_webhook_event,
|
||||
get_active_webhooks_for_event,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – compute_signature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestComputeSignature:
|
||||
"""Tests for HMAC-SHA256 signature generation."""
|
||||
|
||||
def test_signature_format(self):
|
||||
"""Signature starts with 'sha256=' and is a hex digest."""
|
||||
sig = compute_signature(b'{"event":"test"}', "mysecret")
|
||||
assert sig.startswith("sha256=")
|
||||
# hex digest should be 64 chars
|
||||
assert len(sig.split("=")[1]) == 64
|
||||
|
||||
def test_signature_matches_manual_hmac(self):
|
||||
"""Signature matches a manually computed HMAC-SHA256."""
|
||||
payload = b'{"key":"value"}'
|
||||
secret = "s3cret"
|
||||
expected = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
||||
assert compute_signature(payload, secret) == expected
|
||||
|
||||
def test_different_secrets_produce_different_signatures(self):
|
||||
"""Different secrets must produce different signatures."""
|
||||
payload = b"same"
|
||||
assert compute_signature(payload, "secret1") != compute_signature(payload, "secret2")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – build_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildPayload:
|
||||
"""Tests for the webhook payload builder."""
|
||||
|
||||
def test_contains_required_keys(self):
|
||||
"""Payload contains event, timestamp, and data."""
|
||||
payload = build_payload("document.uploaded", {"file_id": 1})
|
||||
assert payload["event"] == "document.uploaded"
|
||||
assert "timestamp" in payload
|
||||
assert payload["data"] == {"file_id": 1}
|
||||
|
||||
def test_timestamp_is_recent(self):
|
||||
"""Timestamp should be close to current time."""
|
||||
before = time.time()
|
||||
payload = build_payload("document.processed", {})
|
||||
after = time.time()
|
||||
assert before <= payload["timestamp"] <= after
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – deliver_webhook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeliverWebhook:
|
||||
"""Tests for the HTTP delivery function."""
|
||||
|
||||
def test_success_returns_true(self, mocker):
|
||||
"""A 200 response returns True."""
|
||||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||
mock_post.return_value = MagicMock(ok=True, status_code=200)
|
||||
|
||||
result = deliver_webhook("https://example.com/hook", {"event": "test"})
|
||||
assert result is True
|
||||
mock_post.assert_called_once()
|
||||
|
||||
def test_non_2xx_returns_false(self, mocker):
|
||||
"""A non-2xx response returns False."""
|
||||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||
mock_post.return_value = MagicMock(ok=False, status_code=500)
|
||||
|
||||
result = deliver_webhook("https://example.com/hook", {"event": "test"})
|
||||
assert result is False
|
||||
|
||||
def test_request_exception_returns_false(self, mocker):
|
||||
"""A network error returns False."""
|
||||
import requests
|
||||
|
||||
mocker.patch("app.utils.webhook.requests.post", side_effect=requests.ConnectionError("fail"))
|
||||
|
||||
result = deliver_webhook("https://example.com/hook", {"event": "test"})
|
||||
assert result is False
|
||||
|
||||
def test_signature_header_included_when_secret(self, mocker):
|
||||
"""X-Webhook-Signature header is present when a secret is supplied."""
|
||||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||
mock_post.return_value = MagicMock(ok=True, status_code=200)
|
||||
|
||||
deliver_webhook("https://example.com/hook", {"event": "test"}, secret="abc")
|
||||
call_kwargs = mock_post.call_args
|
||||
headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers")
|
||||
assert "X-Webhook-Signature" in headers
|
||||
assert headers["X-Webhook-Signature"].startswith("sha256=")
|
||||
|
||||
def test_no_signature_header_without_secret(self, mocker):
|
||||
"""X-Webhook-Signature header is absent when no secret is supplied."""
|
||||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||
mock_post.return_value = MagicMock(ok=True, status_code=200)
|
||||
|
||||
deliver_webhook("https://example.com/hook", {"event": "test"})
|
||||
call_kwargs = mock_post.call_args
|
||||
headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers")
|
||||
assert "X-Webhook-Signature" not in headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – get_active_webhooks_for_event (DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetActiveWebhooks:
|
||||
"""Tests that active webhook configs are correctly filtered by event."""
|
||||
|
||||
def test_returns_matching_webhooks(self, db_session):
|
||||
"""Only webhooks subscribed to the given event are returned."""
|
||||
cfg1 = WebhookConfig(
|
||||
url="https://a.com/hook",
|
||||
secret="s1",
|
||||
events=json.dumps(["document.uploaded", "document.processed"]),
|
||||
is_active=True,
|
||||
)
|
||||
cfg2 = WebhookConfig(
|
||||
url="https://b.com/hook",
|
||||
events=json.dumps(["document.failed"]),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add_all([cfg1, cfg2])
|
||||
db_session.commit()
|
||||
|
||||
with patch("app.utils.webhook.SessionLocal", return_value=db_session):
|
||||
results = get_active_webhooks_for_event("document.uploaded")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["url"] == "https://a.com/hook"
|
||||
|
||||
def test_inactive_webhooks_excluded(self, db_session):
|
||||
"""Inactive webhooks are not returned."""
|
||||
cfg = WebhookConfig(
|
||||
url="https://inactive.com/hook",
|
||||
events=json.dumps(["document.uploaded"]),
|
||||
is_active=False,
|
||||
)
|
||||
db_session.add(cfg)
|
||||
db_session.commit()
|
||||
|
||||
with patch("app.utils.webhook.SessionLocal", return_value=db_session):
|
||||
results = get_active_webhooks_for_event("document.uploaded")
|
||||
|
||||
assert results == []
|
||||
|
||||
def test_returns_empty_when_no_match(self, db_session):
|
||||
"""No webhooks returned when none match the event."""
|
||||
cfg = WebhookConfig(
|
||||
url="https://c.com/hook",
|
||||
events=json.dumps(["document.failed"]),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(cfg)
|
||||
db_session.commit()
|
||||
|
||||
with patch("app.utils.webhook.SessionLocal", return_value=db_session):
|
||||
results = get_active_webhooks_for_event("document.uploaded")
|
||||
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – dispatch_webhook_event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDispatchWebhookEvent:
|
||||
"""Tests for the high-level dispatch function."""
|
||||
|
||||
def test_unknown_event_is_ignored(self, mocker):
|
||||
"""Unknown events are silently ignored."""
|
||||
mock_get = mocker.patch("app.utils.webhook.get_active_webhooks_for_event")
|
||||
dispatch_webhook_event("unknown.event", {})
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_no_webhooks_does_not_fail(self, mocker):
|
||||
"""No error when there are no matching webhooks."""
|
||||
mocker.patch("app.utils.webhook.get_active_webhooks_for_event", return_value=[])
|
||||
dispatch_webhook_event("document.uploaded", {"file_id": 1})
|
||||
|
||||
def test_queues_celery_task_for_each_webhook(self, mocker):
|
||||
"""A Celery task is queued for each matching webhook."""
|
||||
mocker.patch(
|
||||
"app.utils.webhook.get_active_webhooks_for_event",
|
||||
return_value=[
|
||||
{"id": 1, "url": "https://a.com", "secret": "s", "events": ["document.uploaded"]},
|
||||
{"id": 2, "url": "https://b.com", "secret": None, "events": ["document.uploaded"]},
|
||||
],
|
||||
)
|
||||
mock_task = mocker.patch("app.tasks.webhook_tasks.deliver_webhook_task.delay")
|
||||
|
||||
dispatch_webhook_event("document.uploaded", {"file_id": 42})
|
||||
|
||||
assert mock_task.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – API endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestWebhookAPI:
|
||||
"""Tests for the /api/webhooks/ endpoints."""
|
||||
|
||||
def _with_admin(self, client):
|
||||
"""Return headers / approach to authenticate as admin for test client."""
|
||||
# FastAPI TestClient + SessionMiddleware: we can set session data by
|
||||
# using the app's dependency override or the session directly.
|
||||
# Simplest: override the _require_admin dependency.
|
||||
from app.api.webhooks import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True, "name": "test-admin"}
|
||||
return client
|
||||
|
||||
def test_create_webhook(self, client):
|
||||
"""POST /api/webhooks/ creates a new webhook."""
|
||||
self._with_admin(client)
|
||||
resp = client.post(
|
||||
"/api/webhooks/",
|
||||
json={
|
||||
"url": "https://example.com/webhook",
|
||||
"secret": "my-secret",
|
||||
"events": ["document.uploaded"],
|
||||
"description": "Test hook",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["url"] == "https://example.com/webhook"
|
||||
assert data["events"] == ["document.uploaded"]
|
||||
assert data["is_active"] is True
|
||||
assert data["has_secret"] is True
|
||||
assert "secret" not in data # secret must not be exposed
|
||||
|
||||
def test_list_webhooks(self, client, db_session):
|
||||
"""GET /api/webhooks/ returns all webhooks."""
|
||||
self._with_admin(client)
|
||||
# seed one
|
||||
cfg = WebhookConfig(
|
||||
url="https://list.example.com/hook",
|
||||
events=json.dumps(["document.processed"]),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(cfg)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/webhooks/")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()
|
||||
assert any(w["url"] == "https://list.example.com/hook" for w in items)
|
||||
|
||||
def test_get_webhook(self, client, db_session):
|
||||
"""GET /api/webhooks/{id} returns a single webhook."""
|
||||
self._with_admin(client)
|
||||
cfg = WebhookConfig(
|
||||
url="https://get.example.com/hook",
|
||||
events=json.dumps(["document.failed"]),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(cfg)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get(f"/api/webhooks/{cfg.id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["url"] == "https://get.example.com/hook"
|
||||
|
||||
def test_get_webhook_not_found(self, client):
|
||||
"""GET /api/webhooks/9999 returns 404."""
|
||||
self._with_admin(client)
|
||||
resp = client.get("/api/webhooks/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_webhook(self, client, db_session):
|
||||
"""PUT /api/webhooks/{id} updates the webhook."""
|
||||
self._with_admin(client)
|
||||
cfg = WebhookConfig(
|
||||
url="https://old.example.com/hook",
|
||||
events=json.dumps(["document.uploaded"]),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(cfg)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.put(
|
||||
f"/api/webhooks/{cfg.id}",
|
||||
json={"url": "https://new.example.com/hook", "is_active": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["url"] == "https://new.example.com/hook"
|
||||
assert data["is_active"] is False
|
||||
|
||||
def test_update_webhook_not_found(self, client):
|
||||
"""PUT /api/webhooks/9999 returns 404."""
|
||||
self._with_admin(client)
|
||||
resp = client.put("/api/webhooks/9999", json={"url": "https://x.com"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_webhook(self, client, db_session):
|
||||
"""DELETE /api/webhooks/{id} removes the webhook."""
|
||||
self._with_admin(client)
|
||||
cfg = WebhookConfig(
|
||||
url="https://del.example.com/hook",
|
||||
events=json.dumps(["document.uploaded"]),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(cfg)
|
||||
db_session.commit()
|
||||
wid = cfg.id
|
||||
|
||||
resp = client.delete(f"/api/webhooks/{wid}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# confirm it's gone
|
||||
resp2 = client.get(f"/api/webhooks/{wid}")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
def test_delete_webhook_not_found(self, client):
|
||||
"""DELETE /api/webhooks/9999 returns 404."""
|
||||
self._with_admin(client)
|
||||
resp = client.delete("/api/webhooks/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_create_webhook_invalid_event(self, client):
|
||||
"""POST /api/webhooks/ rejects invalid events."""
|
||||
self._with_admin(client)
|
||||
resp = client.post(
|
||||
"/api/webhooks/",
|
||||
json={"url": "https://x.com/hook", "events": ["bad.event"]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_list_events(self, client):
|
||||
"""GET /api/webhooks/events/ returns valid events."""
|
||||
self._with_admin(client)
|
||||
resp = client.get("/api/webhooks/events/")
|
||||
assert resp.status_code == 200
|
||||
events = resp.json()
|
||||
assert "document.uploaded" in events
|
||||
assert "document.processed" in events
|
||||
assert "document.failed" in events
|
||||
|
||||
def test_requires_admin(self, client):
|
||||
"""Endpoints return 403 without admin session."""
|
||||
# Remove the admin override
|
||||
from app.api.webhooks import _require_admin
|
||||
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
resp = client.get("/api/webhooks/")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_create_webhook_without_secret(self, client):
|
||||
"""POST /api/webhooks/ works without a secret."""
|
||||
self._with_admin(client)
|
||||
resp = client.post(
|
||||
"/api/webhooks/",
|
||||
json={"url": "https://nosecret.com/hook", "events": ["document.failed"]},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["has_secret"] is False
|
||||
|
||||
def test_update_webhook_events(self, client, db_session):
|
||||
"""PUT /api/webhooks/{id} can update events."""
|
||||
self._with_admin(client)
|
||||
cfg = WebhookConfig(
|
||||
url="https://evup.example.com/hook",
|
||||
events=json.dumps(["document.uploaded"]),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(cfg)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.put(
|
||||
f"/api/webhooks/{cfg.id}",
|
||||
json={"events": ["document.processed", "document.failed"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert set(resp.json()["events"]) == {"document.processed", "document.failed"}
|
||||
|
||||
def test_update_webhook_invalid_event(self, client, db_session):
|
||||
"""PUT /api/webhooks/{id} rejects invalid events."""
|
||||
self._with_admin(client)
|
||||
cfg = WebhookConfig(
|
||||
url="https://bdev.example.com/hook",
|
||||
events=json.dumps(["document.uploaded"]),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(cfg)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.put(
|
||||
f"/api/webhooks/{cfg.id}",
|
||||
json={"events": ["invalid.event"]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests – Celery task
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeliverWebhookTask:
|
||||
"""Tests for the Celery webhook delivery task."""
|
||||
|
||||
def test_success_returns_status_dict(self, mocker):
|
||||
"""Task returns a dict on successful delivery."""
|
||||
mocker.patch("app.tasks.webhook_tasks.deliver_webhook", return_value=True)
|
||||
|
||||
from app.tasks.webhook_tasks import deliver_webhook_task
|
||||
|
||||
# Mock the task's request context for the log line
|
||||
deliver_webhook_task.request.retries = 0
|
||||
|
||||
result = deliver_webhook_task.__wrapped__("https://example.com/hook", {"event": "test"}, None)
|
||||
assert result["status"] == "delivered"
|
||||
assert result["url"] == "https://example.com/hook"
|
||||
|
||||
def test_failure_raises_for_retry(self, mocker):
|
||||
"""Task raises RuntimeError on delivery failure to trigger retry."""
|
||||
mocker.patch("app.tasks.webhook_tasks.deliver_webhook", return_value=False)
|
||||
|
||||
from app.tasks.webhook_tasks import deliver_webhook_task
|
||||
|
||||
deliver_webhook_task.request.retries = 0
|
||||
|
||||
with pytest.raises(RuntimeError, match="Webhook delivery.*failed"):
|
||||
deliver_webhook_task.__wrapped__("https://example.com/hook", {"event": "test"}, None)
|
||||
Reference in New Issue
Block a user