309 lines
10 KiB
Python
309 lines
10 KiB
Python
import hmac
|
|
import json
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.models.webhook import WebhookDelivery
|
|
from app.services.webhook_events import (
|
|
DELIVERY_ABANDONED,
|
|
DELIVERY_DELIVERED,
|
|
DELIVERY_FAILED,
|
|
DELIVERY_PENDING,
|
|
EVENT_ALERT_CREATED,
|
|
EVENT_REPORTS_MISSING,
|
|
EVENT_WEBHOOK_TEST,
|
|
DeliveryAttemptResult,
|
|
create_webhook_endpoint,
|
|
deliver_due_webhooks,
|
|
deliver_webhook_delivery,
|
|
endpoint_to_dict,
|
|
enqueue_webhook_event,
|
|
normalize_event_types,
|
|
queue_test_webhook,
|
|
sign_delivery,
|
|
update_webhook_endpoint,
|
|
validate_webhook_url,
|
|
)
|
|
|
|
|
|
def test_webhook_event_delivery_signs_and_records_success(db_session):
|
|
"""Webhook deliveries include replay-resistant metadata and mark success."""
|
|
endpoint, secret = create_webhook_endpoint(
|
|
db_session,
|
|
name="receiver",
|
|
url="https://receiver.example/webhook?token=hidden",
|
|
event_types=[EVENT_ALERT_CREATED],
|
|
)
|
|
deliveries = enqueue_webhook_event(
|
|
db_session,
|
|
event_type=EVENT_ALERT_CREATED,
|
|
payload={"domain": "example.com", "detail": "alert"},
|
|
idempotency_key="alert-example",
|
|
)
|
|
assert len(deliveries) == 1
|
|
|
|
seen = {}
|
|
|
|
def sender(url, body, headers, timeout_seconds):
|
|
seen["url"] = url
|
|
seen["body"] = body
|
|
seen["headers"] = headers
|
|
seen["timeout_seconds"] = timeout_seconds
|
|
expected = sign_delivery(
|
|
secret,
|
|
deliveries[0],
|
|
int(headers["X-DMARQ-Timestamp"]),
|
|
body,
|
|
)
|
|
assert hmac.compare_digest(headers["X-DMARQ-Signature"], expected)
|
|
assert headers["X-DMARQ-Event"] == EVENT_ALERT_CREATED
|
|
assert headers["X-DMARQ-Idempotency-Key"] == "alert-example"
|
|
return DeliveryAttemptResult(status_code=204, body="")
|
|
|
|
delivered = deliver_due_webhooks(db_session, sender=sender)
|
|
|
|
assert delivered[0].status == DELIVERY_DELIVERED
|
|
assert json.loads(seen["body"])["data"]["domain"] == "example.com"
|
|
assert seen["url"] == "https://receiver.example/webhook?token=hidden"
|
|
db_session.refresh(endpoint)
|
|
assert endpoint.last_success_at is not None
|
|
assert endpoint.failure_count == 0
|
|
|
|
|
|
def test_webhook_delivery_retries_then_fails(db_session):
|
|
"""Transient failures remain pending with backoff, then fail at max attempts."""
|
|
create_webhook_endpoint(
|
|
db_session,
|
|
name="receiver",
|
|
url="https://receiver.example/webhook",
|
|
event_types=[EVENT_ALERT_CREATED],
|
|
max_attempts=2,
|
|
)
|
|
enqueue_webhook_event(
|
|
db_session,
|
|
event_type=EVENT_ALERT_CREATED,
|
|
payload={"domain": "example.com"},
|
|
idempotency_key="retry-example",
|
|
)[0]
|
|
|
|
def failing_sender(url, body, headers, timeout_seconds):
|
|
return DeliveryAttemptResult(status_code=503, body="try later")
|
|
|
|
first = deliver_due_webhooks(db_session, sender=failing_sender)[0]
|
|
assert first.status == DELIVERY_PENDING
|
|
assert first.attempt_count == 1
|
|
assert first.next_attempt_at > first.last_attempt_at
|
|
|
|
first.next_attempt_at = first.last_attempt_at
|
|
db_session.commit()
|
|
second = deliver_due_webhooks(db_session, sender=failing_sender)[0]
|
|
assert second.status == DELIVERY_FAILED
|
|
assert second.attempt_count == 2
|
|
assert second.last_status_code == 503
|
|
|
|
|
|
def test_webhook_idempotency_skips_duplicate_deliveries(db_session):
|
|
"""Repeated events with the same idempotency key reuse the existing delivery."""
|
|
create_webhook_endpoint(
|
|
db_session,
|
|
name="receiver",
|
|
url="https://receiver.example/webhook",
|
|
event_types=[EVENT_ALERT_CREATED],
|
|
)
|
|
first = enqueue_webhook_event(
|
|
db_session,
|
|
event_type=EVENT_ALERT_CREATED,
|
|
payload={"domain": "example.com"},
|
|
idempotency_key="same-key",
|
|
)
|
|
second = enqueue_webhook_event(
|
|
db_session,
|
|
event_type=EVENT_ALERT_CREATED,
|
|
payload={"domain": "example.com"},
|
|
idempotency_key="same-key",
|
|
)
|
|
|
|
assert first[0].id == second[0].id
|
|
assert db_session.query(WebhookDelivery).count() == 1
|
|
|
|
|
|
def test_webhook_validation_update_and_abandoned_delivery(db_session):
|
|
"""Endpoint helpers validate input, update secrets, and abandon disabled endpoints."""
|
|
assert normalize_event_types([]) == ["*"]
|
|
assert normalize_event_types(["*", EVENT_ALERT_CREATED]) == ["*"]
|
|
assert (
|
|
validate_webhook_url(" https://receiver.example/hook ") == "https://receiver.example/hook"
|
|
)
|
|
|
|
for bad_events in [["bad.event"]]:
|
|
try:
|
|
normalize_event_types(bad_events)
|
|
except ValueError as exc:
|
|
assert "Unsupported webhook event type" in str(exc)
|
|
else: # pragma: no cover - defensive assertion shape
|
|
raise AssertionError("invalid event type was accepted")
|
|
|
|
for bad_url in ["ftp://receiver.example/hook", "not-a-url"]:
|
|
try:
|
|
validate_webhook_url(bad_url)
|
|
except ValueError as exc:
|
|
assert "absolute http or https URL" in str(exc)
|
|
else: # pragma: no cover - defensive assertion shape
|
|
raise AssertionError("invalid webhook URL was accepted")
|
|
|
|
endpoint, _secret = create_webhook_endpoint(
|
|
db_session,
|
|
name="receiver",
|
|
url="https://receiver.example/webhook",
|
|
event_types=[EVENT_ALERT_CREATED],
|
|
max_attempts=50,
|
|
timeout_seconds=50,
|
|
)
|
|
updated, returned_secret = update_webhook_endpoint(
|
|
db_session,
|
|
endpoint,
|
|
name="receiver two",
|
|
url="https://receiver.example/updated?secret=hidden",
|
|
secret="a-new-secret-value",
|
|
event_types=[EVENT_REPORTS_MISSING],
|
|
enabled=False,
|
|
max_attempts=20,
|
|
timeout_seconds=40,
|
|
)
|
|
|
|
assert updated.name == "receiver two"
|
|
assert returned_secret == "a-new-secret-value"
|
|
assert updated.event_types == EVENT_REPORTS_MISSING
|
|
assert updated.max_attempts == 10
|
|
assert updated.timeout_seconds == 30
|
|
assert endpoint_to_dict(updated)["url"] == "https://receiver.example/updated"
|
|
|
|
delivery = queue_test_webhook(db_session, updated.id)
|
|
result = deliver_webhook_delivery(db_session, delivery)
|
|
assert result.status == DELIVERY_ABANDONED
|
|
assert "disabled or missing" in result.last_error
|
|
|
|
|
|
def test_admin_webhook_endpoints_hide_secrets_and_show_delivery_status(
|
|
authed_client: TestClient,
|
|
monkeypatch,
|
|
):
|
|
"""Operators can create, inspect, and test webhooks without secret leakage."""
|
|
created = authed_client.post(
|
|
"/api/v1/webhooks",
|
|
json={
|
|
"name": "ops",
|
|
"url": "https://ops.example/hooks/dmarq?secret=hidden",
|
|
"event_types": [EVENT_WEBHOOK_TEST],
|
|
},
|
|
)
|
|
assert created.status_code == 200
|
|
body = created.json()
|
|
assert body["secret"]
|
|
assert "hidden" not in body["url"]
|
|
|
|
listed = authed_client.get("/api/v1/webhooks")
|
|
assert listed.status_code == 200
|
|
assert listed.json()["endpoints"][0]["url"] == "https://ops.example/hooks/dmarq"
|
|
assert body["secret"] not in listed.text
|
|
|
|
def fake_deliver_due_webhooks(db, endpoint_id=None, limit=25):
|
|
delivery = (
|
|
db.query(WebhookDelivery)
|
|
.filter(WebhookDelivery.endpoint_id == endpoint_id)
|
|
.order_by(WebhookDelivery.id.desc())
|
|
.first()
|
|
)
|
|
delivery.status = DELIVERY_DELIVERED
|
|
delivery.attempt_count = 1
|
|
db.commit()
|
|
db.refresh(delivery)
|
|
return [delivery]
|
|
|
|
monkeypatch.setattr(
|
|
"app.api.api_v1.endpoints.webhooks.deliver_due_webhooks",
|
|
fake_deliver_due_webhooks,
|
|
)
|
|
|
|
tested = authed_client.post(f"/api/v1/webhooks/{body['id']}/test")
|
|
assert tested.status_code == 200
|
|
delivery = tested.json()["delivery"]
|
|
assert delivery["event_type"] == EVENT_WEBHOOK_TEST
|
|
assert delivery["status"] == DELIVERY_DELIVERED
|
|
|
|
history = authed_client.get("/api/v1/webhooks/deliveries")
|
|
assert history.status_code == 200
|
|
assert history.json()["deliveries"][0]["event_type"] == EVENT_WEBHOOK_TEST
|
|
|
|
|
|
def test_admin_webhook_update_disable_filter_and_process(
|
|
authed_client: TestClient,
|
|
monkeypatch,
|
|
):
|
|
"""Admin API covers update, filtering, due processing, and not-found paths."""
|
|
created = authed_client.post(
|
|
"/api/v1/webhooks",
|
|
json={"name": "ops", "url": "https://ops.example/hooks/dmarq"},
|
|
).json()
|
|
|
|
bad_create = authed_client.post(
|
|
"/api/v1/webhooks",
|
|
json={"name": "bad", "url": "ftp://ops.example/hooks/dmarq"},
|
|
)
|
|
assert bad_create.status_code == 400
|
|
|
|
missing_update = authed_client.put("/api/v1/webhooks/9999", json={"name": "missing"})
|
|
assert missing_update.status_code == 404
|
|
|
|
bad_update = authed_client.put(
|
|
f"/api/v1/webhooks/{created['id']}",
|
|
json={"event_types": ["not.supported"]},
|
|
)
|
|
assert bad_update.status_code == 400
|
|
|
|
updated = authed_client.put(
|
|
f"/api/v1/webhooks/{created['id']}",
|
|
json={
|
|
"name": "ops updated",
|
|
"event_types": [EVENT_REPORTS_MISSING],
|
|
"max_attempts": 2,
|
|
"timeout_seconds": 3,
|
|
},
|
|
)
|
|
assert updated.status_code == 200
|
|
assert updated.json()["name"] == "ops updated"
|
|
assert updated.json()["event_types"] == [EVENT_REPORTS_MISSING]
|
|
|
|
missing_test = authed_client.post("/api/v1/webhooks/9999/test")
|
|
assert missing_test.status_code == 404
|
|
|
|
def fake_process(db, endpoint_id=None, limit=25):
|
|
delivery = queue_test_webhook(db, created["id"])
|
|
delivery.status = DELIVERY_DELIVERED
|
|
delivery.attempt_count = 1
|
|
db.commit()
|
|
db.refresh(delivery)
|
|
return [delivery]
|
|
|
|
monkeypatch.setattr(
|
|
"app.api.api_v1.endpoints.webhooks.deliver_due_webhooks",
|
|
fake_process,
|
|
)
|
|
|
|
processed = authed_client.post("/api/v1/webhooks/deliveries/process?limit=500")
|
|
assert processed.status_code == 200
|
|
assert processed.json()["deliveries"][0]["status"] == DELIVERY_DELIVERED
|
|
|
|
endpoint_filtered = authed_client.get(
|
|
f"/api/v1/webhooks/deliveries?endpoint_id={created['id']}&status={DELIVERY_DELIVERED}"
|
|
)
|
|
assert endpoint_filtered.status_code == 200
|
|
assert len(endpoint_filtered.json()["deliveries"]) == 1
|
|
|
|
disabled = authed_client.delete(f"/api/v1/webhooks/{created['id']}")
|
|
assert disabled.status_code == 200
|
|
assert disabled.json()["enabled"] is False
|
|
|
|
missing_delete = authed_client.delete("/api/v1/webhooks/9999")
|
|
assert missing_delete.status_code == 404
|