5b41d32f90
Adds validation to webhook URLs before attempting to deliver them to prevent SSRF attacks targeting private IP ranges, local host, and cloud metadata endpoints. Validates URL schema, hostname, and applies `is_private_ip()`. Also resolved ruff linting errors. Tests have been expanded to ensure validation covers all cases. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
493 lines
18 KiB
Python
493 lines
18 KiB
Python
"""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):
|
||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||
"""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)
|
||
|
||
def test_webhook_ssrf_coverage():
|
||
from app.utils.webhook import deliver_webhook
|
||
assert deliver_webhook("ftp://example.com", {"data": 1}) == False
|
||
assert deliver_webhook("http://", {"data": 1}) == False
|
||
assert deliver_webhook("http://localhost", {"data": 1}) == False
|
||
assert deliver_webhook("http://169.254.169.254", {"data": 1}) == False
|
||
assert deliver_webhook("", {"data": 1}) == False
|
||
|
||
def test_webhook_ssrf_coverage2(mocker):
|
||
from app.utils.webhook import deliver_webhook
|
||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||
assert deliver_webhook("http://127.0.0.1", {"data": 1}) == False
|
||
|
||
def test_webhook_ssrf_coverage3(mocker):
|
||
mocker.patch("app.utils.network.is_private_ip", return_value=False)
|
||
from app.utils.webhook import deliver_webhook
|
||
assert deliver_webhook("http://127.0.0.1", {"data": 1}) == False
|
||
assert deliver_webhook("http://169.254.169.253", {"data": 1}) == False
|
||
assert deliver_webhook("http://metadata.google.internal", {"data": 1}) == False
|
||
|
||
def test_webhook_ssrf_coverage5():
|
||
from app.utils.webhook import deliver_webhook
|
||
assert deliver_webhook("http://example.com/test", {"data": 1}) == False
|
||
|
||
def test_webhook_ssrf_coverage4(mocker):
|
||
from app.utils.webhook import deliver_webhook
|
||
mocker.patch("app.utils.network.is_private_ip", return_value=True)
|
||
assert deliver_webhook("http://127.0.0.1", {"data": 1}) == False
|