feat(automation): add Zapier and Make.com integration
Add REST hooks subscription endpoints, incoming action endpoints, and
Zapier-compatible flat payload format for automation platform integration.
- AutomationHook model for webhook subscriptions
- POST /api/automation/hooks/subscribe and DELETE /hooks/{id}
- GET /api/automation/triggers/sample/{event} for Zapier field mapping
- POST /api/automation/actions/upload for incoming document uploads
- Celery task with retry for async hook delivery
- Integration with existing webhook dispatch flow
- 30 passing tests covering all new functionality
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.admin_users import router as admin_users_router
|
||||
from app.api.api_tokens import router as api_tokens_router
|
||||
from app.api.automation import router as automation_router
|
||||
from app.api.azure import router as azure_router
|
||||
from app.api.backup import router as backup_router
|
||||
from app.api.billing import router as billing_router
|
||||
@@ -82,3 +83,4 @@ router.include_router(imap_accounts_router)
|
||||
router.include_router(integrations_router)
|
||||
router.include_router(notifications_router)
|
||||
router.include_router(scheduled_jobs_router)
|
||||
router.include_router(automation_router)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""API endpoints for Zapier / Make.com automation integration.
|
||||
|
||||
Provides a REST hooks subscription interface for outgoing triggers and
|
||||
incoming action endpoints that external automation platforms can call.
|
||||
|
||||
Outgoing triggers:
|
||||
External platforms subscribe to DocuElevate events via
|
||||
``POST /api/automation/hooks/subscribe``. When a subscribed event
|
||||
fires, DocuElevate POSTs a flat Zapier-compatible JSON payload to the
|
||||
registered ``target_url``.
|
||||
|
||||
Incoming actions:
|
||||
``POST /api/automation/actions/upload`` allows automation platforms to
|
||||
push documents into DocuElevate for processing.
|
||||
|
||||
Authentication:
|
||||
All endpoints require a valid API token via ``Authorization: Bearer``
|
||||
header.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import AutomationHook
|
||||
from app.utils.automation_hooks import SAMPLE_PAYLOADS
|
||||
from app.utils.webhook import VALID_EVENTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/automation", tags=["automation"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper – require a valid API token (Bearer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_api_user(request: Request) -> dict:
|
||||
"""Ensure the caller is authenticated via session or API token.
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated, 403 if automation hooks are disabled.
|
||||
"""
|
||||
if not settings.automation_hooks_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Automation hooks are disabled",
|
||||
)
|
||||
|
||||
# Check for API-token user first (set by auth middleware)
|
||||
user = getattr(request.state, "api_token_user", None)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Fall back to session user
|
||||
user = request.session.get("user")
|
||||
if user:
|
||||
return user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required (Bearer token or session)",
|
||||
)
|
||||
|
||||
|
||||
AuthUser = Annotated[dict, Depends(_require_api_user)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HookSubscribe(BaseModel):
|
||||
"""Schema for subscribing to automation hook events."""
|
||||
|
||||
target_url: str = Field(..., min_length=1, max_length=2048, description="URL to POST event payloads to")
|
||||
events: list[str] = Field(..., min_length=1, description="Event types to subscribe to")
|
||||
secret: str | None = Field(default=None, max_length=512, description="Optional HMAC-SHA256 signing secret")
|
||||
hook_type: str = Field(
|
||||
default="generic",
|
||||
max_length=50,
|
||||
description="Platform identifier (zapier, make, generic)",
|
||||
)
|
||||
description: str | None = Field(default=None, max_length=500, description="Optional human-readable label")
|
||||
|
||||
|
||||
class HookResponse(BaseModel):
|
||||
"""Schema returned when listing or creating hooks."""
|
||||
|
||||
id: int
|
||||
target_url: str
|
||||
events: list[str]
|
||||
is_active: bool
|
||||
hook_type: str
|
||||
description: str | None
|
||||
has_secret: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ActionUploadResponse(BaseModel):
|
||||
"""Response after an automation action uploads a document."""
|
||||
|
||||
status: str
|
||||
filename: str
|
||||
task_id: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_events(events: list[str]) -> None:
|
||||
"""Raise 422 if any event name is not recognised."""
|
||||
invalid = set(events) - VALID_EVENTS
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid event(s): {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_EVENTS))}",
|
||||
)
|
||||
|
||||
|
||||
def _hook_to_response(hook: AutomationHook) -> dict[str, Any]:
|
||||
"""Convert a DB model instance to a response dict."""
|
||||
try:
|
||||
events = json.loads(hook.events)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
events = []
|
||||
return {
|
||||
"id": hook.id,
|
||||
"target_url": hook.target_url,
|
||||
"events": events,
|
||||
"is_active": hook.is_active,
|
||||
"hook_type": hook.hook_type,
|
||||
"description": hook.description,
|
||||
"has_secret": hook.secret is not None and len(hook.secret) > 0,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outgoing triggers – REST hooks subscription endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/hooks/subscribe",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Subscribe to automation events (REST hooks)",
|
||||
)
|
||||
def subscribe_hook(body: HookSubscribe, db: DbSession, user: AuthUser) -> dict[str, Any]:
|
||||
"""Register a new automation hook subscription.
|
||||
|
||||
Zapier and Make.com call this endpoint to subscribe to DocuElevate
|
||||
events. When an event fires, a flat JSON payload is POSTed to
|
||||
``target_url``.
|
||||
"""
|
||||
_validate_events(body.events)
|
||||
|
||||
hook = AutomationHook(
|
||||
target_url=body.target_url,
|
||||
secret=body.secret,
|
||||
events=json.dumps(sorted(body.events)),
|
||||
is_active=True,
|
||||
hook_type=body.hook_type or "generic",
|
||||
description=body.description,
|
||||
)
|
||||
try:
|
||||
db.add(hook)
|
||||
db.commit()
|
||||
db.refresh(hook)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Automation hook %d created (type=%s) for events %s", hook.id, hook.hook_type, body.events)
|
||||
return _hook_to_response(hook)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/hooks/{hook_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Unsubscribe an automation hook",
|
||||
)
|
||||
def unsubscribe_hook(hook_id: int, db: DbSession, user: AuthUser) -> None:
|
||||
"""Remove an automation hook subscription.
|
||||
|
||||
Zapier calls this endpoint when a Zap is turned off or deleted.
|
||||
"""
|
||||
hook = db.query(AutomationHook).filter(AutomationHook.id == hook_id).first()
|
||||
if not hook:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hook not found")
|
||||
|
||||
try:
|
||||
db.delete(hook)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Automation hook %d deleted", hook_id)
|
||||
|
||||
|
||||
@router.get("/hooks", summary="List automation hook subscriptions")
|
||||
def list_hooks(db: DbSession, user: AuthUser) -> list[dict[str, Any]]:
|
||||
"""Return all active automation hook subscriptions."""
|
||||
hooks = db.query(AutomationHook).order_by(AutomationHook.id).all()
|
||||
return [_hook_to_response(h) for h in hooks]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outgoing triggers – sample data for Zapier field mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/triggers/sample/{event}", summary="Get sample trigger data")
|
||||
def get_trigger_sample(event: str, user: AuthUser) -> list[dict[str, Any]]:
|
||||
"""Return sample payload data for the given event type.
|
||||
|
||||
Zapier uses this during Zap setup to discover available fields and
|
||||
provide a mapping interface. The response is wrapped in an array
|
||||
as Zapier expects.
|
||||
"""
|
||||
if event not in VALID_EVENTS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unknown event: {event}. Valid: {', '.join(sorted(VALID_EVENTS))}",
|
||||
)
|
||||
|
||||
sample = SAMPLE_PAYLOADS.get(event, {"id": "evt_sample", "event": event, "timestamp": 0})
|
||||
return [sample]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outgoing triggers – list valid events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/events", summary="List valid automation event types")
|
||||
def list_events(user: AuthUser) -> list[str]:
|
||||
"""Return the list of valid event types that automation hooks can subscribe to."""
|
||||
return sorted(VALID_EVENTS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Incoming actions – endpoints that Zapier / Make.com can call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/actions/upload", summary="Upload a document (incoming action)")
|
||||
def action_upload(
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
user: AuthUser,
|
||||
file: UploadFile = File(...),
|
||||
) -> dict[str, Any]:
|
||||
"""Accept a document upload from an automation platform.
|
||||
|
||||
This endpoint allows Zapier or Make.com to push a document into
|
||||
DocuElevate for processing. The file is saved to the work directory
|
||||
and a background processing task is queued.
|
||||
"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
|
||||
|
||||
owner_id = user.get("preferred_username") or user.get("email") or user.get("id", "automation")
|
||||
workdir = settings.workdir or tempfile.gettempdir()
|
||||
upload_dir = os.path.join(workdir, "uploads")
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
dest_path = os.path.join(upload_dir, file.filename)
|
||||
try:
|
||||
contents = file.file.read()
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(contents)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to save uploaded file: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save file")
|
||||
|
||||
# Queue background processing
|
||||
task_id = None
|
||||
try:
|
||||
from app.tasks.process_document import process_document
|
||||
|
||||
result = process_document.delay(dest_path, owner_id)
|
||||
task_id = result.id
|
||||
logger.info("Automation upload queued: file=%s, task=%s, owner=%s", file.filename, task_id, owner_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not queue processing task (Celery may be unavailable): %s", exc)
|
||||
|
||||
return {
|
||||
"status": "accepted",
|
||||
"filename": file.filename,
|
||||
"task_id": task_id,
|
||||
}
|
||||
@@ -617,6 +617,12 @@ class Settings(BaseSettings):
|
||||
description="Enable webhook delivery for document events",
|
||||
)
|
||||
|
||||
# Automation hooks (Zapier / Make.com)
|
||||
automation_hooks_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable Zapier / Make.com automation hook subscriptions and delivery",
|
||||
)
|
||||
|
||||
# ── Backup / restore settings ──────────────────────────────────────────────
|
||||
backup_enabled: bool = Field(
|
||||
default=True,
|
||||
|
||||
@@ -176,6 +176,28 @@ class WebhookConfig(Base):
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class AutomationHook(Base):
|
||||
"""Zapier / Make.com compatible webhook subscription for automation triggers.
|
||||
|
||||
External automation platforms subscribe to DocuElevate events via the REST
|
||||
hooks protocol. When an event fires, DocuElevate POSTs a Zapier-compatible
|
||||
flat JSON payload to ``target_url``. The ``hook_type`` field records which
|
||||
platform created the subscription (informational only).
|
||||
"""
|
||||
|
||||
__tablename__ = "automation_hooks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_url = Column(String, nullable=False) # URL to POST events to
|
||||
secret = Column(String, nullable=True) # Optional HMAC-SHA256 signing secret
|
||||
events = Column(Text, nullable=False) # JSON list of subscribed event names
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
hook_type = Column(String(50), nullable=False, default="generic") # zapier | make | generic
|
||||
description = Column(String, nullable=True) # Optional human-readable label
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class LocalUser(Base):
|
||||
"""A locally-registered user authenticated by email and bcrypt password.
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Celery task for asynchronous automation hook delivery with retry and backoff.
|
||||
|
||||
Uses :class:`~app.tasks.retry_config.BaseTaskWithRetry` so failed deliveries
|
||||
are automatically retried with exponential backoff (default: 60 s, 300 s,
|
||||
900 s) and ±20 % jitter.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.celery_app import celery
|
||||
from app.tasks.retry_config import BaseTaskWithRetry
|
||||
from app.utils.webhook import deliver_webhook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.task(base=BaseTaskWithRetry, bind=True, name="automation.deliver_hook")
|
||||
def deliver_automation_hook_task(self, url: str, payload: dict[str, Any], secret: str | None = None) -> dict[str, Any]:
|
||||
"""Deliver an automation hook payload to *url* with automatic retries.
|
||||
|
||||
Args:
|
||||
url: Target webhook URL (provided by Zapier / Make.com).
|
||||
payload: The flat Zapier-compatible payload.
|
||||
secret: Optional shared secret for HMAC-SHA256 signing.
|
||||
|
||||
Returns:
|
||||
A dict with ``status`` and ``url`` on success.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Re-raised to trigger Celery retry on delivery failure.
|
||||
"""
|
||||
logger.info(
|
||||
"Delivering automation hook to %s (attempt %d/%d)",
|
||||
url,
|
||||
self.request.retries + 1,
|
||||
self.max_retries + 1,
|
||||
)
|
||||
|
||||
success = deliver_webhook(url, payload, secret)
|
||||
if success:
|
||||
return {"status": "delivered", "url": url}
|
||||
|
||||
raise RuntimeError(f"Automation hook delivery to {url} failed")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Automation hook utilities for Zapier / Make.com integration.
|
||||
|
||||
Provides helpers to build Zapier-compatible flat payloads, query active
|
||||
automation hook subscriptions, and fan-out event delivery to all matching
|
||||
hooks via Celery tasks.
|
||||
|
||||
The payload format is intentionally *flat* (no nested ``data`` key) so that
|
||||
Zapier and Make.com can map fields without JSONPath expressions. An ``id``
|
||||
field is included for Zapier deduplication.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models import AutomationHook
|
||||
from app.utils.webhook import VALID_EVENTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_zapier_payload(event: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build a flat, Zapier-compatible webhook payload.
|
||||
|
||||
Zapier works best with flat JSON objects that include an ``id`` field
|
||||
for deduplication. This function merges event metadata into the
|
||||
top-level object alongside the event-specific *data*.
|
||||
|
||||
Args:
|
||||
event: The event name (e.g. ``document.processed``).
|
||||
data: Event-specific key/value pairs.
|
||||
|
||||
Returns:
|
||||
A flat dictionary suitable for Zapier / Make.com consumption.
|
||||
"""
|
||||
return {
|
||||
"id": f"evt_{uuid.uuid4().hex[:16]}",
|
||||
"event": event,
|
||||
"timestamp": time.time(),
|
||||
**data,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample payloads (used by the /triggers/sample endpoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Example payloads that Zapier uses for field-mapping during Zap creation.
|
||||
SAMPLE_PAYLOADS: dict[str, dict[str, Any]] = {
|
||||
"document.uploaded": {
|
||||
"id": "evt_sample0001",
|
||||
"event": "document.uploaded",
|
||||
"timestamp": 1710000000.0,
|
||||
"document_id": 42,
|
||||
"filename": "invoice_2024.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"size_bytes": 204800,
|
||||
"owner_id": "user@example.com",
|
||||
},
|
||||
"document.processed": {
|
||||
"id": "evt_sample0002",
|
||||
"event": "document.processed",
|
||||
"timestamp": 1710000060.0,
|
||||
"document_id": 42,
|
||||
"filename": "invoice_2024.pdf",
|
||||
"status": "processed",
|
||||
"title": "Invoice #1234",
|
||||
"owner_id": "user@example.com",
|
||||
},
|
||||
"document.failed": {
|
||||
"id": "evt_sample0003",
|
||||
"event": "document.failed",
|
||||
"timestamp": 1710000120.0,
|
||||
"document_id": 42,
|
||||
"filename": "corrupt.pdf",
|
||||
"status": "failed",
|
||||
"error": "Unable to extract text from document",
|
||||
"owner_id": "user@example.com",
|
||||
},
|
||||
"user.signup": {
|
||||
"id": "evt_sample0004",
|
||||
"event": "user.signup",
|
||||
"timestamp": 1710000180.0,
|
||||
"user_id": "newuser@example.com",
|
||||
"display_name": "Jane Doe",
|
||||
},
|
||||
"user.plan_changed": {
|
||||
"id": "evt_sample0005",
|
||||
"event": "user.plan_changed",
|
||||
"timestamp": 1710000240.0,
|
||||
"user_id": "user@example.com",
|
||||
"old_tier": "free",
|
||||
"new_tier": "pro",
|
||||
},
|
||||
"user.payment_issue": {
|
||||
"id": "evt_sample0006",
|
||||
"event": "user.payment_issue",
|
||||
"timestamp": 1710000300.0,
|
||||
"user_id": "user@example.com",
|
||||
"issue": "Credit card declined",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_active_hooks_for_event(event: str) -> list[dict[str, Any]]:
|
||||
"""Return all active automation hooks subscribed to *event*.
|
||||
|
||||
Args:
|
||||
event: The event name to filter on.
|
||||
|
||||
Returns:
|
||||
A list of dicts with ``id``, ``target_url``, ``secret``, and
|
||||
``events`` keys.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
hooks = db.query(AutomationHook).filter(AutomationHook.is_active.is_(True)).all()
|
||||
result: list[dict[str, Any]] = []
|
||||
for hook in hooks:
|
||||
try:
|
||||
subscribed = json.loads(hook.events)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
subscribed = []
|
||||
if event in subscribed:
|
||||
result.append(
|
||||
{
|
||||
"id": hook.id,
|
||||
"target_url": hook.target_url,
|
||||
"secret": hook.secret,
|
||||
"events": subscribed,
|
||||
}
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def dispatch_automation_hooks(event: str, data: dict[str, Any]) -> None:
|
||||
"""Fan-out an event to all matching active automation hooks.
|
||||
|
||||
Builds a Zapier-compatible flat payload and queues a Celery task for
|
||||
each matching hook so delivery is asynchronous with automatic retries.
|
||||
|
||||
Args:
|
||||
event: Event name (must be in :data:`VALID_EVENTS`).
|
||||
data: Event-specific payload data.
|
||||
"""
|
||||
if not settings.automation_hooks_enabled:
|
||||
return
|
||||
|
||||
if event not in VALID_EVENTS:
|
||||
logger.warning("Ignoring unknown automation hook event: %s", event)
|
||||
return
|
||||
|
||||
hooks = get_active_hooks_for_event(event)
|
||||
if not hooks:
|
||||
logger.debug("No active automation hooks for event %s", event)
|
||||
return
|
||||
|
||||
payload = build_zapier_payload(event, data)
|
||||
|
||||
from app.tasks.automation_tasks import deliver_automation_hook_task
|
||||
|
||||
for hook in hooks:
|
||||
try:
|
||||
deliver_automation_hook_task.delay(hook["target_url"], payload, hook["secret"])
|
||||
logger.debug("Queued automation hook delivery to %s for event %s", hook["target_url"], event)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to queue automation hook to %s: %s", hook["target_url"], exc)
|
||||
@@ -1595,6 +1595,18 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"automation_hooks_enabled": {
|
||||
"category": "Feature Flags",
|
||||
"description": (
|
||||
"Enable Zapier / Make.com automation hook subscriptions and delivery. "
|
||||
"When enabled, external automation platforms can subscribe to DocuElevate events "
|
||||
"via the REST hooks protocol. Default: True."
|
||||
),
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Backup / Restore
|
||||
"backup_enabled": {
|
||||
"category": "Backup",
|
||||
|
||||
+19
-10
@@ -145,6 +145,8 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
|
||||
It delegates to :func:`deliver_webhook_task` (Celery) for each matching
|
||||
webhook so delivery happens asynchronously with automatic retries.
|
||||
|
||||
Also dispatches to automation hooks (Zapier / Make.com) if enabled.
|
||||
|
||||
Args:
|
||||
event: Event name (must be in :data:`VALID_EVENTS`).
|
||||
data: Event-specific payload data.
|
||||
@@ -156,16 +158,23 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
|
||||
webhooks = get_active_webhooks_for_event(event)
|
||||
if not webhooks:
|
||||
logger.debug("No active webhooks for event %s", event)
|
||||
return
|
||||
else:
|
||||
payload = build_payload(event, data)
|
||||
|
||||
payload = build_payload(event, data)
|
||||
# Import here to avoid circular dependency with celery_app
|
||||
from app.tasks.webhook_tasks import deliver_webhook_task
|
||||
|
||||
# Import here to avoid circular dependency with celery_app
|
||||
from app.tasks.webhook_tasks import deliver_webhook_task
|
||||
for wh in webhooks:
|
||||
try:
|
||||
deliver_webhook_task.delay(wh["url"], payload, wh["secret"])
|
||||
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
|
||||
|
||||
for wh in webhooks:
|
||||
try:
|
||||
deliver_webhook_task.delay(wh["url"], payload, wh["secret"])
|
||||
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
|
||||
# Also fan-out to Zapier / Make.com automation hooks
|
||||
try:
|
||||
from app.utils.automation_hooks import dispatch_automation_hooks
|
||||
|
||||
dispatch_automation_hooks(event, data)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to dispatch automation hooks for event %s: %s", event, exc)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Add automation_hooks table for Zapier / Make.com webhook subscriptions.
|
||||
|
||||
Revision ID: 027_add_automation_hooks
|
||||
Revises: 026_add_scheduled_jobs
|
||||
Create Date: 2026-03-09
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "027_add_automation_hooks"
|
||||
down_revision: Union[str, None] = "026_add_scheduled_jobs"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create automation_hooks table."""
|
||||
op.create_table(
|
||||
"automation_hooks",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("target_url", sa.String(), nullable=False),
|
||||
sa.Column("secret", sa.String(), nullable=True),
|
||||
sa.Column("events", sa.Text(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("hook_type", sa.String(50), nullable=False, server_default="generic"),
|
||||
sa.Column("description", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_automation_hooks_id", "automation_hooks", ["id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop automation_hooks table."""
|
||||
op.drop_index("ix_automation_hooks_id", "automation_hooks")
|
||||
op.drop_table("automation_hooks")
|
||||
@@ -61,6 +61,7 @@ from app.main import app as fastapi_app # noqa: E402
|
||||
# Import models to register them with SQLAlchemy Base
|
||||
from app.models import ( # noqa: F401, E402
|
||||
ApiToken,
|
||||
AutomationHook,
|
||||
DocumentMetadata,
|
||||
FileRecord,
|
||||
Pipeline,
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"""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.unit
|
||||
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 returns 422 for invalid multipart form data
|
||||
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
|
||||
Reference in New Issue
Block a user