1a195a96bd
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html) - Add defensive JSON parsing with try/except for integration.config - Wrap tester() call in try/except to prevent 500 errors from bad config - Add i18n key integrations.connection_test_failed_fallback in en.json - Reference i18n key in template JS fallback message - Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date - Remove accidental revert.sh file - Fix missing MagicMock/patch imports in test file - Add tests for invalid JSON config and tester exception error paths Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
433 lines
17 KiB
Python
433 lines
17 KiB
Python
"""Tests for the Zapier / Make.com automation integration.
|
||
|
||
Covers:
|
||
- Automation hook utility functions (payload builder, DB queries, dispatch)
|
||
- Automation hook Celery task
|
||
- REST hooks API endpoints (subscribe, unsubscribe, list, sample, events)
|
||
- Incoming action endpoints (upload)
|
||
- Integration with existing webhook dispatch
|
||
"""
|
||
|
||
import json
|
||
import time
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
from app.models import AutomationHook
|
||
from app.utils.automation_hooks import (
|
||
SAMPLE_PAYLOADS,
|
||
build_zapier_payload,
|
||
dispatch_automation_hooks,
|
||
get_active_hooks_for_event,
|
||
)
|
||
from app.utils.webhook import VALID_EVENTS
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit tests – build_zapier_payload
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestBuildZapierPayload:
|
||
"""Tests for the Zapier-compatible payload builder."""
|
||
|
||
def test_contains_required_keys(self):
|
||
"""Payload must contain id, event, timestamp, plus data fields."""
|
||
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
|
||
assert "id" in payload
|
||
assert "event" in payload
|
||
assert "timestamp" in payload
|
||
assert "document_id" in payload
|
||
|
||
def test_id_starts_with_evt(self):
|
||
"""ID field must start with 'evt_' for Zapier deduplication."""
|
||
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
|
||
assert payload["id"].startswith("evt_")
|
||
|
||
def test_event_matches_input(self):
|
||
"""Event field must match the event argument."""
|
||
payload = build_zapier_payload("document.processed", {"document_id": 2})
|
||
assert payload["event"] == "document.processed"
|
||
|
||
def test_timestamp_is_recent(self):
|
||
"""Timestamp should be close to current time."""
|
||
before = time.time()
|
||
payload = build_zapier_payload("document.uploaded", {})
|
||
after = time.time()
|
||
assert before <= payload["timestamp"] <= after
|
||
|
||
def test_data_is_flat(self):
|
||
"""Data fields should be merged into top level (flat, no nested 'data' key)."""
|
||
payload = build_zapier_payload("document.uploaded", {"filename": "test.pdf", "size": 1024})
|
||
assert payload["filename"] == "test.pdf"
|
||
assert payload["size"] == 1024
|
||
assert "data" not in payload
|
||
|
||
def test_unique_ids(self):
|
||
"""Each call should produce a unique ID."""
|
||
ids = {build_zapier_payload("document.uploaded", {})["id"] for _ in range(50)}
|
||
assert len(ids) == 50
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit tests – SAMPLE_PAYLOADS
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestSamplePayloads:
|
||
"""Tests for the sample payloads used by Zapier field mapping."""
|
||
|
||
def test_all_events_have_samples(self):
|
||
"""Every valid event should have a sample payload."""
|
||
for event in VALID_EVENTS:
|
||
assert event in SAMPLE_PAYLOADS, f"Missing sample payload for {event}"
|
||
|
||
def test_samples_contain_id_and_event(self):
|
||
"""Each sample should contain id and event keys."""
|
||
for event, sample in SAMPLE_PAYLOADS.items():
|
||
assert "id" in sample, f"Sample for {event} missing 'id'"
|
||
assert sample["event"] == event
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit tests – get_active_hooks_for_event
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestGetActiveHooksForEvent:
|
||
"""Tests for querying active automation hooks from the database."""
|
||
|
||
def test_returns_matching_hooks(self, mocker):
|
||
"""Only hooks subscribed to the event should be returned."""
|
||
hook = MagicMock(
|
||
id=1,
|
||
target_url="https://hooks.zapier.com/1234",
|
||
secret="abc",
|
||
events=json.dumps(["document.uploaded"]),
|
||
is_active=True,
|
||
)
|
||
mock_session = MagicMock()
|
||
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
|
||
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
|
||
|
||
result = get_active_hooks_for_event("document.uploaded")
|
||
assert len(result) == 1
|
||
assert result[0]["target_url"] == "https://hooks.zapier.com/1234"
|
||
|
||
def test_excludes_non_matching_hooks(self, mocker):
|
||
"""Hooks for different events should not be returned."""
|
||
hook = MagicMock(
|
||
id=1,
|
||
target_url="https://hooks.zapier.com/1234",
|
||
secret=None,
|
||
events=json.dumps(["document.processed"]),
|
||
is_active=True,
|
||
)
|
||
mock_session = MagicMock()
|
||
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
|
||
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
|
||
|
||
result = get_active_hooks_for_event("document.uploaded")
|
||
assert len(result) == 0
|
||
|
||
def test_empty_when_no_hooks(self, mocker):
|
||
"""Empty list returned when no hooks exist."""
|
||
mock_session = MagicMock()
|
||
mock_session.query.return_value.filter.return_value.all.return_value = []
|
||
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
|
||
|
||
result = get_active_hooks_for_event("document.uploaded")
|
||
assert result == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit tests – dispatch_automation_hooks
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestDispatchAutomationHooks:
|
||
"""Tests for the automation hook dispatch function."""
|
||
|
||
def test_ignores_unknown_events(self, mocker):
|
||
"""Unknown events should be silently ignored."""
|
||
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
|
||
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
|
||
dispatch_automation_hooks("bad.event", {})
|
||
mock_get.assert_not_called()
|
||
|
||
def test_skips_when_disabled(self, mocker):
|
||
"""No hooks should fire when automation_hooks_enabled is False."""
|
||
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=False))
|
||
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
|
||
dispatch_automation_hooks("document.uploaded", {"file_id": 1})
|
||
mock_get.assert_not_called()
|
||
|
||
def test_queues_celery_task_for_each_hook(self, mocker):
|
||
"""A Celery task is queued for each matching hook."""
|
||
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
|
||
mocker.patch(
|
||
"app.utils.automation_hooks.get_active_hooks_for_event",
|
||
return_value=[
|
||
{"id": 1, "target_url": "https://hooks.zapier.com/a", "secret": "s", "events": ["document.uploaded"]},
|
||
{"id": 2, "target_url": "https://hooks.zapier.com/b", "secret": None, "events": ["document.uploaded"]},
|
||
],
|
||
)
|
||
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
|
||
|
||
dispatch_automation_hooks("document.uploaded", {"file_id": 42})
|
||
|
||
assert mock_task.call_count == 2
|
||
|
||
def test_no_tasks_when_no_hooks(self, mocker):
|
||
"""No tasks should be queued when there are no matching hooks."""
|
||
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
|
||
mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event", return_value=[])
|
||
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
|
||
|
||
dispatch_automation_hooks("document.uploaded", {})
|
||
|
||
mock_task.assert_not_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unit tests – deliver_automation_hook_task
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestDeliverAutomationHookTask:
|
||
"""Tests for the automation hook Celery task."""
|
||
|
||
def test_returns_success_dict(self, mocker):
|
||
"""Successful delivery returns status dict."""
|
||
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=True)
|
||
|
||
from app.tasks.automation_tasks import deliver_automation_hook_task
|
||
|
||
deliver_automation_hook_task.request.retries = 0
|
||
|
||
result = deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
|
||
assert result["status"] == "delivered"
|
||
assert result["url"] == "https://hooks.zapier.com/test"
|
||
|
||
def test_raises_on_failure(self, mocker):
|
||
"""Failed delivery raises RuntimeError for Celery retry."""
|
||
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=False)
|
||
|
||
from app.tasks.automation_tasks import deliver_automation_hook_task
|
||
|
||
deliver_automation_hook_task.request.retries = 0
|
||
|
||
with pytest.raises(RuntimeError, match="Automation hook delivery"):
|
||
deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration tests – webhook dispatch integration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestWebhookDispatchIntegration:
|
||
"""Test that dispatch_webhook_event also triggers automation hooks."""
|
||
|
||
def test_dispatch_triggers_automation_hooks(self, mocker):
|
||
"""dispatch_webhook_event should also call dispatch_automation_hooks."""
|
||
mocker.patch("app.utils.webhook.get_active_webhooks_for_event", return_value=[])
|
||
mock_auto = mocker.patch("app.utils.automation_hooks.dispatch_automation_hooks")
|
||
|
||
from app.utils.webhook import dispatch_webhook_event
|
||
|
||
dispatch_webhook_event("document.uploaded", {"file_id": 1})
|
||
|
||
mock_auto.assert_called_once_with("document.uploaded", {"file_id": 1})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Integration tests – API endpoints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestAutomationAPI:
|
||
"""Tests for the /api/automation/ endpoints."""
|
||
|
||
def _with_auth(self, client):
|
||
"""Override auth dependency to simulate an authenticated user."""
|
||
from app.api.automation import _require_api_user
|
||
|
||
client.app.dependency_overrides[_require_api_user] = lambda: {
|
||
"id": "testuser",
|
||
"email": "test@example.com",
|
||
"preferred_username": "testuser",
|
||
"is_admin": False,
|
||
}
|
||
return client
|
||
|
||
# ── Subscribe / Unsubscribe ──────────────────────────────────────
|
||
|
||
def test_subscribe_hook(self, client):
|
||
"""POST /api/automation/hooks/subscribe creates a new hook."""
|
||
self._with_auth(client)
|
||
resp = client.post(
|
||
"/api/automation/hooks/subscribe",
|
||
json={
|
||
"target_url": "https://hooks.zapier.com/test",
|
||
"events": ["document.uploaded"],
|
||
"hook_type": "zapier",
|
||
"description": "My Zap",
|
||
},
|
||
)
|
||
assert resp.status_code == 201
|
||
data = resp.json()
|
||
assert data["target_url"] == "https://hooks.zapier.com/test"
|
||
assert data["events"] == ["document.uploaded"]
|
||
assert data["is_active"] is True
|
||
assert data["hook_type"] == "zapier"
|
||
|
||
def test_subscribe_with_secret(self, client):
|
||
"""POST /api/automation/hooks/subscribe with secret masks it."""
|
||
self._with_auth(client)
|
||
resp = client.post(
|
||
"/api/automation/hooks/subscribe",
|
||
json={
|
||
"target_url": "https://hooks.zapier.com/secret",
|
||
"events": ["document.processed"],
|
||
"secret": "my-signing-secret",
|
||
},
|
||
)
|
||
assert resp.status_code == 201
|
||
data = resp.json()
|
||
assert data["has_secret"] is True
|
||
assert "secret" not in data
|
||
|
||
def test_subscribe_invalid_event(self, client):
|
||
"""POST /api/automation/hooks/subscribe rejects invalid events."""
|
||
self._with_auth(client)
|
||
resp = client.post(
|
||
"/api/automation/hooks/subscribe",
|
||
json={
|
||
"target_url": "https://hooks.zapier.com/bad",
|
||
"events": ["bad.event"],
|
||
},
|
||
)
|
||
assert resp.status_code == 422
|
||
|
||
def test_unsubscribe_hook(self, client, db_session):
|
||
"""DELETE /api/automation/hooks/{id} removes the hook."""
|
||
self._with_auth(client)
|
||
hook = AutomationHook(
|
||
target_url="https://hooks.zapier.com/del",
|
||
events=json.dumps(["document.uploaded"]),
|
||
is_active=True,
|
||
hook_type="zapier",
|
||
)
|
||
db_session.add(hook)
|
||
db_session.commit()
|
||
hook_id = hook.id
|
||
|
||
resp = client.delete(f"/api/automation/hooks/{hook_id}")
|
||
assert resp.status_code == 204
|
||
|
||
def test_unsubscribe_not_found(self, client):
|
||
"""DELETE /api/automation/hooks/9999 returns 404."""
|
||
self._with_auth(client)
|
||
resp = client.delete("/api/automation/hooks/9999")
|
||
assert resp.status_code == 404
|
||
|
||
# ── List hooks ───────────────────────────────────────────────────
|
||
|
||
def test_list_hooks(self, client, db_session):
|
||
"""GET /api/automation/hooks returns all hooks."""
|
||
self._with_auth(client)
|
||
hook = AutomationHook(
|
||
target_url="https://hooks.zapier.com/list",
|
||
events=json.dumps(["document.processed"]),
|
||
is_active=True,
|
||
hook_type="make",
|
||
)
|
||
db_session.add(hook)
|
||
db_session.commit()
|
||
|
||
resp = client.get("/api/automation/hooks")
|
||
assert resp.status_code == 200
|
||
items = resp.json()
|
||
assert any(h["target_url"] == "https://hooks.zapier.com/list" for h in items)
|
||
|
||
# ── Sample trigger data ──────────────────────────────────────────
|
||
|
||
def test_trigger_sample(self, client):
|
||
"""GET /api/automation/triggers/sample/{event} returns sample data."""
|
||
self._with_auth(client)
|
||
resp = client.get("/api/automation/triggers/sample/document.uploaded")
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert isinstance(data, list)
|
||
assert len(data) == 1
|
||
assert data[0]["event"] == "document.uploaded"
|
||
assert "id" in data[0]
|
||
|
||
def test_trigger_sample_unknown_event(self, client):
|
||
"""GET /api/automation/triggers/sample/bad returns 404."""
|
||
self._with_auth(client)
|
||
resp = client.get("/api/automation/triggers/sample/bad.event")
|
||
assert resp.status_code == 404
|
||
|
||
# ── Events listing ───────────────────────────────────────────────
|
||
|
||
def test_list_events(self, client):
|
||
"""GET /api/automation/events returns valid event types."""
|
||
self._with_auth(client)
|
||
resp = client.get("/api/automation/events")
|
||
assert resp.status_code == 200
|
||
events = resp.json()
|
||
assert "document.uploaded" in events
|
||
assert "document.processed" in events
|
||
assert "document.failed" in events
|
||
|
||
# ── Incoming action: upload ──────────────────────────────────────
|
||
|
||
def test_action_upload(self, client, mocker):
|
||
"""POST /api/automation/actions/upload accepts a file."""
|
||
self._with_auth(client)
|
||
mock_task = MagicMock()
|
||
mock_task.id = "task-123"
|
||
mocker.patch("app.tasks.process_document.process_document.delay", return_value=mock_task)
|
||
|
||
resp = client.post(
|
||
"/api/automation/actions/upload",
|
||
files={"file": ("test.pdf", b"fake-pdf-content", "application/pdf")},
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["status"] == "accepted"
|
||
assert data["filename"] == "test.pdf"
|
||
assert data["task_id"] == "task-123"
|
||
|
||
def test_action_upload_no_filename(self, client):
|
||
"""POST /api/automation/actions/upload rejects empty filename."""
|
||
self._with_auth(client)
|
||
resp = client.post(
|
||
"/api/automation/actions/upload",
|
||
files={"file": ("", b"content", "application/pdf")},
|
||
)
|
||
# FastAPI/Starlette may return 422 (multipart validation) or 400
|
||
# (our explicit check) depending on how the empty filename is
|
||
# parsed by the underlying multipart parser version.
|
||
assert resp.status_code in (400, 422)
|
||
|
||
# ── Auth required ────────────────────────────────────────────────
|
||
|
||
def test_requires_auth(self, client):
|
||
"""Endpoints return 401 without authentication."""
|
||
from app.api.automation import _require_api_user
|
||
|
||
client.app.dependency_overrides.pop(_require_api_user, None)
|
||
|
||
resp = client.get("/api/automation/hooks")
|
||
assert resp.status_code == 401
|