e2fa96318f
* fix: validate webhook targets before delivery * test: cover webhook SSRF validation
505 lines
19 KiB
Python
505 lines
19 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."""
|
||
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
|
||
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."""
|
||
mocker.patch("app.utils.webhook.is_private_ip", return_value=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.is_private_ip", return_value=False)
|
||
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."""
|
||
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
|
||
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."""
|
||
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
|
||
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
|
||
|
||
def test_private_target_is_blocked(self, mocker):
|
||
"""Private network webhook targets are not called."""
|
||
mocker.patch("app.utils.webhook.is_private_ip", return_value=True)
|
||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||
|
||
result = deliver_webhook("https://10.0.0.5/hook", {"event": "test"})
|
||
|
||
assert result is False
|
||
mock_post.assert_not_called()
|
||
|
||
def test_metadata_target_is_blocked(self, mocker):
|
||
"""Cloud metadata webhook targets are not called."""
|
||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||
|
||
result = deliver_webhook("http://169.254.169.254/latest/meta-data", {"event": "test"})
|
||
|
||
assert result is False
|
||
mock_post.assert_not_called()
|
||
|
||
def test_invalid_scheme_is_blocked(self, mocker):
|
||
"""Unsupported webhook URL schemes are not called."""
|
||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||
|
||
result = deliver_webhook("file:///etc/passwd", {"event": "test"})
|
||
|
||
assert result is False
|
||
mock_post.assert_not_called()
|
||
|
||
def test_missing_hostname_is_blocked(self, mocker):
|
||
"""Webhook URLs without a hostname are not called."""
|
||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||
|
||
result = deliver_webhook("https:///missing-host", {"event": "test"})
|
||
|
||
assert result is False
|
||
mock_post.assert_not_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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)
|