fix: resolve merge conflict with main in test_convert_to_pdfa.py

This commit is contained in:
copilot-swe-agent[bot]
2026-03-21 14:25:01 +00:00
23 changed files with 1306 additions and 21 deletions
+1
View File
@@ -62,6 +62,7 @@ from app.main import app as fastapi_app # noqa: E402
from app.models import ( # noqa: F401, E402
ApiToken,
AuditLog,
AutomationHook,
ClassificationRuleModel,
ComplianceTemplate,
DocumentMetadata,
+432
View File
@@ -0,0 +1,432 @@
"""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
+2 -2
View File
@@ -38,8 +38,8 @@ class TestConvertPdfToPdfa:
assert "pdfa-2" in cmd
assert "--quiet" in cmd
assert "--invalidate-digital-signatures" in cmd
# "--" must be present to terminate option parsing and prevent paths
# starting with "-" from being interpreted as flags (command injection guard)
# SECURITY: Verify `--` end-of-options separator is present and precedes
# the file paths to prevent option/argument injection.
assert "--" in cmd
assert cmd.index("--") < cmd.index("/input.pdf")
assert cmd.index("--") < cmd.index("/output.pdf")
+5
View File
@@ -769,6 +769,11 @@ class TestUploadRclone:
cmd = mock_run.call_args[0][0]
assert cmd[0] == "rclone"
assert cmd[1] == "copyto"
# SECURITY: Verify `--` end-of-options separator is present and precedes
# the file path and destination to prevent option/argument injection.
assert "--" in cmd
fp_index = next(i for i, v in enumerate(cmd) if v == fp)
assert cmd.index("--") < fp_index
def test_raises_on_rclone_nonzero_exit(self, tmp_path):
fp = str(tmp_path / "doc.pdf")