diff --git a/BUILD_DATE b/BUILD_DATE index ab2725eb..5ca577eb 100644 --- a/BUILD_DATE +++ b/BUILD_DATE @@ -1 +1 @@ -2026-03-20T23:38:07Z +2026-03-21T14:21:09Z diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d59d41d..c3ae3510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## v0.164.0 (2026-03-21) + +### Bug Fixes + +- Merge main branch and renumber migration 027→037 + ([`204000a`](https://github.com/christianlouis/DocuElevate/commit/204000aabcf94bd9eeff7cb67d8707e9af8b7fa3)) + +- Merge main branch and renumber migration 037→040 + ([`e518bce`](https://github.com/christianlouis/DocuElevate/commit/e518bce922524ae8a12372abe582a6feb5efe3aa)) + +- **automation**: Address code review - path traversal fix and test marker + ([`6a83d51`](https://github.com/christianlouis/DocuElevate/commit/6a83d51d888a20045ad2a3e4e68d3205329e3856)) + +- **automation**: Register automation task in celery worker and add docs + ([`ce2a76f`](https://github.com/christianlouis/DocuElevate/commit/ce2a76fb770da3257b31cb5958c888821a23da0c)) + +- **docs**: Remove duplicate Further Assistance heading in API.md + ([`34ff7f8`](https://github.com/christianlouis/DocuElevate/commit/34ff7f8de877cc0a7ae3b000cb3199cf017be16c)) + +### Features + +- **automation**: Add Zapier and Make.com integration + ([`d167be8`](https://github.com/christianlouis/DocuElevate/commit/d167be827421b3abf19441a2dea052abb486b567)) + + +## v0.163.1 (2026-03-21) + +### Bug Fixes + +- **api**: Add missing `import requests` in dropbox.py and onedrive.py to fix ruff F821 + ([`35caf24`](https://github.com/christianlouis/DocuElevate/commit/35caf24e3c0f4034b200038fd507a4747b6785f6)) + +### Testing + +- Add -- separator assertions to rclone and ocrmypdf tests + ([`81484ad`](https://github.com/christianlouis/DocuElevate/commit/81484ad770e859e49da2a4bb5cd0ab01edf89de1)) + + ## v0.163.0 (2026-03-20) ### Bug Fixes diff --git a/GIT_SHA b/GIT_SHA index b98bde3f..874b0ba3 100644 --- a/GIT_SHA +++ b/GIT_SHA @@ -1 +1 @@ -6f5a73f +a27a4ce diff --git a/RUNTIME_INFO b/RUNTIME_INFO index 3d9f8b3b..d1890d63 100644 --- a/RUNTIME_INFO +++ b/RUNTIME_INFO @@ -1,10 +1,10 @@ DocuElevate Build Information ============================== -Version: 0.163.0 -Build Date: 2026-03-20T23:38:07Z -Git Commit: 6f5a73f98ae3e890a232e5202521b1a263cfe5d0 -Git Short SHA: 6f5a73f +Version: 0.164.0 +Build Date: 2026-03-21T14:21:09Z +Git Commit: a27a4ce130515d2d214b03cd4275b3414cc24c1f +Git Short SHA: a27a4ce Git Branch: main -Commit Date: 2026-03-21T00:37:49+01:00 -Build Timestamp: 2026-03-20T23:38:07Z +Commit Date: 2026-03-21T15:20:42+01:00 +Build Timestamp: 2026-03-21T14:21:09Z ============================== diff --git a/VERSION b/VERSION index 599e9b4e..a6746cb8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.163.0 +0.164.0 diff --git a/app/api/__init__.py b/app/api/__init__.py index 871daa72..de5c41e8 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -9,6 +9,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.audit_logs import router as audit_logs_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 @@ -106,3 +107,4 @@ router.include_router(compliance_router) router.include_router(system_reset_router) router.include_router(translation_router) router.include_router(classification_rules_router) +router.include_router(automation_router) diff --git a/app/api/automation.py b/app/api/automation.py new file mode 100644 index 00000000..ed81a343 --- /dev/null +++ b/app/api/automation.py @@ -0,0 +1,311 @@ +"""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") + + # Sanitise filename to prevent path traversal attacks + safe_filename = os.path.basename(file.filename) + if not safe_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, safe_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", safe_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": safe_filename, + "task_id": task_id, + } diff --git a/app/api/dropbox.py b/app/api/dropbox.py index 86f72a25..e2d33448 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -8,6 +8,7 @@ from typing import Annotated, Optional from urllib.parse import quote import httpx +import requests from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from sqlalchemy.orm import Session diff --git a/app/api/onedrive.py b/app/api/onedrive.py index 9e19c303..cf39f43b 100644 --- a/app/api/onedrive.py +++ b/app/api/onedrive.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta from typing import Annotated, Optional import httpx +import requests from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from sqlalchemy.orm import Session diff --git a/app/celery_worker.py b/app/celery_worker.py index 29480a32..e8debec4 100644 --- a/app/celery_worker.py +++ b/app/celery_worker.py @@ -10,6 +10,7 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe # Import the shared Celery instance from app.celery_app import celery from app.config import settings +from app.tasks.automation_tasks import deliver_automation_hook_task # noqa: F401 from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401 from app.tasks.batch_tasks import ( # noqa: F401 backfill_missing_metadata, diff --git a/app/config.py b/app/config.py index 70ba5c36..ab0bfd4c 100644 --- a/app/config.py +++ b/app/config.py @@ -878,6 +878,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, diff --git a/app/models.py b/app/models.py index 60323f02..94697fc4 100644 --- a/app/models.py +++ b/app/models.py @@ -211,6 +211,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. diff --git a/app/tasks/automation_tasks.py b/app/tasks/automation_tasks.py new file mode 100644 index 00000000..18851f48 --- /dev/null +++ b/app/tasks/automation_tasks.py @@ -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") diff --git a/app/utils/automation_hooks.py b/app/utils/automation_hooks.py new file mode 100644 index 00000000..23e75046 --- /dev/null +++ b/app/utils/automation_hooks.py @@ -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) diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 644a62eb..0c990143 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -2050,6 +2050,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, + }, "compliance_enabled": { "category": "Feature Flags", "description": ( diff --git a/app/utils/webhook.py b/app/utils/webhook.py index 3bc54f82..0d181e71 100644 --- a/app/utils/webhook.py +++ b/app/utils/webhook.py @@ -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) diff --git a/docs/API.md b/docs/API.md index bd48ec75..cfc1a844 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2499,6 +2499,159 @@ DELETE /api/classification-rules/{rule_id} **Response:** `204 No Content` + +## Automation (Zapier / Make.com) + +Manage automation hook subscriptions for integrating DocuElevate with external platforms like Zapier and Make.com. All endpoints require API token authentication (`Authorization: Bearer `). + +### Supported Events + +The automation system shares event types with the [Webhooks](#webhooks) subsystem: + +| Event | Description | +|-------|-------------| +| `document.uploaded` | A new document has been ingested | +| `document.processed` | A document finished processing successfully | +| `document.failed` | Document processing failed | +| `user.signup` | A new user account was created | +| `user.plan_changed` | A user's subscription plan changed | +| `user.payment_issue` | A payment issue was reported for a user | + +### GET /api/automation/events + +List all valid event types that automation hooks can subscribe to. + +**Response (200):** +```json +["document.failed", "document.processed", "document.uploaded", "user.payment_issue", "user.plan_changed", "user.signup"] +``` + +### POST /api/automation/hooks/subscribe + +Subscribe to DocuElevate events. Zapier and Make.com call this endpoint to register a webhook URL that receives event notifications. + +**Request:** +```bash +curl -X POST "http://your-instance/api/automation/hooks/subscribe" \ + -H "Authorization: Bearer de_your_token_here" \ + -H "Content-Type: application/json" \ + -d '{ + "target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", + "events": ["document.processed", "document.uploaded"], + "hook_type": "zapier", + "secret": "optional-signing-secret", + "description": "My Zap for processed documents" + }' +``` + +**Response (201):** +```json +{ + "id": 1, + "target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", + "events": ["document.processed", "document.uploaded"], + "is_active": true, + "hook_type": "zapier", + "description": "My Zap for processed documents", + "has_secret": true +} +``` + +### GET /api/automation/hooks + +List all automation hook subscriptions. + +**Response (200):** +```json +[ + { + "id": 1, + "target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", + "events": ["document.processed", "document.uploaded"], + "is_active": true, + "hook_type": "zapier", + "description": "My Zap for processed documents", + "has_secret": true + } +] +``` + +### DELETE /api/automation/hooks/{hook_id} + +Unsubscribe an automation hook. Zapier calls this when a Zap is turned off or deleted. + +**Response (204):** No content. + +### GET /api/automation/triggers/sample/{event} + +Get sample trigger data for Zapier field mapping. Zapier uses this during Zap setup to discover available fields. + +**Request:** +```bash +curl "http://your-instance/api/automation/triggers/sample/document.processed" \ + -H "Authorization: Bearer de_your_token_here" +``` + +**Response (200):** +```json +[ + { + "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" + } +] +``` + +### POST /api/automation/actions/upload + +Upload a document from an automation platform. This incoming action endpoint allows Zapier or Make.com to push documents into DocuElevate for processing. + +**Request:** +```bash +curl -X POST "http://your-instance/api/automation/actions/upload" \ + -H "Authorization: Bearer de_your_token_here" \ + -F "file=@/path/to/document.pdf" +``` + +**Response (200):** +```json +{ + "status": "accepted", + "filename": "document.pdf", + "task_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +### Zapier-Compatible Payload Format + +When events fire, automation hooks receive a **flat JSON payload** (no nested `data` key) that Zapier and Make.com can easily map: + +```json +{ + "id": "evt_a1b2c3d4e5f67890", + "event": "document.processed", + "timestamp": 1710000060.0, + "document_id": 42, + "filename": "invoice_2024.pdf", + "status": "processed", + "title": "Invoice #1234", + "owner_id": "user@example.com" +} +``` + +The `id` field is unique per event and is used by Zapier for deduplication. If a `secret` was provided during subscription, an `X-Webhook-Signature` header with an HMAC-SHA256 signature is included. + +### Retry Behavior + +Automation hook deliveries follow the same retry policy as regular webhooks: up to 3 retries with exponential backoff (60 s, 300 s, 900 s) and ±20% jitter. + + ## Further Assistance For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md). diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index a84ce393..f977b6be 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -1454,6 +1454,26 @@ Configurations are stored in the database and managed through the API (see [API Webhook URLs, secrets, and subscribed events are configured per-webhook via the `/api/webhooks/` endpoints (admin access required). Each delivery includes an optional HMAC-SHA256 signature for verification and is retried with exponential backoff on failure. +### Automation Hooks (Zapier / Make.com) + +Automation hooks enable integration with external automation platforms such as +[Zapier](https://zapier.com) and [Make.com](https://make.com) (formerly Integromat). + +| **Variable** | **Description** | **Default** | +|----------------------------|------------------------------------------------------------------------------------------------|-------------| +| `AUTOMATION_HOOKS_ENABLED` | Enable or disable Zapier / Make.com automation hook subscriptions and delivery (`True`/`False`) | `True` | + +When enabled, external platforms can: + +- **Subscribe** to DocuElevate events via `POST /api/automation/hooks/subscribe` (outgoing triggers) +- **Send documents** to DocuElevate via `POST /api/automation/actions/upload` (incoming actions) +- **Discover fields** via `GET /api/automation/triggers/sample/{event}` (Zapier field mapping) + +Automation hooks share the same event types as webhooks (`document.uploaded`, `document.processed`, +`document.failed`, `user.signup`, `user.plan_changed`, `user.payment_issue`) and use a flat +Zapier-compatible JSON payload format. See the [API docs](API.md#automation-zapier--makecom) for +endpoint details and payload examples. + ### Backup & Restore DocuElevate automatically backs up the database on a scheduled basis. diff --git a/migrations/versions/040_add_automation_hooks.py b/migrations/versions/040_add_automation_hooks.py new file mode 100644 index 00000000..3d9b778c --- /dev/null +++ b/migrations/versions/040_add_automation_hooks.py @@ -0,0 +1,39 @@ +"""Add automation_hooks table for Zapier / Make.com webhook subscriptions. + +Revision ID: 040_add_automation_hooks +Revises: 039_add_classification_rules +Create Date: 2026-03-09 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "040_add_automation_hooks" +down_revision: Union[str, None] = "039_add_classification_rules" +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") diff --git a/tests/conftest.py b/tests/conftest.py index 26724500..fd110d82 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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, diff --git a/tests/test_automation.py b/tests/test_automation.py new file mode 100644 index 00000000..6f35d8a8 --- /dev/null +++ b/tests/test_automation.py @@ -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 diff --git a/tests/test_convert_to_pdfa.py b/tests/test_convert_to_pdfa.py index 12ee6243..734ca988 100644 --- a/tests/test_convert_to_pdfa.py +++ b/tests/test_convert_to_pdfa.py @@ -38,6 +38,11 @@ class TestConvertPdfToPdfa: assert "pdfa-2" in cmd assert "--quiet" in cmd assert "--invalidate-digital-signatures" in cmd + # 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") assert "/input.pdf" in cmd assert "/output.pdf" in cmd diff --git a/tests/test_upload_handlers.py b/tests/test_upload_handlers.py index bb27addd..192cdf98 100644 --- a/tests/test_upload_handlers.py +++ b/tests/test_upload_handlers.py @@ -769,13 +769,11 @@ class TestUploadRclone: cmd = mock_run.call_args[0][0] assert cmd[0] == "rclone" assert cmd[1] == "copyto" - # Verify the -- end-of-options separator is present and precedes the positional - # file arguments, preventing any file path starting with '-' from being - # misinterpreted as a flag (security hardening regression guard). + # SECURITY: Verify `--` end-of-options separator is present and precedes + # the file path and destination to prevent option/argument injection. assert "--" in cmd - separator_idx = cmd.index("--") - file_idx = cmd.index(fp) - assert separator_idx < file_idx, "'--' must appear before the file_path argument" + 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")