mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 13:26:58 +08:00
refactor(api): isolate native webhook runtime (#45912)
Co-authored-by: Stream <Stream_2@qq.com>
This commit is contained in:
1 parent
af37c00fb3
commit
9fbd9dcd33
22 files changed
+2500
-3931
No files matched your search
@@ -1443,6 +1443,7 @@ source_modules =
|
||||
services.trigger.subscription_binding_service
|
||||
services.async_workflow_service
|
||||
services.trigger.workflow_dispatch_service
|
||||
services.trigger.webhook_service
|
||||
forbidden_modules =
|
||||
controllers
|
||||
extensions
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import logging
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import jsonify, request
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from controllers.common.access_response import webhook_not_found_response
|
||||
from controllers.trigger import bp
|
||||
from controllers.trigger.webhook_request import WebhookRequestParser, WebhookRequestValidationError
|
||||
from core.trigger.debug.event_bus import TriggerDebugEventBus
|
||||
from core.trigger.debug.events import WebhookDebugEvent, build_webhook_pool_key
|
||||
from core.trigger.utils.endpoint import generate_webhook_trigger_endpoint
|
||||
from enums import QuotaType
|
||||
from extensions.ext_application_services import application_services
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.trigger.webhook_service import RawWebhookDataDict, WebhookService
|
||||
from services.trigger.webhook_service import WebhookContext, WebhookNotFoundError, build_webhook_workflow_inputs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,40 +34,19 @@ def _get_quota_exceeded_message(feature: str) -> str:
|
||||
return _QUOTA_EXCEEDED_MESSAGES.get(quota_type, _DEFAULT_QUOTA_EXCEEDED_MESSAGE)
|
||||
|
||||
|
||||
def _prepare_webhook_execution(webhook_id: str, is_debug: bool = False):
|
||||
"""Fetch trigger context, extract request data, and validate payload using unified processing.
|
||||
|
||||
Args:
|
||||
webhook_id: The webhook ID to process
|
||||
is_debug: If True, skip status validation for debug mode
|
||||
"""
|
||||
webhook_trigger, workflow, node_config = WebhookService.get_webhook_trigger_and_workflow(
|
||||
webhook_id, is_debug=is_debug
|
||||
def _prepare_webhook_execution(webhook_id: str, *, debug: bool) -> tuple[WebhookContext, WebhookRequestParser]:
|
||||
services = application_services().workspaces
|
||||
context = services.webhooks.prepare(webhook_id=webhook_id, debug=debug)
|
||||
parser = WebhookRequestParser(
|
||||
request=request,
|
||||
files=services.webhook_files,
|
||||
resolve_actor=lambda webhook_trigger: services.webhooks.resolve_actor(context=webhook_trigger),
|
||||
)
|
||||
|
||||
webhook_data: RawWebhookDataDict
|
||||
try:
|
||||
# Use new unified extraction and validation
|
||||
webhook_data = WebhookService.extract_and_validate_webhook_data(
|
||||
webhook_trigger,
|
||||
node_config,
|
||||
end_users=application_services().app_scoped_end_users.commands,
|
||||
)
|
||||
return webhook_trigger, workflow, node_config, webhook_data, None
|
||||
except ValueError as e:
|
||||
# Provide minimal context for error reporting without risking another parse failure
|
||||
webhook_data = {
|
||||
"method": request.method,
|
||||
"headers": dict(request.headers),
|
||||
"query_params": dict(request.args),
|
||||
"body": {},
|
||||
"files": {},
|
||||
}
|
||||
return webhook_trigger, workflow, node_config, webhook_data, str(e)
|
||||
return context, parser
|
||||
|
||||
|
||||
@bp.route("/webhook/<string:webhook_id>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
||||
def handle_webhook(webhook_id: str):
|
||||
def handle_webhook(webhook_id: str) -> Response | tuple[Response, int]:
|
||||
"""
|
||||
Handle webhook trigger calls.
|
||||
|
||||
@@ -71,22 +54,17 @@ def handle_webhook(webhook_id: str):
|
||||
configured webhook trigger settings.
|
||||
"""
|
||||
try:
|
||||
webhook_trigger, workflow, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id)
|
||||
if error:
|
||||
return jsonify({"error": "Bad Request", "message": error}), 400
|
||||
context, parser = _prepare_webhook_execution(webhook_id, debug=False)
|
||||
try:
|
||||
webhook_data = parser.extract_and_validate_webhook_data(context, context.node_config)
|
||||
except WebhookRequestValidationError as error:
|
||||
return jsonify({"error": "Bad Request", "message": error.public_message}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Process webhook call (send to Celery)
|
||||
WebhookService.trigger_workflow_execution(
|
||||
webhook_trigger,
|
||||
webhook_data,
|
||||
workflow,
|
||||
end_users=application_services().app_scoped_end_users.commands,
|
||||
trigger_status=application_services().apps.trigger_status,
|
||||
workflow_dispatch=application_services().workspaces.workflow_dispatch,
|
||||
)
|
||||
application_services().workspaces.webhooks.execute(context=context, data=webhook_data, actor=parser.actor)
|
||||
|
||||
# Return configured response
|
||||
response_data, status_code = WebhookService.generate_webhook_response(node_config)
|
||||
response_data, status_code = parser.generate_webhook_response(context.node_config)
|
||||
return jsonify(response_data), status_code
|
||||
|
||||
except QuotaExceededError as error:
|
||||
@@ -95,18 +73,20 @@ def handle_webhook(webhook_id: str):
|
||||
"error": "Too Many Requests",
|
||||
"message": _get_quota_exceeded_message(error.feature),
|
||||
}
|
||||
), 429
|
||||
except ValueError:
|
||||
), HTTPStatus.TOO_MANY_REQUESTS
|
||||
except WebhookNotFoundError:
|
||||
return webhook_not_found_response()
|
||||
except RequestEntityTooLarge:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Webhook processing failed for %s", webhook_id)
|
||||
return jsonify({"error": "Internal server error", "message": "An internal error has occurred."}), 500
|
||||
return jsonify(
|
||||
{"error": "Internal server error", "message": "An internal error has occurred."}
|
||||
), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
@bp.route("/webhook-debug/<string:webhook_id>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
||||
def handle_webhook_debug(webhook_id: str):
|
||||
def handle_webhook_debug(webhook_id: str) -> Response | tuple[Response, int]:
|
||||
"""Handle webhook debug calls without triggering production workflow execution.
|
||||
|
||||
The debug webhook endpoint is only for draft inspection flows. It never enqueues
|
||||
@@ -116,22 +96,24 @@ def handle_webhook_debug(webhook_id: str):
|
||||
effectively dropped.
|
||||
"""
|
||||
try:
|
||||
webhook_trigger, _, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id, is_debug=True)
|
||||
if error:
|
||||
return jsonify({"error": "Bad Request", "message": error}), 400
|
||||
context, parser = _prepare_webhook_execution(webhook_id, debug=True)
|
||||
try:
|
||||
webhook_data = parser.extract_and_validate_webhook_data(context, context.node_config)
|
||||
except WebhookRequestValidationError as error:
|
||||
return jsonify({"error": "Bad Request", "message": error.public_message}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
workflow_inputs = WebhookService.build_workflow_inputs(webhook_data)
|
||||
workflow_inputs = build_webhook_workflow_inputs(webhook_data)
|
||||
|
||||
# Generate pool key and dispatch debug event
|
||||
pool_key: str = build_webhook_pool_key(
|
||||
tenant_id=webhook_trigger.tenant_id,
|
||||
app_id=webhook_trigger.app_id,
|
||||
node_id=webhook_trigger.node_id,
|
||||
tenant_id=context.tenant_id,
|
||||
app_id=context.app_id,
|
||||
node_id=context.node_id,
|
||||
)
|
||||
event = WebhookDebugEvent(
|
||||
request_id=f"webhook_debug_{webhook_trigger.webhook_id}_{int(time.time() * 1000)}",
|
||||
request_id=f"webhook_debug_{context.webhook_id}_{int(time.time() * 1000)}",
|
||||
timestamp=int(time.time()),
|
||||
node_id=webhook_trigger.node_id,
|
||||
node_id=context.node_id,
|
||||
payload={
|
||||
"inputs": workflow_inputs,
|
||||
"webhook_data": webhook_data,
|
||||
@@ -139,17 +121,17 @@ def handle_webhook_debug(webhook_id: str):
|
||||
},
|
||||
)
|
||||
dispatch_count = TriggerDebugEventBus.dispatch(
|
||||
tenant_id=webhook_trigger.tenant_id,
|
||||
tenant_id=context.tenant_id,
|
||||
event=event,
|
||||
pool_key=pool_key,
|
||||
)
|
||||
if dispatch_count == 0:
|
||||
logger.warning(
|
||||
"Webhook debug request dropped without an active listener for webhook %s (tenant=%s, app=%s, node=%s)",
|
||||
webhook_trigger.webhook_id,
|
||||
webhook_trigger.tenant_id,
|
||||
webhook_trigger.app_id,
|
||||
webhook_trigger.node_id,
|
||||
context.webhook_id,
|
||||
context.tenant_id,
|
||||
context.app_id,
|
||||
context.node_id,
|
||||
)
|
||||
return (
|
||||
jsonify(
|
||||
@@ -159,18 +141,20 @@ def handle_webhook_debug(webhook_id: str):
|
||||
"The webhook debug URL only works while the Variable Inspector is listening. "
|
||||
"Use the published webhook URL to execute the workflow in Celery."
|
||||
),
|
||||
"execution_url": webhook_trigger.webhook_url,
|
||||
"execution_url": generate_webhook_trigger_endpoint(context.webhook_id),
|
||||
}
|
||||
),
|
||||
409,
|
||||
HTTPStatus.CONFLICT,
|
||||
)
|
||||
response_data, status_code = WebhookService.generate_webhook_response(node_config)
|
||||
response_data, status_code = parser.generate_webhook_response(context.node_config)
|
||||
return jsonify(response_data), status_code
|
||||
|
||||
except ValueError:
|
||||
except WebhookNotFoundError:
|
||||
return webhook_not_found_response()
|
||||
except RequestEntityTooLarge:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Webhook debug processing failed for %s", webhook_id)
|
||||
return jsonify({"error": "Internal server error", "message": "An internal error has occurred."}), 500
|
||||
return jsonify(
|
||||
{"error": "Internal server error", "message": "An internal error has occurred."}
|
||||
), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
@@ -0,0 +1,673 @@
|
||||
"""HTTP parsing and response formatting for native webhook triggers."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import mimetypes
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
import orjson
|
||||
from flask import Request
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
|
||||
from configs import dify_config
|
||||
from core.workflow.nodes.trigger_webhook.entities import (
|
||||
ContentType,
|
||||
WebhookBodyParameter,
|
||||
WebhookData,
|
||||
WebhookParameter,
|
||||
)
|
||||
from graphon.entities.graph_config import NodeConfigDict
|
||||
from graphon.file import File
|
||||
from graphon.variables.types import ArrayValidation, SegmentType
|
||||
from services.trigger.webhook_service import RawWebhookDataDict, WebhookContext, WebhookFiles
|
||||
from services.workflow.entities import TriggerActor
|
||||
|
||||
try:
|
||||
import magic
|
||||
except ImportError:
|
||||
magic = None # type: ignore[assignment]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValidationResultDict(TypedDict):
|
||||
valid: bool
|
||||
error: NotRequired[str]
|
||||
|
||||
|
||||
class WebhookRequestValidationError(ValueError):
|
||||
"""Expose only messages intentionally generated for invalid webhook requests."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.public_message = message
|
||||
|
||||
|
||||
class WebhookRequestParser:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request: Request,
|
||||
files: WebhookFiles,
|
||||
resolve_actor: Callable[[WebhookContext], TriggerActor] | None = None,
|
||||
) -> None:
|
||||
self._request = request
|
||||
self._files = files
|
||||
self._resolve_actor = resolve_actor
|
||||
self.actor: TriggerActor | None = None
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_key(key: str) -> str:
|
||||
if not isinstance(key, str):
|
||||
return key
|
||||
return key.replace("-", "_")
|
||||
|
||||
def extract_and_validate_webhook_data(
|
||||
self, webhook_trigger: WebhookContext, node_config: NodeConfigDict
|
||||
) -> RawWebhookDataDict:
|
||||
"""Extract and validate webhook data in a single unified process.
|
||||
|
||||
Args:
|
||||
webhook_trigger: The webhook trigger object containing metadata
|
||||
node_config: The node configuration containing validation rules
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Processed and validated webhook data with correct types
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails (HTTP method mismatch, missing required fields, type errors)
|
||||
"""
|
||||
# Extract raw data first
|
||||
raw_data = self.extract_webhook_data(webhook_trigger)
|
||||
|
||||
# Validate HTTP metadata (method, content-type)
|
||||
node_data = WebhookData.model_validate(node_config["data"], from_attributes=True)
|
||||
validation_result = self._validate_http_metadata(raw_data, node_data)
|
||||
if not validation_result["valid"]:
|
||||
raise WebhookRequestValidationError(validation_result.get("error", "Validation failed"))
|
||||
|
||||
# Process and validate data according to configuration
|
||||
processed_data = self._process_and_validate_data(raw_data, node_data)
|
||||
|
||||
return processed_data
|
||||
|
||||
def extract_webhook_data(self, webhook_trigger: WebhookContext) -> RawWebhookDataDict:
|
||||
"""Extract raw data from incoming webhook request without type conversion.
|
||||
|
||||
Args:
|
||||
webhook_trigger: The webhook trigger object for file processing context
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Raw webhook data containing:
|
||||
- method: HTTP method
|
||||
- headers: Request headers
|
||||
- query_params: Query parameters as strings
|
||||
- body: Request body (varies by content type; JSON parsing errors raise ValueError)
|
||||
- files: Uploaded files (if any)
|
||||
"""
|
||||
self._validate_content_length()
|
||||
|
||||
data: RawWebhookDataDict = {
|
||||
"method": self._request.method,
|
||||
"headers": dict(self._request.headers),
|
||||
"query_params": dict(self._request.args),
|
||||
"body": {},
|
||||
"files": {},
|
||||
}
|
||||
|
||||
# Extract and normalize content type
|
||||
content_type = self._extract_content_type(dict(self._request.headers))
|
||||
|
||||
# Route to appropriate extractor based on content type
|
||||
extractors: dict[str, Callable[[], tuple[dict[str, Any], dict[str, Any]]]] = {
|
||||
"application/json": self._extract_json_body,
|
||||
"application/x-www-form-urlencoded": self._extract_form_body,
|
||||
"multipart/form-data": lambda: self._extract_multipart_body(webhook_trigger),
|
||||
"application/octet-stream": lambda: self._extract_octet_stream_body(webhook_trigger),
|
||||
"text/plain": self._extract_text_body,
|
||||
}
|
||||
|
||||
extractor = extractors.get(content_type)
|
||||
if not extractor:
|
||||
# Default to text/plain for unknown content types
|
||||
logger.warning("Unknown Content-Type: %s, treating as text/plain", content_type)
|
||||
extractor = self._extract_text_body
|
||||
|
||||
# Extract body and files
|
||||
body_data, files_data = extractor()
|
||||
data["body"] = body_data
|
||||
data["files"] = files_data
|
||||
|
||||
return data
|
||||
|
||||
def _process_and_validate_data(self, raw_data: RawWebhookDataDict, node_data: WebhookData) -> RawWebhookDataDict:
|
||||
"""Process and validate webhook data according to node configuration.
|
||||
|
||||
Args:
|
||||
raw_data: Raw webhook data from extraction
|
||||
node_data: Node configuration containing validation and type rules
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Processed data with validated types
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails or required fields are missing
|
||||
"""
|
||||
result = raw_data.copy()
|
||||
|
||||
# Validate and process headers
|
||||
self._validate_required_headers(raw_data["headers"], node_data.headers)
|
||||
|
||||
# Process query parameters with type conversion and validation
|
||||
result["query_params"] = self._process_parameters(raw_data["query_params"], node_data.params, is_form_data=True)
|
||||
|
||||
# Process body parameters based on content type
|
||||
result["body"] = self._process_body_parameters(raw_data["body"], node_data.body, node_data.content_type)
|
||||
|
||||
return result
|
||||
|
||||
def _validate_content_length(self) -> None:
|
||||
"""Validate request content length against maximum allowed size."""
|
||||
content_length = self._request.content_length
|
||||
if content_length and content_length > dify_config.WEBHOOK_REQUEST_BODY_MAX_SIZE:
|
||||
raise RequestEntityTooLarge(
|
||||
f"Webhook request too large: {content_length} bytes exceeds maximum allowed size "
|
||||
f"of {dify_config.WEBHOOK_REQUEST_BODY_MAX_SIZE} bytes"
|
||||
)
|
||||
|
||||
def _extract_json_body(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Extract JSON body from self._request.
|
||||
|
||||
Returns:
|
||||
tuple: (body_data, files_data) where:
|
||||
- body_data: Parsed JSON content
|
||||
- files_data: Empty dict (JSON requests don't contain files)
|
||||
|
||||
Raises:
|
||||
ValueError: If JSON parsing fails
|
||||
"""
|
||||
raw_body = self._request.get_data(cache=True)
|
||||
if not raw_body or raw_body.strip() == b"":
|
||||
return {}, {}
|
||||
|
||||
try:
|
||||
body = orjson.loads(raw_body)
|
||||
except orjson.JSONDecodeError as exc:
|
||||
logger.warning("Failed to parse JSON body: %s", exc)
|
||||
message = f"{exc.msg}: line {exc.lineno} column {exc.colno} (char {exc.pos})"
|
||||
raise WebhookRequestValidationError(f"Invalid JSON body: {message}") from exc
|
||||
return body, {}
|
||||
|
||||
def _extract_form_body(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Extract form-urlencoded body from self._request.
|
||||
|
||||
Returns:
|
||||
tuple: (body_data, files_data) where:
|
||||
- body_data: Form data as key-value pairs
|
||||
- files_data: Empty dict (form-urlencoded requests don't contain files)
|
||||
"""
|
||||
return dict(self._request.form), {}
|
||||
|
||||
def _extract_multipart_body(self, webhook_trigger: WebhookContext) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Extract multipart/form-data body and files from self._request.
|
||||
|
||||
Args:
|
||||
webhook_trigger: Webhook trigger for file processing context
|
||||
|
||||
Returns:
|
||||
tuple: (body_data, files_data) where:
|
||||
- body_data: Form data as key-value pairs
|
||||
- files_data: Processed file objects indexed by field name
|
||||
"""
|
||||
body = dict(self._request.form)
|
||||
files = self._process_file_uploads(self._request.files, webhook_trigger) if self._request.files else {}
|
||||
return body, files
|
||||
|
||||
def _extract_octet_stream_body(self, webhook_trigger: WebhookContext) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Extract binary data as file from self._request.
|
||||
|
||||
Args:
|
||||
webhook_trigger: Webhook trigger for file processing context
|
||||
|
||||
Returns:
|
||||
tuple: (body_data, files_data) where:
|
||||
- body_data: Dict with 'raw' key containing file object or None
|
||||
- files_data: Empty dict
|
||||
"""
|
||||
try:
|
||||
file_content = self._request.get_data()
|
||||
if file_content:
|
||||
mimetype = self._detect_binary_mimetype(file_content)
|
||||
file_obj = self._create_file_from_binary(file_content, mimetype, webhook_trigger)
|
||||
return {"raw": file_obj.to_dict()}, {}
|
||||
else:
|
||||
return {"raw": None}, {}
|
||||
except Exception:
|
||||
logger.exception("Failed to process octet-stream data")
|
||||
return {"raw": None}, {}
|
||||
|
||||
def _extract_text_body(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Extract text/plain body from self._request.
|
||||
|
||||
Returns:
|
||||
tuple: (body_data, files_data) where:
|
||||
- body_data: Dict with 'raw' key containing text content
|
||||
- files_data: Empty dict (text requests don't contain files)
|
||||
"""
|
||||
try:
|
||||
body = {"raw": self._request.get_data(as_text=True)}
|
||||
except Exception:
|
||||
logger.warning("Failed to extract text body")
|
||||
body = {"raw": ""}
|
||||
return body, {}
|
||||
|
||||
@staticmethod
|
||||
def _detect_binary_mimetype(file_content: bytes) -> str:
|
||||
"""Guess MIME type for binary payloads using python-magic when available."""
|
||||
if magic is not None:
|
||||
try:
|
||||
detected = magic.from_buffer(file_content[:1024], mime=True)
|
||||
if detected:
|
||||
return detected
|
||||
except Exception:
|
||||
logger.debug("python-magic detection failed for octet-stream payload")
|
||||
return "application/octet-stream"
|
||||
|
||||
def _process_file_uploads(
|
||||
self, files: Mapping[str, FileStorage], webhook_trigger: WebhookContext
|
||||
) -> dict[str, Any]:
|
||||
"""Process file uploads using ToolFileManager.
|
||||
|
||||
Args:
|
||||
files: Flask request files object containing uploaded files
|
||||
webhook_trigger: Webhook trigger for tenant and user context
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Processed file objects indexed by field name
|
||||
"""
|
||||
processed_files = {}
|
||||
|
||||
for name, file in files.items():
|
||||
if file and file.filename:
|
||||
try:
|
||||
file_content = file.stream.read()
|
||||
mimetype = file.content_type or mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
|
||||
file_obj = self._create_file_from_binary(file_content, mimetype, webhook_trigger)
|
||||
processed_files[name] = file_obj.to_dict()
|
||||
except Exception:
|
||||
logger.exception("Failed to process file upload '%s'", name)
|
||||
# Continue processing other files
|
||||
|
||||
return processed_files
|
||||
|
||||
def _create_file_from_binary(self, file_content: bytes, mimetype: str, webhook_trigger: WebhookContext) -> File:
|
||||
if self.actor is None and self._resolve_actor is not None:
|
||||
self.actor = self._resolve_actor(webhook_trigger)
|
||||
user_id = self.actor.id if self.actor is not None else webhook_trigger.created_by
|
||||
return self._files.create(
|
||||
tenant_id=webhook_trigger.tenant_id,
|
||||
user_id=user_id,
|
||||
content=file_content,
|
||||
mimetype=mimetype,
|
||||
)
|
||||
|
||||
def _process_parameters(
|
||||
self,
|
||||
raw_params: dict[str, str],
|
||||
param_configs: Sequence[WebhookParameter],
|
||||
*,
|
||||
is_form_data: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Process parameters with unified validation and type conversion.
|
||||
|
||||
Args:
|
||||
raw_params: Raw parameter values as strings
|
||||
param_configs: List of parameter configuration dictionaries
|
||||
is_form_data: Whether the parameters are from form data (requiring string conversion)
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Processed parameters with validated types
|
||||
|
||||
Raises:
|
||||
ValueError: If required parameters are missing or validation fails
|
||||
"""
|
||||
processed = {}
|
||||
configured_params = {config.name: config for config in param_configs}
|
||||
|
||||
# Process configured parameters
|
||||
for param_config in param_configs:
|
||||
name = param_config.name
|
||||
param_type = param_config.type
|
||||
required = param_config.required
|
||||
|
||||
# Check required parameters
|
||||
if required and name not in raw_params:
|
||||
raise WebhookRequestValidationError(f"Required parameter missing: {name}")
|
||||
|
||||
if name in raw_params:
|
||||
raw_value = raw_params[name]
|
||||
processed[name] = self._validate_and_convert_value(name, raw_value, param_type, is_form_data)
|
||||
|
||||
# Include unconfigured parameters as strings
|
||||
for name, value in raw_params.items():
|
||||
if name not in configured_params:
|
||||
processed[name] = value
|
||||
|
||||
return processed
|
||||
|
||||
def _process_body_parameters(
|
||||
self,
|
||||
raw_body: dict[str, Any],
|
||||
body_configs: Sequence[WebhookBodyParameter],
|
||||
content_type: ContentType,
|
||||
) -> dict[str, Any]:
|
||||
"""Process body parameters based on content type and configuration.
|
||||
|
||||
Args:
|
||||
raw_body: Raw body data from request
|
||||
body_configs: List of body parameter configuration dictionaries
|
||||
content_type: The request content type
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Processed body parameters with validated types
|
||||
|
||||
Raises:
|
||||
ValueError: If required body parameters are missing or validation fails
|
||||
"""
|
||||
match content_type:
|
||||
case ContentType.TEXT | ContentType.BINARY:
|
||||
# For text/plain and octet-stream, validate required content exists
|
||||
if body_configs and any(config.required for config in body_configs):
|
||||
raw_content = raw_body.get("raw")
|
||||
if not raw_content:
|
||||
raise WebhookRequestValidationError(f"Required body content missing for {content_type} request")
|
||||
return raw_body
|
||||
case _:
|
||||
pass
|
||||
|
||||
# For structured data (JSON, form-data, etc.)
|
||||
processed = {}
|
||||
configured_params: dict[str, WebhookBodyParameter] = {config.name: config for config in body_configs}
|
||||
|
||||
for body_config in body_configs:
|
||||
name = body_config.name
|
||||
param_type = body_config.type
|
||||
required = body_config.required
|
||||
|
||||
# Multipart file validation is handled separately in the extraction phase.
|
||||
if not (param_type == SegmentType.FILE and content_type == ContentType.FORM_DATA):
|
||||
if required and name not in raw_body:
|
||||
raise WebhookRequestValidationError(f"Required body parameter missing: {name}")
|
||||
|
||||
if name in raw_body:
|
||||
raw_value = raw_body[name]
|
||||
is_form_data = content_type in [ContentType.FORM_URLENCODED, ContentType.FORM_DATA]
|
||||
processed[name] = self._validate_and_convert_value(name, raw_value, param_type, is_form_data)
|
||||
|
||||
# Include unconfigured parameters
|
||||
for name, value in raw_body.items():
|
||||
if name not in configured_params:
|
||||
processed[name] = value
|
||||
|
||||
return processed
|
||||
|
||||
def _validate_and_convert_value(
|
||||
self, param_name: str, value: Any, param_type: SegmentType | str, is_form_data: bool
|
||||
) -> Any:
|
||||
"""Unified validation and type conversion for parameter values.
|
||||
|
||||
Args:
|
||||
param_name: Name of the parameter for error reporting
|
||||
value: The value to validate and convert
|
||||
param_type: The expected parameter type (SegmentType)
|
||||
is_form_data: Whether the value is from form data (requiring string conversion)
|
||||
|
||||
Returns:
|
||||
Any: The validated and converted value
|
||||
|
||||
Raises:
|
||||
ValueError: If validation or conversion fails. The original validation
|
||||
error is preserved as ``__cause__`` for debugging.
|
||||
"""
|
||||
try:
|
||||
if is_form_data:
|
||||
# Form data comes as strings and needs conversion
|
||||
return self._convert_form_value(param_name, value, param_type)
|
||||
else:
|
||||
# JSON data should already be in correct types, just validate
|
||||
return self._validate_json_value(param_name, value, param_type)
|
||||
except WebhookRequestValidationError as error:
|
||||
raise WebhookRequestValidationError(
|
||||
f"Parameter '{param_name}' validation failed: {error.public_message}"
|
||||
) from error
|
||||
|
||||
def _convert_form_value(self, param_name: str, value: str, param_type: SegmentType | str) -> Any:
|
||||
"""Convert form data string values to specified types.
|
||||
|
||||
Args:
|
||||
param_name: Name of the parameter for error reporting
|
||||
value: The string value to convert
|
||||
param_type: The target type to convert to (SegmentType)
|
||||
|
||||
Returns:
|
||||
Any: The converted value in the appropriate type
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted to the specified type
|
||||
"""
|
||||
match param_type:
|
||||
case SegmentType.STRING:
|
||||
return value
|
||||
case SegmentType.NUMBER:
|
||||
if not self._can_convert_to_number(value):
|
||||
raise WebhookRequestValidationError(f"Cannot convert '{value}' to number")
|
||||
try:
|
||||
# Parse integers exactly before float conversion can round them.
|
||||
return int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
numeric_value = float(value)
|
||||
return int(numeric_value) if numeric_value.is_integer() else numeric_value
|
||||
case SegmentType.BOOLEAN:
|
||||
lower_value = value.lower()
|
||||
bool_map = {"true": True, "false": False, "1": True, "0": False, "yes": True, "no": False}
|
||||
if lower_value not in bool_map:
|
||||
raise WebhookRequestValidationError(f"Cannot convert '{value}' to boolean")
|
||||
return bool_map[lower_value]
|
||||
case (
|
||||
SegmentType.OBJECT
|
||||
| SegmentType.FILE
|
||||
| SegmentType.ARRAY_ANY
|
||||
| SegmentType.ARRAY_STRING
|
||||
| SegmentType.ARRAY_NUMBER
|
||||
| SegmentType.ARRAY_OBJECT
|
||||
| SegmentType.ARRAY_FILE
|
||||
| SegmentType.ARRAY_BOOLEAN
|
||||
| SegmentType.SECRET
|
||||
| SegmentType.INTEGER
|
||||
| SegmentType.FLOAT
|
||||
| SegmentType.NONE
|
||||
| SegmentType.GROUP
|
||||
):
|
||||
raise WebhookRequestValidationError(
|
||||
f"Unsupported type '{param_type}' for form data parameter '{param_name}'"
|
||||
)
|
||||
case _:
|
||||
raise WebhookRequestValidationError(
|
||||
f"Unsupported type '{param_type}' for form data parameter '{param_name}'"
|
||||
)
|
||||
|
||||
def _validate_json_value(self, param_name: str, value: Any, param_type: SegmentType | str) -> Any:
|
||||
"""Validate JSON values against expected types.
|
||||
|
||||
Args:
|
||||
param_name: Name of the parameter for error reporting
|
||||
value: The value to validate
|
||||
param_type: The expected parameter type (SegmentType)
|
||||
|
||||
Returns:
|
||||
Any: The validated value (unchanged if valid)
|
||||
|
||||
Raises:
|
||||
ValueError: If the value type doesn't match the expected type
|
||||
"""
|
||||
param_type_enum = self._coerce_segment_type(param_type, param_name=param_name)
|
||||
if param_type_enum is None:
|
||||
return value
|
||||
|
||||
if not param_type_enum.is_valid(value, array_validation=ArrayValidation.ALL) or self._is_boolean_for_number(
|
||||
value, param_type_enum
|
||||
):
|
||||
actual_type = type(value).__name__
|
||||
expected_type = self._expected_type_label(param_type_enum)
|
||||
raise WebhookRequestValidationError(f"Expected {expected_type}, got {actual_type}")
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _is_boolean_for_number(value: Any, param_type: SegmentType) -> bool:
|
||||
match param_type:
|
||||
case SegmentType.NUMBER | SegmentType.INTEGER | SegmentType.FLOAT:
|
||||
return isinstance(value, bool)
|
||||
case SegmentType.ARRAY_NUMBER:
|
||||
return isinstance(value, list) and any(isinstance(item, bool) for item in value)
|
||||
case _:
|
||||
return False
|
||||
|
||||
def _coerce_segment_type(self, param_type: SegmentType | str, *, param_name: str) -> SegmentType | None:
|
||||
if isinstance(param_type, SegmentType):
|
||||
return param_type
|
||||
try:
|
||||
return SegmentType(param_type)
|
||||
except Exception:
|
||||
logger.warning("Unknown parameter type: %s for parameter %s", param_type, param_name)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _expected_type_label(param_type: SegmentType) -> str:
|
||||
match param_type:
|
||||
case SegmentType.ARRAY_STRING:
|
||||
return "array of strings"
|
||||
case SegmentType.ARRAY_NUMBER:
|
||||
return "array of numbers"
|
||||
case SegmentType.ARRAY_BOOLEAN:
|
||||
return "array of booleans"
|
||||
case SegmentType.ARRAY_OBJECT:
|
||||
return "array of objects"
|
||||
case _:
|
||||
return param_type.value
|
||||
|
||||
def _validate_required_headers(self, headers: dict[str, Any], header_configs: Sequence[WebhookParameter]) -> None:
|
||||
"""Validate required headers are present.
|
||||
|
||||
Args:
|
||||
headers: Request headers dictionary
|
||||
header_configs: List of header configuration dictionaries
|
||||
|
||||
Raises:
|
||||
ValueError: If required headers are missing
|
||||
"""
|
||||
headers_lower = {k.lower(): v for k, v in headers.items()}
|
||||
headers_sanitized = {self._sanitize_key(k).lower(): v for k, v in headers.items()}
|
||||
for header_config in header_configs:
|
||||
if header_config.required:
|
||||
header_name = header_config.name
|
||||
sanitized_name = self._sanitize_key(header_name).lower()
|
||||
if header_name.lower() not in headers_lower and sanitized_name not in headers_sanitized:
|
||||
raise WebhookRequestValidationError(f"Required header missing: {header_name}")
|
||||
|
||||
def _validate_http_metadata(self, webhook_data: RawWebhookDataDict, node_data: WebhookData) -> ValidationResultDict:
|
||||
"""Validate HTTP method and content-type.
|
||||
|
||||
Args:
|
||||
webhook_data: Extracted webhook data containing method and headers
|
||||
node_data: Node configuration containing expected method and content-type
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Validation result with 'valid' key and optional 'error' key
|
||||
"""
|
||||
# Validate HTTP method
|
||||
configured_method = node_data.method.value.upper()
|
||||
request_method = webhook_data["method"].upper()
|
||||
if configured_method != request_method:
|
||||
return self._validation_error(f"HTTP method mismatch. Expected {configured_method}, got {request_method}")
|
||||
|
||||
# Validate Content-type
|
||||
configured_content_type = node_data.content_type.value.lower()
|
||||
request_content_type = self._extract_content_type(webhook_data["headers"])
|
||||
|
||||
if configured_content_type != request_content_type:
|
||||
return self._validation_error(
|
||||
f"Content-type mismatch. Expected {configured_content_type}, got {request_content_type}"
|
||||
)
|
||||
|
||||
return {"valid": True}
|
||||
|
||||
def _extract_content_type(self, headers: dict[str, Any]) -> str:
|
||||
"""Extract and normalize content-type from headers.
|
||||
|
||||
Args:
|
||||
headers: Request headers dictionary
|
||||
|
||||
Returns:
|
||||
str: Normalized content-type (main type without parameters)
|
||||
"""
|
||||
content_type = headers.get("Content-Type", "").lower()
|
||||
if not content_type:
|
||||
content_type = headers.get("content-type", "application/json").lower()
|
||||
# Extract the main content type (ignore parameters like boundary)
|
||||
return content_type.split(";")[0].strip()
|
||||
|
||||
def _validation_error(self, error_message: str) -> ValidationResultDict:
|
||||
"""Create a standard validation error response.
|
||||
|
||||
Args:
|
||||
error_message: The error message to include
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Validation error response with 'valid' and 'error' keys
|
||||
"""
|
||||
return {"valid": False, "error": error_message}
|
||||
|
||||
def _can_convert_to_number(self, value: str) -> bool:
|
||||
"""Check if a string can be converted to a finite number."""
|
||||
try:
|
||||
return math.isfinite(float(value))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def generate_webhook_response(self, node_config: NodeConfigDict) -> tuple[Any, int]:
|
||||
"""Generate HTTP response based on node configuration.
|
||||
|
||||
Args:
|
||||
node_config: Node configuration containing response settings
|
||||
|
||||
Returns:
|
||||
tuple[dict[str, Any], int]: Response data and HTTP status code
|
||||
"""
|
||||
node_data = WebhookData.model_validate(node_config["data"], from_attributes=True)
|
||||
|
||||
# Get configured status code and response body
|
||||
status_code = node_data.status_code
|
||||
response_body = node_data.response_body
|
||||
|
||||
# Parse response body as JSON if it's valid JSON, otherwise return as text
|
||||
try:
|
||||
if response_body:
|
||||
try:
|
||||
response_data = (
|
||||
json.loads(response_body)
|
||||
if response_body.strip().startswith(("{", "["))
|
||||
else {"message": response_body}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
response_data = {"message": response_body}
|
||||
else:
|
||||
response_data = {"status": "success", "message": "Webhook processed successfully"}
|
||||
except Exception:
|
||||
response_data = {"message": response_body or "Webhook processed successfully"}
|
||||
|
||||
return response_data, status_code
|
||||
@@ -139,13 +139,13 @@ class WebhookTriggerDebugEventPoller(TriggerDebugEventPoller):
|
||||
if not webhook_event:
|
||||
return None
|
||||
|
||||
from services.trigger.webhook_service import WebhookService
|
||||
from services.trigger.webhook_service import build_webhook_workflow_inputs
|
||||
|
||||
payload = webhook_event.payload or {}
|
||||
workflow_inputs = payload.get("inputs")
|
||||
if workflow_inputs is None:
|
||||
webhook_data = payload.get("webhook_data", {})
|
||||
workflow_inputs = WebhookService.build_workflow_inputs(webhook_data)
|
||||
workflow_inputs = build_webhook_workflow_inputs(webhook_data)
|
||||
|
||||
workflow_args: Mapping[str, Any] = {
|
||||
"inputs": workflow_inputs or {},
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE
|
||||
from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated
|
||||
from extensions.ext_application_services import application_services
|
||||
from models.model import App, AppMode
|
||||
from models.workflow import Workflow
|
||||
from services.trigger.webhook_service import WebhookService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app_draft_workflow_was_synced.connect
|
||||
def handle(sender, synced_draft_workflow: Workflow, **kwargs):
|
||||
"""
|
||||
While creating a workflow or updating a workflow, we may need to sync
|
||||
its webhook relationships in DB.
|
||||
"""
|
||||
app: App = sender
|
||||
if app.mode != AppMode.WORKFLOW.value:
|
||||
# only handle workflow app, chatflow is not supported yet
|
||||
def handle(sender: App, synced_draft_workflow: Workflow, **kwargs: object) -> None:
|
||||
if sender.mode != AppMode.WORKFLOW.value:
|
||||
return
|
||||
|
||||
WebhookService.sync_webhook_relationships(app, synced_draft_workflow, remove_stale=False)
|
||||
application_services().workspaces.webhook_draft_bindings.sync(
|
||||
tenant_id=sender.tenant_id,
|
||||
app_id=sender.id,
|
||||
created_by=sender.created_by,
|
||||
node_ids=[node_id for node_id, _ in synced_draft_workflow.walk_nodes(TRIGGER_WEBHOOK_NODE_TYPE)],
|
||||
)
|
||||
|
||||
|
||||
@app_published_workflow_was_updated.connect
|
||||
def handle_published(sender, published_workflow: Workflow, **kwargs):
|
||||
"""Remove stale webhook relationships after their node deletion is published."""
|
||||
app: App = sender
|
||||
if app.mode != AppMode.WORKFLOW.value:
|
||||
def handle_published(sender: App, published_workflow: Workflow, *, session: Session, **kwargs: object) -> None:
|
||||
if sender.mode != AppMode.WORKFLOW.value:
|
||||
return
|
||||
|
||||
WebhookService.sync_webhook_relationships(app, published_workflow, remove_stale=True)
|
||||
application_services().workspaces.create_webhook_publication_service(session).sync(
|
||||
tenant_id=sender.tenant_id,
|
||||
app_id=sender.id,
|
||||
created_by=sender.created_by,
|
||||
node_ids=[node_id for node_id, _ in published_workflow.walk_nodes(TRIGGER_WEBHOOK_NODE_TYPE)],
|
||||
)
|
||||
@@ -7,7 +7,9 @@ from functools import partial
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from core.trigger.trigger_manager import TriggerManager
|
||||
from core.trigger.utils.encryption import delete_cache_for_subscription
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
@@ -16,6 +18,7 @@ from repositories.app.console_repository import ConsoleAppRepository
|
||||
from repositories.credentials.query_repository import CredentialQueryRepository
|
||||
from repositories.knowledge.dataset_repository import SQLAlchemyDatasetRepository
|
||||
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerDispatchRepository
|
||||
from repositories.trigger.app_repository import SQLAlchemyAppTriggerRepository
|
||||
from repositories.trigger.oauth_client_repository import TriggerOAuthClientRepository
|
||||
from repositories.trigger.subscription_builder_repository import RedisTriggerSubscriptionBuilderRepository
|
||||
from repositories.trigger.subscription_repository import (
|
||||
@@ -60,6 +63,8 @@ from services.trigger.subscription_renewal_service import TriggerSubscriptionRen
|
||||
from services.trigger.subscription_service import TriggerSubscriptionService
|
||||
from services.trigger.trigger_request_service import TriggerHttpRequestCachingService
|
||||
from services.trigger.trigger_subscription_builder_service import TriggerSubscriptionBuilderService
|
||||
from services.trigger.webhook_gateway import ToolWebhookFiles
|
||||
from services.trigger.webhook_service import WebhookBindingService, WebhookFiles, WebhookService
|
||||
from services.trigger.workflow_dispatch_service import PluginTriggerWorkflowDispatchService, TriggerEndUserProvisioner
|
||||
from services.workflow.queue_dispatcher import CeleryWorkflowQueuePublisher
|
||||
from services.workspace.gateways import (
|
||||
@@ -109,6 +114,10 @@ class WorkspaceServices:
|
||||
trigger_endpoints: TriggerEndpointService
|
||||
trigger_events: TriggerEventService
|
||||
workflow_dispatch: AsyncWorkflowService
|
||||
webhooks: WebhookService
|
||||
webhook_files: WebhookFiles
|
||||
webhook_draft_bindings: WebhookBindingService
|
||||
create_webhook_publication_service: Callable[[Session], WebhookBindingService]
|
||||
trigger_workflow_dispatch: PluginTriggerWorkflowDispatchService
|
||||
trigger_oauth_clients: TriggerOAuthClientService
|
||||
trigger_oauth: TriggerOAuthService
|
||||
@@ -201,6 +210,7 @@ def build_workspace_services(
|
||||
workflow_dispatch = AsyncWorkflowService(
|
||||
workflows=workflow_queries, logs=execution_logs, queues=queues, reserve_quota=QuotaService.reserve
|
||||
)
|
||||
app_triggers = SQLAlchemyAppTriggerRepository(session_factory=database_client)
|
||||
return WorkspaceServices(
|
||||
queries=WorkspaceQueryService(workspaces=workspaces, plans=DeploymentWorkspacePlanGateway()),
|
||||
management=WorkspaceService(
|
||||
@@ -248,6 +258,19 @@ def build_workspace_services(
|
||||
),
|
||||
trigger_events=trigger_events,
|
||||
workflow_dispatch=workflow_dispatch,
|
||||
webhooks=WebhookService(
|
||||
webhooks=app_triggers,
|
||||
end_users=trigger_end_users,
|
||||
dispatch=workflow_dispatch,
|
||||
reserve_quota=QuotaService.reserve,
|
||||
mark_rate_limited=mark_rate_limited,
|
||||
),
|
||||
webhook_files=ToolWebhookFiles(manager=ToolFileManager(), access=DatabaseFileAccessController()),
|
||||
webhook_draft_bindings=WebhookBindingService(sync_bindings=app_triggers.sync_draft_webhooks),
|
||||
# Publish binding changes atomically with the workflow that owns them.
|
||||
create_webhook_publication_service=lambda session: WebhookBindingService(
|
||||
sync_bindings=partial(app_triggers.sync_webhooks, session=session, remove_stale=True)
|
||||
),
|
||||
trigger_workflow_dispatch=PluginTriggerWorkflowDispatchService(
|
||||
workflows=workflow_queries,
|
||||
logs=execution_logs,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"""App trigger management with workspace-scoped, bounded database transactions."""
|
||||
|
||||
import secrets
|
||||
from collections.abc import Sequence
|
||||
from copy import deepcopy
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from models.enums import AppTriggerStatus
|
||||
from models.model import AppMode
|
||||
from models.enums import AppTriggerStatus, AppTriggerType
|
||||
from models.model import App, AppMode
|
||||
from models.trigger import AppTrigger, WorkflowWebhookTrigger
|
||||
from models.workflow import NodeNotFoundError, Workflow
|
||||
from repositories.app.console_repository import require_console_app
|
||||
from services.app.console_service import ConsoleAppNotFoundError
|
||||
from services.trigger.app_trigger_store import (
|
||||
@@ -20,6 +23,7 @@ from services.trigger.app_trigger_store import (
|
||||
WebhookTriggerNotFoundError,
|
||||
WebhookTriggerRecord,
|
||||
)
|
||||
from services.trigger.webhook_service import UnavailableWebhook, WebhookContext, WebhookNotFoundError
|
||||
|
||||
|
||||
def _require_workflow_app(session: Session, context: RequestContext, app_id: str) -> None:
|
||||
@@ -45,6 +49,123 @@ class SQLAlchemyAppTriggerRepository(AppTriggerStore):
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
def get_webhook_context(self, *, webhook_id: str, debug: bool) -> WebhookContext | UnavailableWebhook:
|
||||
with self._session_factory() as session:
|
||||
webhook = session.scalar(
|
||||
select(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.webhook_id == webhook_id).limit(1)
|
||||
)
|
||||
if webhook is None:
|
||||
raise WebhookNotFoundError(f"Webhook not found: {webhook_id}")
|
||||
status = None
|
||||
if debug:
|
||||
workflow = session.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == webhook.tenant_id,
|
||||
Workflow.app_id == webhook.app_id,
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
)
|
||||
.order_by(Workflow.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
else:
|
||||
trigger = session.scalar(
|
||||
select(AppTrigger)
|
||||
.where(
|
||||
AppTrigger.tenant_id == webhook.tenant_id,
|
||||
AppTrigger.app_id == webhook.app_id,
|
||||
AppTrigger.node_id == webhook.node_id,
|
||||
AppTrigger.trigger_type == AppTriggerType.TRIGGER_WEBHOOK,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if trigger is None:
|
||||
raise WebhookNotFoundError(f"App trigger not found for webhook {webhook_id}")
|
||||
status = AppTriggerStatus(trigger.status)
|
||||
# Preserve admission precedence even when an inactive app's stored graph is broken.
|
||||
if status != AppTriggerStatus.ENABLED:
|
||||
return UnavailableWebhook(tenant_id=webhook.tenant_id, status=status)
|
||||
workflow = session.scalar(
|
||||
select(Workflow)
|
||||
.join(App, App.workflow_id == Workflow.id)
|
||||
.where(
|
||||
App.id == webhook.app_id,
|
||||
App.tenant_id == webhook.tenant_id,
|
||||
Workflow.app_id == webhook.app_id,
|
||||
Workflow.tenant_id == webhook.tenant_id,
|
||||
Workflow.version != Workflow.VERSION_DRAFT,
|
||||
)
|
||||
)
|
||||
if workflow is None:
|
||||
raise WebhookNotFoundError(f"Workflow not found for app {webhook.app_id}")
|
||||
try:
|
||||
node_config = deepcopy(workflow.get_node_config_by_id(webhook.node_id))
|
||||
except NodeNotFoundError as error:
|
||||
raise WebhookNotFoundError(str(error)) from error
|
||||
return WebhookContext(
|
||||
tenant_id=webhook.tenant_id,
|
||||
app_id=webhook.app_id,
|
||||
webhook_id=webhook.webhook_id,
|
||||
node_id=webhook.node_id,
|
||||
created_by=webhook.created_by,
|
||||
workflow_id=workflow.id,
|
||||
node_config=node_config,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def sync_draft_webhooks(
|
||||
self, *, tenant_id: str, app_id: str, created_by: str | None, node_ids: Sequence[str]
|
||||
) -> None:
|
||||
# Draft signals are emitted after the draft transaction commits.
|
||||
with self._session_factory.begin() as session:
|
||||
self.sync_webhooks(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
created_by=created_by,
|
||||
node_ids=node_ids,
|
||||
remove_stale=False,
|
||||
)
|
||||
|
||||
def sync_webhooks(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
created_by: str | None,
|
||||
node_ids: Sequence[str],
|
||||
remove_stale: bool,
|
||||
) -> None:
|
||||
"""Retain URLs across draft undo; published deletion joins the publisher transaction."""
|
||||
app = session.scalar(select(App).where(App.tenant_id == tenant_id, App.id == app_id).with_for_update())
|
||||
if app is None:
|
||||
raise WebhookNotFoundError(f"App not found: {app_id}")
|
||||
rows = session.scalars(
|
||||
select(WorkflowWebhookTrigger).where(
|
||||
WorkflowWebhookTrigger.tenant_id == tenant_id,
|
||||
WorkflowWebhookTrigger.app_id == app_id,
|
||||
)
|
||||
).all()
|
||||
existing = {row.node_id: row for row in rows}
|
||||
desired = set(node_ids)
|
||||
for node_id in desired - existing.keys():
|
||||
if created_by is None:
|
||||
raise RuntimeError("App is missing created_by; cannot create webhook trigger")
|
||||
session.add(
|
||||
WorkflowWebhookTrigger(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
created_by=created_by,
|
||||
node_id=node_id,
|
||||
webhook_id=secrets.token_urlsafe(18)[:24],
|
||||
)
|
||||
)
|
||||
if remove_stale:
|
||||
for row in rows:
|
||||
if row.node_id not in desired:
|
||||
session.delete(row)
|
||||
|
||||
@override
|
||||
def get_webhook(self, context: RequestContext, app_id: str, node_id: str) -> WebhookTriggerRecord:
|
||||
with self._session_factory() as session:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Native webhook file storage through the shared tool-file owner."""
|
||||
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from factories import file_factory
|
||||
from graphon.file import File, FileTransferMethod
|
||||
|
||||
|
||||
class ToolWebhookFiles:
|
||||
def __init__(self, *, manager: ToolFileManager, access: DatabaseFileAccessController) -> None:
|
||||
self._manager = manager
|
||||
self._access = access
|
||||
|
||||
def create(self, *, tenant_id: str, user_id: str, content: bytes, mimetype: str) -> File:
|
||||
record = self._manager.create_file_by_raw(
|
||||
user_id=user_id, tenant_id=tenant_id, conversation_id=None, file_binary=content, mimetype=mimetype
|
||||
)
|
||||
return file_factory.build_from_mapping(
|
||||
mapping={"tool_file_id": record.id, "transfer_method": FileTransferMethod.TOOL_FILE.value},
|
||||
tenant_id=tenant_id,
|
||||
access_controller=self._access,
|
||||
)
|
||||
+111
-1048
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,12 @@ class AppScopedTriggerEndUsers:
|
||||
def __init__(self, *, end_users: AppScopedEndUserService[EndUser]) -> None:
|
||||
self._end_users = end_users
|
||||
|
||||
def get_or_create_trigger_end_user(self, *, tenant_id: str, app_id: str) -> TriggerActor:
|
||||
record = self._end_users.get_or_create_end_user_by_type(
|
||||
type=EndUserType.TRIGGER, tenant_id=tenant_id, app_id=app_id, user_id=None
|
||||
)
|
||||
return TriggerActor(id=record.id, role=CreatorUserRole.END_USER)
|
||||
|
||||
def create_trigger_end_users(
|
||||
self, *, tenant_id: str, app_ids: list[str], user_id: str
|
||||
) -> Mapping[str, TriggerActor]:
|
||||
|
||||
@@ -28,6 +28,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TriggerEndUserProvisioner(Protocol):
|
||||
def get_or_create_trigger_end_user(self, *, tenant_id: str, app_id: str) -> TriggerActor: ...
|
||||
|
||||
def create_trigger_end_users(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -1,166 +1,82 @@
|
||||
"""Database integration tests for webhook trigger and workflow lookup."""
|
||||
"""Native debug HTTP uses the real Redis waiting pool and never queues workflow execution."""
|
||||
|
||||
import json
|
||||
from typing import TypedDict
|
||||
from contextvars import Context
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from models.account import Account, Tenant
|
||||
from models.enums import AppTriggerStatus, AppTriggerType
|
||||
from models.model import App
|
||||
from models.trigger import AppTrigger, WorkflowWebhookTrigger
|
||||
from models.workflow import Workflow
|
||||
from models.workflow import WorkflowType as ModelWorkflowType
|
||||
from services.trigger.webhook_service import WebhookService
|
||||
from tests.e2e_tests.containers.helpers import accounts as account_fixtures
|
||||
from tests.e2e_tests.containers.helpers import generate_valid_password
|
||||
from core.trigger.debug.event_bus import TriggerDebugEventBus
|
||||
from core.trigger.debug.events import WebhookDebugEvent, build_webhook_pool_key
|
||||
from core.trigger.utils.endpoint import generate_webhook_trigger_endpoint
|
||||
from models.enums import AppTriggerStatus
|
||||
from models.model import EndUser
|
||||
from models.trigger import AppTrigger, WorkflowTriggerLog
|
||||
from tests.e2e_tests.containers.services import test_trigger_subscription_builder
|
||||
from tests.unit_tests.controllers.console.workspace import test_trigger_oauth_client
|
||||
from tests.unit_tests.controllers.console.workspace.test_trigger_oauth_client import _Harness
|
||||
from tests.unit_tests.controllers.trigger import test_webhook
|
||||
from tests.unit_tests.controllers.trigger.test_webhook import _configure, _Webhook
|
||||
|
||||
application_redis = test_trigger_subscription_builder.application_redis
|
||||
sqlite_engine = test_trigger_subscription_builder.sqlite_engine
|
||||
sqlite_session_factory = test_trigger_subscription_builder.sqlite_session_factory
|
||||
harness = test_trigger_oauth_client.harness
|
||||
webhook = test_webhook.webhook
|
||||
|
||||
|
||||
class WebhookIntegrationData(TypedDict):
|
||||
tenant: Tenant
|
||||
account: Account
|
||||
app: App
|
||||
workflow: Workflow
|
||||
webhook_trigger: WorkflowWebhookTrigger
|
||||
webhook_id: str
|
||||
app_trigger: AppTrigger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_data(
|
||||
db_session_with_containers: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> WebhookIntegrationData:
|
||||
"""Persist the webhook graph with account and workspace creation enabled."""
|
||||
|
||||
fake = Faker()
|
||||
monkeypatch.setattr(
|
||||
"services.account.login_adapters.SystemFeatureService.is_registration_allowed",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.account.login_adapters.SystemFeatureService.is_workspace_creation_allowed",
|
||||
lambda: True,
|
||||
)
|
||||
account = account_fixtures.create_account(
|
||||
email=fake.email(),
|
||||
name=fake.name(),
|
||||
interface_language="en-US",
|
||||
password=generate_valid_password(fake),
|
||||
session=db_session_with_containers,
|
||||
)
|
||||
account_fixtures.create_owner_workspace(account, name=fake.company(), session=db_session_with_containers)
|
||||
tenant = account.current_tenant
|
||||
assert tenant is not None
|
||||
app = App(
|
||||
tenant_id=tenant.id,
|
||||
name=fake.company(),
|
||||
description=fake.text(),
|
||||
mode="workflow",
|
||||
icon="",
|
||||
icon_background="",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
)
|
||||
db_session_with_containers.add(app)
|
||||
db_session_with_containers.flush()
|
||||
workflow_data = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "webhook_node",
|
||||
"type": "webhook",
|
||||
"data": {
|
||||
"type": "trigger-webhook",
|
||||
"title": "Test Webhook",
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"headers": [
|
||||
{"name": "Authorization", "required": True},
|
||||
{"name": "Content-Type", "required": False},
|
||||
],
|
||||
"params": [
|
||||
{"name": "version", "required": True},
|
||||
{"name": "format", "required": False},
|
||||
],
|
||||
"body": [
|
||||
{"name": "message", "type": "string", "required": True},
|
||||
{"name": "count", "type": "number", "required": False},
|
||||
{"name": "upload", "type": "file", "required": False},
|
||||
],
|
||||
"status_code": 200,
|
||||
"response_body": '{"status": "success"}',
|
||||
"timeout": 30,
|
||||
},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
workflow = Workflow(
|
||||
tenant_id=tenant.id,
|
||||
app_id=app.id,
|
||||
type=ModelWorkflowType("workflow"),
|
||||
graph=json.dumps(workflow_data),
|
||||
_features=json.dumps({}),
|
||||
created_by=account.id,
|
||||
_environment_variables=[],
|
||||
_conversation_variables=[],
|
||||
version="1.0",
|
||||
)
|
||||
db_session_with_containers.add(workflow)
|
||||
db_session_with_containers.flush()
|
||||
app.workflow_id = workflow.id
|
||||
webhook_id = fake.uuid4()[:16]
|
||||
webhook_trigger = WorkflowWebhookTrigger(
|
||||
app_id=app.id,
|
||||
node_id="webhook_node",
|
||||
tenant_id=tenant.id,
|
||||
webhook_id=webhook_id,
|
||||
created_by=account.id,
|
||||
)
|
||||
db_session_with_containers.add(webhook_trigger)
|
||||
db_session_with_containers.flush()
|
||||
app_trigger = AppTrigger(
|
||||
tenant_id=tenant.id,
|
||||
app_id=app.id,
|
||||
node_id="webhook_node",
|
||||
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
|
||||
provider_name="webhook",
|
||||
title="Test Webhook",
|
||||
status=AppTriggerStatus.ENABLED,
|
||||
)
|
||||
db_session_with_containers.add(app_trigger)
|
||||
db_session_with_containers.commit()
|
||||
return {
|
||||
"tenant": tenant,
|
||||
"account": account,
|
||||
"app": app,
|
||||
"workflow": workflow,
|
||||
"webhook_trigger": webhook_trigger,
|
||||
"webhook_id": webhook_id,
|
||||
"app_trigger": app_trigger,
|
||||
}
|
||||
|
||||
|
||||
class TestWebhookServiceDatabaseLookup:
|
||||
def test_get_webhook_trigger_and_workflow_success(
|
||||
self,
|
||||
test_data: WebhookIntegrationData,
|
||||
flask_app_with_containers: Flask,
|
||||
) -> None:
|
||||
with flask_app_with_containers.app_context():
|
||||
webhook_trigger, workflow, node_config = WebhookService.get_webhook_trigger_and_workflow(
|
||||
test_data["webhook_id"]
|
||||
@pytest.mark.parametrize("listener", ["absent", "other-app", "matching"])
|
||||
def test_debug_http_requires_matching_listener_and_preserves_draft_response(
|
||||
harness: _Harness, webhook: _Webhook, listener: str
|
||||
) -> None:
|
||||
_configure(harness, webhook, status_code=202, response_body='["accepted"]')
|
||||
with harness.session_factory.begin() as session:
|
||||
trigger = session.get(AppTrigger, webhook.trigger_id)
|
||||
assert trigger is not None
|
||||
trigger.status = AppTriggerStatus.DISABLED
|
||||
listener_app = str(uuid4()) if listener == "other-app" else webhook.app_id
|
||||
pool_key = build_webhook_pool_key(tenant_id=harness.tenant_id, app_id=listener_app, node_id=webhook.node_id)
|
||||
if listener != "absent":
|
||||
assert (
|
||||
TriggerDebugEventBus.poll(
|
||||
event_type=WebhookDebugEvent,
|
||||
pool_key=pool_key,
|
||||
tenant_id=harness.tenant_id,
|
||||
user_id=harness.account_id,
|
||||
app_id=listener_app,
|
||||
node_id=webhook.node_id,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
assert webhook_trigger.webhook_id == test_data["webhook_id"]
|
||||
assert workflow.app_id == test_data["app"].id
|
||||
assert node_config["id"] == "webhook_node"
|
||||
assert node_config["data"].title == "Test Webhook"
|
||||
response = Context().run(harness.client.post, webhook.debug_url, json={"ref": "main"})
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_not_found(self, flask_app_with_containers: Flask) -> None:
|
||||
with flask_app_with_containers.app_context():
|
||||
with pytest.raises(ValueError, match="Webhook not found"):
|
||||
WebhookService.get_webhook_trigger_and_workflow("nonexistent_webhook")
|
||||
if listener == "matching":
|
||||
assert response.status_code == HTTPStatus.ACCEPTED
|
||||
assert response.get_json() == ["accepted"]
|
||||
event = TriggerDebugEventBus.poll(
|
||||
event_type=WebhookDebugEvent,
|
||||
pool_key=pool_key,
|
||||
tenant_id=harness.tenant_id,
|
||||
user_id=harness.account_id,
|
||||
app_id=webhook.app_id,
|
||||
node_id=webhook.node_id,
|
||||
)
|
||||
assert event is not None
|
||||
assert event.node_id == webhook.node_id
|
||||
assert event.payload["webhook_data"]["body"] == {"ref": "main"}
|
||||
assert event.payload["inputs"]["webhook_body"] == {"ref": "main"}
|
||||
else:
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.get_json() == {
|
||||
"error": "No active debug listener",
|
||||
"message": (
|
||||
"The webhook debug URL only works while the Variable Inspector is listening. "
|
||||
"Use the published webhook URL to execute the workflow in Celery."
|
||||
),
|
||||
"execution_url": generate_webhook_trigger_endpoint(webhook.webhook_id),
|
||||
}
|
||||
with harness.session_factory() as session:
|
||||
assert session.scalar(select(EndUser)) is None
|
||||
assert session.scalar(select(WorkflowTriggerLog)) is None
|
||||
@@ -1,589 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE
|
||||
from enums import QuotaType
|
||||
from extensions.ext_application_services import application_services
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.enums import AppTriggerStatus, AppTriggerType, EndUserType
|
||||
from models.model import App, EndUser
|
||||
from models.trigger import AppTrigger, WorkflowWebhookTrigger
|
||||
from models.workflow import Workflow
|
||||
from models.workflow import WorkflowType as ModelWorkflowType
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.trigger.webhook_service import WebhookService
|
||||
|
||||
|
||||
class _EndUserServiceStub:
|
||||
def __init__(self, result: EndUser | Exception) -> None:
|
||||
self._result = result
|
||||
self.calls: list[tuple[EndUserType, str, str, str | None]] = []
|
||||
|
||||
def get_or_create_end_user_by_type(
|
||||
self,
|
||||
type: EndUserType,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
user_id: str | None = None,
|
||||
) -> EndUser:
|
||||
self.calls.append((type, tenant_id, app_id, user_id))
|
||||
if isinstance(self._result, Exception):
|
||||
raise self._result
|
||||
return self._result
|
||||
|
||||
|
||||
def _end_user(*, tenant_id: str, app_id: str) -> EndUser:
|
||||
return EndUser(
|
||||
id=str(uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
type=EndUserType.TRIGGER,
|
||||
session_id="trigger-session",
|
||||
)
|
||||
|
||||
|
||||
class WebhookServiceRelationshipFactory:
|
||||
@staticmethod
|
||||
def create_account_and_tenant(db_session_with_containers: Session) -> tuple[Account, Tenant]:
|
||||
account = Account(
|
||||
name=f"Account {uuid4()}",
|
||||
email=f"webhook-{uuid4()}@example.com",
|
||||
password="hashed-password",
|
||||
password_salt="salt",
|
||||
interface_language="en-US",
|
||||
timezone="UTC",
|
||||
)
|
||||
db_session_with_containers.add(account)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
tenant = Tenant(name=f"Tenant {uuid4()}", plan="basic", status="normal")
|
||||
db_session_with_containers.add(tenant)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
current=True,
|
||||
)
|
||||
db_session_with_containers.add(join)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
account.set_current_tenant_with_session(tenant, session=db_session_with_containers)
|
||||
return account, tenant
|
||||
|
||||
@staticmethod
|
||||
def create_app(db_session_with_containers: Session, tenant: Tenant, account: Account) -> App:
|
||||
app = App(
|
||||
tenant_id=tenant.id,
|
||||
name=f"Webhook App {uuid4()}",
|
||||
description="",
|
||||
mode="workflow",
|
||||
icon_type="emoji",
|
||||
icon="bot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
api_rpm=100,
|
||||
api_rph=100,
|
||||
is_demo=False,
|
||||
is_public=False,
|
||||
is_universal=False,
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
)
|
||||
db_session_with_containers.add(app)
|
||||
db_session_with_containers.commit()
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def create_workflow(
|
||||
db_session_with_containers: Session,
|
||||
*,
|
||||
app: App,
|
||||
account: Account,
|
||||
node_ids: list[str],
|
||||
version: str,
|
||||
) -> Workflow:
|
||||
graph = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": node_id,
|
||||
"data": {
|
||||
"type": TRIGGER_WEBHOOK_NODE_TYPE,
|
||||
"title": f"Webhook {node_id}",
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"params": [],
|
||||
"body": [],
|
||||
"status_code": 200,
|
||||
"response_body": '{"status": "ok"}',
|
||||
"timeout": 30,
|
||||
},
|
||||
}
|
||||
for node_id in node_ids
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
workflow = Workflow(
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
type=ModelWorkflowType("workflow"),
|
||||
graph=json.dumps(graph),
|
||||
_features=json.dumps({}),
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
_environment_variables=[],
|
||||
_conversation_variables=[],
|
||||
version=version,
|
||||
)
|
||||
db_session_with_containers.add(workflow)
|
||||
db_session_with_containers.commit()
|
||||
return workflow
|
||||
|
||||
@staticmethod
|
||||
def create_webhook_trigger(
|
||||
db_session_with_containers: Session,
|
||||
*,
|
||||
app: App,
|
||||
account: Account,
|
||||
node_id: str,
|
||||
webhook_id: str | None = None,
|
||||
) -> WorkflowWebhookTrigger:
|
||||
webhook_trigger = WorkflowWebhookTrigger(
|
||||
app_id=app.id,
|
||||
node_id=node_id,
|
||||
tenant_id=app.tenant_id,
|
||||
webhook_id=webhook_id or uuid4().hex[:24],
|
||||
created_by=account.id,
|
||||
)
|
||||
db_session_with_containers.add(webhook_trigger)
|
||||
db_session_with_containers.commit()
|
||||
return webhook_trigger
|
||||
|
||||
@staticmethod
|
||||
def create_app_trigger(
|
||||
db_session_with_containers: Session,
|
||||
*,
|
||||
app: App,
|
||||
node_id: str,
|
||||
status: AppTriggerStatus,
|
||||
) -> AppTrigger:
|
||||
app_trigger = AppTrigger(
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
node_id=node_id,
|
||||
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
|
||||
provider_name="webhook",
|
||||
title=f"Webhook {node_id}",
|
||||
status=status,
|
||||
)
|
||||
db_session_with_containers.add(app_trigger)
|
||||
db_session_with_containers.commit()
|
||||
return app_trigger
|
||||
|
||||
|
||||
class TestWebhookServiceLookupWithContainers:
|
||||
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_missing(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="App trigger not found"):
|
||||
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_rate_limited(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
factory.create_app_trigger(
|
||||
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.RATE_LIMITED
|
||||
)
|
||||
|
||||
with pytest.raises(QuotaExceededError) as exc_info:
|
||||
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
|
||||
|
||||
assert exc_info.value.feature == QuotaType.TRIGGER.value
|
||||
assert exc_info.value.tenant_id == tenant.id
|
||||
assert exc_info.value.required == 1
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_disabled(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
factory.create_app_trigger(
|
||||
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.DISABLED
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="disabled"):
|
||||
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_raises_when_workflow_missing(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
factory.create_app_trigger(
|
||||
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.ENABLED
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_uses_app_workflow_id(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
current_workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
newer_workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-15.001"
|
||||
)
|
||||
current_workflow.created_at = datetime(2026, 4, 14)
|
||||
newer_workflow.created_at = datetime(2026, 4, 15)
|
||||
app.workflow_id = current_workflow.id
|
||||
db_session_with_containers.commit()
|
||||
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
factory.create_app_trigger(
|
||||
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.ENABLED
|
||||
)
|
||||
|
||||
got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow(
|
||||
webhook_trigger.webhook_id
|
||||
)
|
||||
|
||||
assert got_trigger.id == webhook_trigger.id
|
||||
assert got_workflow.id == current_workflow.id
|
||||
assert got_workflow.id != newer_workflow.id
|
||||
assert got_node_config["id"] == "node-1"
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_returns_debug_draft_workflow(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
factory.create_workflow(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_ids=["published-node"],
|
||||
version="2026-04-14.001",
|
||||
)
|
||||
draft_workflow = factory.create_workflow(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_ids=["debug-node"],
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="debug-node"
|
||||
)
|
||||
|
||||
got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow(
|
||||
webhook_trigger.webhook_id,
|
||||
is_debug=True,
|
||||
)
|
||||
|
||||
assert got_trigger.id == webhook_trigger.id
|
||||
assert got_workflow.id == draft_workflow.id
|
||||
assert got_node_config["id"] == "debug-node"
|
||||
|
||||
|
||||
class TestWebhookServiceTriggerExecutionWithContainers:
|
||||
def test_trigger_workflow_execution_triggers_async_workflow_successfully(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
|
||||
end_user = _end_user(tenant_id=tenant.id, app_id=app.id)
|
||||
webhook_data = {"body": {"value": 1}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"}
|
||||
|
||||
quota_charge = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"services.trigger.webhook_service.QuotaService.reserve",
|
||||
return_value=quota_charge,
|
||||
) as mock_reserve,
|
||||
patch("services.async_workflow_service.AsyncWorkflowService.trigger_workflow_async") as mock_trigger,
|
||||
):
|
||||
WebhookService.trigger_workflow_execution(
|
||||
webhook_trigger,
|
||||
webhook_data,
|
||||
workflow,
|
||||
end_users=_EndUserServiceStub(end_user),
|
||||
trigger_status=application_services().apps.trigger_status,
|
||||
workflow_dispatch=application_services().workspaces.workflow_dispatch,
|
||||
)
|
||||
|
||||
mock_reserve.assert_called_once()
|
||||
reserve_args = mock_reserve.call_args.args
|
||||
assert reserve_args[0] == QuotaType.TRIGGER
|
||||
assert reserve_args[1] == webhook_trigger.tenant_id
|
||||
quota_charge.commit.assert_called_once()
|
||||
mock_trigger.assert_called_once()
|
||||
trigger_args = mock_trigger.call_args.kwargs
|
||||
assert trigger_args["actor"].id == end_user.id
|
||||
assert trigger_args["trigger_data"].workflow_id == workflow.id
|
||||
assert trigger_args["trigger_data"].root_node_id == webhook_trigger.node_id
|
||||
|
||||
def test_trigger_workflow_execution_marks_tenant_rate_limited_when_quota_exceeded(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
|
||||
app_trigger = factory.create_app_trigger(
|
||||
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.ENABLED
|
||||
)
|
||||
with patch(
|
||||
"services.trigger.webhook_service.QuotaService.reserve",
|
||||
side_effect=QuotaExceededError(feature="trigger", tenant_id=tenant.id, required=1),
|
||||
):
|
||||
with pytest.raises(QuotaExceededError):
|
||||
WebhookService.trigger_workflow_execution(
|
||||
webhook_trigger,
|
||||
{"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"},
|
||||
workflow,
|
||||
end_users=_EndUserServiceStub(_end_user(tenant_id=tenant.id, app_id=app.id)),
|
||||
trigger_status=application_services().apps.trigger_status,
|
||||
workflow_dispatch=application_services().workspaces.workflow_dispatch,
|
||||
)
|
||||
|
||||
db_session_with_containers.refresh(app_trigger)
|
||||
assert app_trigger.status == AppTriggerStatus.RATE_LIMITED
|
||||
|
||||
def test_trigger_workflow_execution_logs_and_reraises_unexpected_errors(
|
||||
self,
|
||||
db_session_with_containers: Session,
|
||||
flask_app_with_containers: Flask,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
caplog.set_level(logging.ERROR, logger="services.trigger.webhook_service")
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
WebhookService.trigger_workflow_execution(
|
||||
webhook_trigger,
|
||||
{"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"},
|
||||
workflow,
|
||||
end_users=_EndUserServiceStub(RuntimeError("boom")),
|
||||
trigger_status=application_services().apps.trigger_status,
|
||||
workflow_dispatch=application_services().workspaces.workflow_dispatch,
|
||||
)
|
||||
|
||||
assert caplog.messages.count(f"Failed to trigger workflow for webhook {webhook_trigger.webhook_id}") == 1
|
||||
|
||||
|
||||
class TestWebhookServiceRelationshipSyncWithContainers:
|
||||
def test_sync_webhook_relationships_raises_when_workflow_exceeds_node_limit(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
node_ids = [f"node-{index}" for index in range(WebhookService.MAX_WEBHOOK_NODES_PER_WORKFLOW + 1)]
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=node_ids, version=Workflow.VERSION_DRAFT
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="maximum webhook node limit"):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
def test_sync_webhook_relationships_raises_when_lock_not_acquired(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version=Workflow.VERSION_DRAFT
|
||||
)
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = False
|
||||
|
||||
with patch("services.trigger.webhook_service.redis_client.lock", return_value=lock):
|
||||
with pytest.raises(RuntimeError, match="Failed to acquire lock"):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
def test_sync_webhook_relationships_creates_missing_records_and_deletes_stale_records(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
stale_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_id="node-stale",
|
||||
webhook_id="stale-webhook-id-000001",
|
||||
)
|
||||
stale_trigger_id = stale_trigger.id
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_ids=["node-new"],
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"services.trigger.webhook_service.WebhookService.generate_webhook_id", return_value="new-webhook-id-000001"
|
||||
):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
db_session_with_containers.expire_all()
|
||||
records = db_session_with_containers.scalars(
|
||||
select(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.app_id == app.id)
|
||||
).all()
|
||||
|
||||
assert [record.node_id for record in records] == ["node-new"]
|
||||
assert records[0].webhook_id == "new-webhook-id-000001"
|
||||
assert db_session_with_containers.get(WorkflowWebhookTrigger, stale_trigger_id) is None
|
||||
|
||||
def test_sync_webhook_relationships_sets_redis_cache_for_new_record(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_ids=["node-cache"],
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
)
|
||||
cache_key = f"{WebhookService.__WEBHOOK_NODE_CACHE_KEY__}:{app.id}:node-cache"
|
||||
|
||||
with patch(
|
||||
"services.trigger.webhook_service.WebhookService.generate_webhook_id", return_value="cache-webhook-id-00001"
|
||||
):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
cached_payload = WebhookServiceRelationshipFactory._read_cache(cache_key)
|
||||
assert cached_payload is not None
|
||||
assert cached_payload["node_id"] == "node-cache"
|
||||
assert cached_payload["webhook_id"] == "cache-webhook-id-00001"
|
||||
|
||||
def test_sync_webhook_relationships_logs_when_lock_release_fails(
|
||||
self,
|
||||
db_session_with_containers: Session,
|
||||
flask_app_with_containers: Flask,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=[], version=Workflow.VERSION_DRAFT
|
||||
)
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = True
|
||||
lock.release.side_effect = RuntimeError("release failed")
|
||||
caplog.set_level(logging.ERROR, logger="services.trigger.webhook_service")
|
||||
|
||||
with patch("services.trigger.webhook_service.redis_client.lock", return_value=lock):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
assert caplog.messages.count(f"Failed to release lock for webhook sync, app {app.id}") == 1
|
||||
|
||||
|
||||
def _read_cache(cache_key: str) -> dict[str, str] | None:
|
||||
from extensions.ext_redis import redis_client
|
||||
|
||||
cached = redis_client.get(cache_key)
|
||||
if not cached:
|
||||
return None
|
||||
if isinstance(cached, bytes):
|
||||
cached = cached.decode("utf-8")
|
||||
return json.loads(cached)
|
||||
|
||||
|
||||
WebhookServiceRelationshipFactory._read_cache = staticmethod(_read_cache)
|
||||
@@ -11,6 +11,7 @@ from uuid import uuid4
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
from kombu.simple import SimpleQueue
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -47,6 +48,8 @@ from services.trigger.subscription_query_service import TriggerSubscriptionRunti
|
||||
from services.workflow.entities import TriggerActor, TriggerData
|
||||
from services.workflow_service import WorkflowService
|
||||
from tasks import trigger_processing_tasks
|
||||
from tests.helpers.unit.config_override import config_overrides_context
|
||||
from tests.unit_tests.controllers.trigger import test_webhook as native_webhook_tests
|
||||
|
||||
from .conftest import RecordingScheduleTask
|
||||
|
||||
@@ -55,6 +58,8 @@ WEBHOOK_ID_PRODUCTION = "wh1234567890123456789012"
|
||||
WEBHOOK_ID_DEBUG = "whdebug1234567890123456"
|
||||
TEST_TRIGGER_URL = "https://trigger.example.com/base"
|
||||
|
||||
broker = native_webhook_tests.broker
|
||||
|
||||
|
||||
def _build_workflow_graph(root_node_id: str, trigger_type: str) -> str:
|
||||
"""Build a minimal workflow graph JSON for testing."""
|
||||
@@ -156,7 +161,7 @@ def test_webhook_trigger_creates_trigger_log(
|
||||
db_session_with_containers: Session,
|
||||
tenant_and_account: tuple[Tenant, Account],
|
||||
app_model: App,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
broker: SimpleQueue,
|
||||
) -> None:
|
||||
"""Production webhook trigger should create a trigger log in the database."""
|
||||
tenant, account = tenant_and_account
|
||||
@@ -198,39 +203,10 @@ def test_webhook_trigger_creates_trigger_log(
|
||||
db_session_with_containers.add_all([webhook_trigger, app_trigger])
|
||||
db_session_with_containers.commit()
|
||||
|
||||
def _fake_trigger_workflow_async(
|
||||
self: AsyncWorkflowService, *, actor: TriggerActor, trigger_data: TriggerData
|
||||
) -> SimpleNamespace:
|
||||
session = db_session_with_containers
|
||||
log = WorkflowTriggerLog(
|
||||
tenant_id=trigger_data.tenant_id,
|
||||
app_id=trigger_data.app_id,
|
||||
workflow_id=trigger_data.workflow_id,
|
||||
root_node_id=trigger_data.root_node_id,
|
||||
trigger_metadata=trigger_data.trigger_metadata.model_dump_json() if trigger_data.trigger_metadata else "{}",
|
||||
trigger_type=trigger_data.trigger_type,
|
||||
workflow_run_id=None,
|
||||
outputs=None,
|
||||
trigger_data=trigger_data.model_dump_json(),
|
||||
inputs=json.dumps(dict(trigger_data.inputs)),
|
||||
status=WorkflowTriggerStatus.SUCCEEDED,
|
||||
error="",
|
||||
queue_name="triggered_workflow_dispatcher",
|
||||
celery_task_id="celery-test",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=account.id,
|
||||
with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY):
|
||||
response = test_client_with_containers.post(
|
||||
f"/triggers/webhook/{webhook_trigger.webhook_id}", json={"foo": "bar"}
|
||||
)
|
||||
session.add(log)
|
||||
session.commit()
|
||||
return SimpleNamespace(workflow_trigger_log_id=log.id, task_id=None, status="queued", queue="test")
|
||||
|
||||
monkeypatch.setattr(
|
||||
AsyncWorkflowService,
|
||||
"trigger_workflow_async",
|
||||
_fake_trigger_workflow_async,
|
||||
)
|
||||
|
||||
response = test_client_with_containers.post(f"/triggers/webhook/{webhook_trigger.webhook_id}", json={"foo": "bar"})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -238,7 +214,14 @@ def test_webhook_trigger_creates_trigger_log(
|
||||
logs = db_session_with_containers.scalars(
|
||||
select(WorkflowTriggerLog).where(WorkflowTriggerLog.app_id == app_model.id)
|
||||
).all()
|
||||
assert logs, "Webhook trigger should create trigger log"
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == WorkflowTriggerStatus.QUEUED
|
||||
assert logs[0].root_node_id == webhook_node_id
|
||||
assert json.loads(logs[0].inputs)["webhook_body"] == {"foo": "bar"}
|
||||
message = broker.get(block=False)
|
||||
assert message.payload[0] == [{"workflow_trigger_log_id": logs[0].id}]
|
||||
assert message.headers["id"] == logs[0].celery_task_id
|
||||
message.ack()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schedule_type", ["visual", "cron"])
|
||||
@@ -390,7 +373,6 @@ def test_webhook_debug_dispatches_event(
|
||||
db_session_with_containers: Session,
|
||||
tenant_and_account: tuple[Tenant, Account],
|
||||
app_model: App,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Webhook single-step debug should dispatch debug event and be pollable."""
|
||||
tenant, account = tenant_and_account
|
||||
@@ -421,13 +403,6 @@ def test_webhook_debug_dispatches_event(
|
||||
db_session_with_containers.add(webhook_trigger)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
debug_events: list[dict[str, Any]] = []
|
||||
original_dispatch = TriggerDebugEventBus.dispatch
|
||||
monkeypatch.setattr(
|
||||
"controllers.trigger.webhook.TriggerDebugEventBus.dispatch",
|
||||
lambda **kwargs: (debug_events.append(kwargs), original_dispatch(**kwargs))[1],
|
||||
)
|
||||
|
||||
# Listener polls first to enter waiting pool
|
||||
poller = WebhookTriggerDebugEventPoller(
|
||||
tenant_id=tenant.id,
|
||||
@@ -445,12 +420,14 @@ def test_webhook_debug_dispatches_event(
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert debug_events, "Debug event should be sent to event bus"
|
||||
# Second poll should get the event
|
||||
event = poller.poll()
|
||||
assert event is not None
|
||||
assert event.workflow_args["inputs"]["webhook_body"]["foo"] == "bar"
|
||||
assert debug_events[0]["pool_key"].endswith(f":{app_model.id}:{webhook_node_id}")
|
||||
assert (
|
||||
db_session_with_containers.scalar(select(WorkflowTriggerLog).where(WorkflowTriggerLog.app_id == app_model.id))
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_schedule_trigger_creates_trigger_log(
|
||||
|
||||
@@ -16,7 +16,7 @@ from pytest_mock import MockerFixture
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, object_session, sessionmaker
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
import controllers.service_api.wraps as token_wraps
|
||||
import services.api_token_service as token_service
|
||||
@@ -27,7 +27,6 @@ from controllers.common.access_response import (
|
||||
register_auth_error_response,
|
||||
)
|
||||
from controllers.mcp import mcp
|
||||
from controllers.trigger import webhook
|
||||
from extensions import ext_request_logging
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from libs.external_api import ExternalApi
|
||||
@@ -553,29 +552,6 @@ def test_mcp_reads_body_outside_sessions_and_rechecks_identity_before_execution(
|
||||
assert writes == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["webhook", "webhook-debug"])
|
||||
def test_webhook_missing_identity_uses_native_html_without_disclosing_id(
|
||||
http_app: Flask, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, route: Literal["webhook", "webhook-debug"]
|
||||
) -> None:
|
||||
monkeypatch.setattr(webhook, "request", request)
|
||||
monkeypatch.setattr(webhook, "jsonify", jsonify)
|
||||
mocker.patch.object(
|
||||
webhook.WebhookService,
|
||||
"get_webhook_trigger_and_workflow",
|
||||
side_effect=ValueError("Webhook not found: private-fixture"),
|
||||
)
|
||||
execute = mocker.patch.object(webhook.WebhookService, "trigger_workflow_execution")
|
||||
handler = webhook.handle_webhook if route == "webhook" else webhook.handle_webhook_debug
|
||||
http_app.add_url_rule(f"/triggers/{route}/<string:webhook_id>", view_func=handler, methods=["POST"])
|
||||
response = http_app.test_client().post(f"/triggers/{route}/private-fixture", json={})
|
||||
assert response.status_code == 404
|
||||
assert response.data == NotFound().get_body().encode()
|
||||
assert response.headers["Content-Type"] == "text/html; charset=utf-8"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
assert b"private-fixture" not in response.data
|
||||
execute.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403, 429])
|
||||
def test_openapi_local_hook_preserves_native_details_and_oauth_challenge(http_app: Flask, status: int) -> None:
|
||||
payload = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, create_autospec
|
||||
@@ -17,65 +16,18 @@ from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.inner_api.workspace import workspace as controller
|
||||
from controllers.inner_api.wraps import InnerApiUnauthorizedError
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
from core.trigger.trigger_manager import TriggerManager
|
||||
from core.trigger.utils.encryption import delete_cache_for_subscription
|
||||
from enums import DeploymentEdition
|
||||
from extensions.application_services.workspace import WorkspaceServices
|
||||
from extensions.ext_application_services import build_application_services
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from libs.external_api import register_external_error_handlers
|
||||
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
|
||||
from repositories.app_scoped_end_user_repository import AppScopedEndUserRepo
|
||||
from repositories.credentials.query_repository import CredentialQueryRepository
|
||||
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerDispatchRepository
|
||||
from repositories.trigger.app_repository import SQLAlchemyAppTriggerRepository
|
||||
from repositories.trigger.oauth_client_repository import TriggerOAuthClientRepository
|
||||
from repositories.trigger.subscription_builder_repository import RedisTriggerSubscriptionBuilderRepository
|
||||
from repositories.trigger.subscription_repository import (
|
||||
RedisTriggerSubscriptionLocks,
|
||||
RedisTriggerSubscriptionRefreshReservations,
|
||||
TriggerSubscriptionRepository,
|
||||
)
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.app_scoped_end_user_service import AppScopedEndUserService
|
||||
from services.async_workflow_service import AsyncWorkflowService
|
||||
from services.errors.workspace import (
|
||||
InvalidWorkspaceMemberRoleError,
|
||||
WorkspaceNotFoundError,
|
||||
WorkspaceOwnerNotFoundError,
|
||||
WorkspacesLimitExceededError,
|
||||
)
|
||||
from services.plugin.plugin_parameter_gateway import DaemonPluginParameterGateway
|
||||
from services.plugin.plugin_parameter_service import PluginParameterService
|
||||
from services.quota_service import QuotaService
|
||||
from services.trigger.app_trigger_service import AppTriggerService
|
||||
from services.trigger.endpoint_gateway import PluginTriggerEndpointGateway, enqueue_triggered_workflows
|
||||
from services.trigger.endpoint_service import TriggerEndpointService
|
||||
from services.trigger.event_gateway import PluginTriggerEventGateway
|
||||
from services.trigger.event_service import TriggerEventService
|
||||
from services.trigger.oauth_client_service import TriggerOAuthClientService
|
||||
from services.trigger.oauth_gateway import (
|
||||
OAuthProxyContextGateway,
|
||||
PluginTriggerOAuthGateway,
|
||||
TriggerOAuthBuilderGateway,
|
||||
)
|
||||
from services.trigger.oauth_service import TriggerOAuthService
|
||||
from services.trigger.provider_gateway import PluginTriggerProviderQuery
|
||||
from services.trigger.subscription_binding_service import TriggerSubscriptionBindingService
|
||||
from services.trigger.subscription_codec import ProviderTriggerSubscriptionCodec
|
||||
from services.trigger.subscription_gateway import (
|
||||
PluginTriggerSubscriptionGateway,
|
||||
enqueue_subscription_refresh,
|
||||
invalidate_subscription_properties,
|
||||
)
|
||||
from services.trigger.subscription_query_service import TriggerSubscriptionQueryService
|
||||
from services.trigger.subscription_refresh_service import TriggerSubscriptionRefreshService
|
||||
from services.trigger.subscription_renewal_service import TriggerSubscriptionRenewalService
|
||||
from services.trigger.subscription_service import TriggerSubscriptionService
|
||||
from services.trigger.trigger_request_service import TriggerHttpRequestCachingService
|
||||
from services.trigger.trigger_subscription_builder_service import TriggerSubscriptionBuilderService
|
||||
from services.trigger.workflow_dispatch_gateway import AppScopedTriggerEndUsers
|
||||
from services.trigger.workflow_dispatch_service import PluginTriggerWorkflowDispatchService
|
||||
from services.workflow.queue_dispatcher import CeleryWorkflowQueuePublisher
|
||||
from services.workspace.contracts import CreatedWorkspace, WorkspaceMembership
|
||||
from services.workspace.provisioning_service import WorkspaceProvisioningService
|
||||
from services.workspace.service import WorkspaceQueryService, WorkspaceService
|
||||
@@ -146,55 +98,15 @@ class TestWorkspaceCases:
|
||||
@pytest.fixture
|
||||
def provisioning(monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]) -> Mock:
|
||||
provisioning = create_autospec(WorkspaceProvisioningService, instance=True)
|
||||
oauth_provider = PluginTriggerOAuthGateway(oauth=OAuthHandler())
|
||||
oauth_clients = TriggerOAuthClientService(
|
||||
clients=TriggerOAuthClientRepository(session_factory=sqlite_session_factory), providers=oauth_provider
|
||||
)
|
||||
redis = RedisClientWrapper()
|
||||
redis.initialize(Redis())
|
||||
subscription_management = TriggerSubscriptionService(
|
||||
subscriptions=TriggerSubscriptionRepository(session_factory=sqlite_session_factory),
|
||||
providers=PluginTriggerSubscriptionGateway(),
|
||||
locks=RedisTriggerSubscriptionLocks(redis=redis),
|
||||
codec=ProviderTriggerSubscriptionCodec(),
|
||||
invalidate_cache=delete_cache_for_subscription,
|
||||
)
|
||||
builders = TriggerSubscriptionBuilderService(
|
||||
builders=RedisTriggerSubscriptionBuilderRepository(redis=redis),
|
||||
get_provider=TriggerManager.get_trigger_provider,
|
||||
subscribe=TriggerManager.subscribe_trigger,
|
||||
create_subscription=subscription_management.create,
|
||||
)
|
||||
subscription_queries = TriggerSubscriptionQueryService(
|
||||
credentials=CredentialQueryRepository(session_factory=sqlite_session_factory),
|
||||
schemas=PluginTriggerProviderQuery(),
|
||||
subscriptions=TriggerSubscriptionRepository(session_factory=sqlite_session_factory),
|
||||
)
|
||||
renewal = TriggerSubscriptionRenewalService(
|
||||
subscriptions=TriggerSubscriptionRepository(session_factory=sqlite_session_factory),
|
||||
providers=PluginTriggerSubscriptionGateway(),
|
||||
oauth=oauth_provider,
|
||||
clients=oauth_clients,
|
||||
locks=RedisTriggerSubscriptionLocks(redis=redis),
|
||||
codec=ProviderTriggerSubscriptionCodec(),
|
||||
credential_threshold=10,
|
||||
subscription_threshold=10,
|
||||
console_api_url="https://console.example.test",
|
||||
invalidate_cache=delete_cache_for_subscription,
|
||||
invalidate_properties=invalidate_subscription_properties,
|
||||
)
|
||||
workflow_queries = AppDefinitionQueryRepository(session_factory=sqlite_session_factory)
|
||||
logs = SQLAlchemyWorkflowTriggerDispatchRepository(session_factory=sqlite_session_factory)
|
||||
queues = CeleryWorkflowQueuePublisher()
|
||||
events = TriggerEventService(
|
||||
subscriptions=subscription_queries,
|
||||
plugins=PluginTriggerEventGateway(),
|
||||
requests=TriggerHttpRequestCachingService,
|
||||
)
|
||||
workflow_dispatch = AsyncWorkflowService(
|
||||
workflows=workflow_queries, logs=logs, queues=queues, reserve_quota=QuotaService.reserve
|
||||
)
|
||||
services = WorkspaceServices(
|
||||
services = replace(
|
||||
build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=redis,
|
||||
).workspaces,
|
||||
queries=create_autospec(WorkspaceQueryService, instance=True),
|
||||
management=create_autospec(WorkspaceService, instance=True),
|
||||
provisioning=provisioning,
|
||||
@@ -204,62 +116,6 @@ class TestWorkspaceCases:
|
||||
invitations=Mock(),
|
||||
identity=Mock(),
|
||||
resource_cleanup=Mock(),
|
||||
plugin_parameters=PluginParameterService(
|
||||
credentials=CredentialQueryRepository(session_factory=sqlite_session_factory),
|
||||
subscriptions=TriggerSubscriptionRepository(session_factory=sqlite_session_factory),
|
||||
builders=builders,
|
||||
plugins=DaemonPluginParameterGateway(),
|
||||
),
|
||||
trigger_providers=PluginTriggerProviderQuery(),
|
||||
trigger_subscriptions=subscription_queries,
|
||||
trigger_binding_queries=TriggerSubscriptionRepository(session_factory=sqlite_session_factory),
|
||||
create_trigger_binding_service=lambda session: TriggerSubscriptionBindingService(
|
||||
sync_bindings=partial(
|
||||
TriggerSubscriptionRepository(session_factory=sqlite_session_factory).sync_workflow_bindings,
|
||||
session=session,
|
||||
)
|
||||
),
|
||||
trigger_events=events,
|
||||
workflow_dispatch=workflow_dispatch,
|
||||
trigger_workflow_dispatch=PluginTriggerWorkflowDispatchService(
|
||||
workflows=workflow_queries,
|
||||
logs=logs,
|
||||
queues=queues,
|
||||
dispatch=workflow_dispatch,
|
||||
events=events,
|
||||
bindings=TriggerSubscriptionRepository(session_factory=sqlite_session_factory),
|
||||
end_users=AppScopedTriggerEndUsers(
|
||||
end_users=AppScopedEndUserService(
|
||||
end_users=AppScopedEndUserRepo(session_factory=sqlite_session_factory)
|
||||
)
|
||||
),
|
||||
reserve_quota=QuotaService.reserve,
|
||||
mark_rate_limited=AppTriggerService(
|
||||
triggers=SQLAlchemyAppTriggerRepository(session_factory=sqlite_session_factory)
|
||||
).mark_tenant_triggers_rate_limited,
|
||||
),
|
||||
trigger_builders=builders,
|
||||
trigger_endpoints=TriggerEndpointService(
|
||||
subscriptions=TriggerSubscriptionRepository(session_factory=sqlite_session_factory),
|
||||
provider=PluginTriggerEndpointGateway(),
|
||||
requests=TriggerHttpRequestCachingService,
|
||||
publish=enqueue_triggered_workflows,
|
||||
),
|
||||
trigger_subscription_management=subscription_management,
|
||||
trigger_subscription_renewal=renewal,
|
||||
trigger_subscription_refresh=TriggerSubscriptionRefreshService(
|
||||
subscriptions=TriggerSubscriptionRepository(session_factory=sqlite_session_factory),
|
||||
reservations=RedisTriggerSubscriptionRefreshReservations(redis=redis),
|
||||
publish=enqueue_subscription_refresh,
|
||||
renewal=renewal,
|
||||
),
|
||||
trigger_oauth_clients=oauth_clients,
|
||||
trigger_oauth=TriggerOAuthService(
|
||||
clients=oauth_clients,
|
||||
providers=oauth_provider,
|
||||
contexts=OAuthProxyContextGateway(),
|
||||
builders=TriggerOAuthBuilderGateway(builders=builders),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(controller, "application_services", lambda: Services(services))
|
||||
return provisioning
|
||||
|
||||
@@ -1,361 +1,516 @@
|
||||
from dataclasses import dataclass
|
||||
"""Native webhook HTTP requests use real admission, SQL, quota HTTP and task publishing."""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from contextvars import Context
|
||||
from dataclasses import dataclass, field
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
from threading import Thread
|
||||
from typing import override
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import NotFound, RequestEntityTooLarge
|
||||
from celery import Celery
|
||||
from kombu.simple import SimpleQueue
|
||||
from sqlalchemy import Engine, delete, select
|
||||
from sqlalchemy.pool import QueuePool
|
||||
from werkzeug.test import TestResponse
|
||||
|
||||
import controllers.trigger.webhook as module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.file_access import DatabaseFileAccessController, FileAccessScope, bind_file_access_scope
|
||||
from factories import file_factory
|
||||
from models.enums import DEFAULT_END_USER_SESSION_ID, EndUserType
|
||||
from models.model import EndUser
|
||||
from models.trigger import WorkflowWebhookTrigger
|
||||
from controllers.trigger import bp
|
||||
from core.app.workflow.file_runtime import create_dify_workflow_file_runtime
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_storage import storage
|
||||
from graphon.file.runtime import use_workflow_file_runtime
|
||||
from models.enums import AppTriggerStatus, AppTriggerType, EndUserType, WorkflowTriggerStatus
|
||||
from models.model import AppMode, EndUser
|
||||
from models.tools import ToolFile
|
||||
from models.trigger import AppTrigger, WorkflowTriggerLog, WorkflowWebhookTrigger
|
||||
from models.workflow import Workflow
|
||||
from repositories.trigger.app_repository import SQLAlchemyAppTriggerRepository
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.trigger.app_trigger_service import AppTriggerService
|
||||
from tests.helpers.unit.model_factories import make_end_user, make_workflow
|
||||
from tasks.async_workflow_tasks import execute_workflow_sandbox
|
||||
from tasks.workflow_cfs_scheduler.entities import AsyncWorkflowQueue
|
||||
from tests.helpers.unit.config_override import config_overrides_context
|
||||
from tests.helpers.unit.model_factories import make_app, make_workflow
|
||||
from tests.unit_tests.controllers.console.workspace import test_trigger_oauth_client
|
||||
from tests.unit_tests.controllers.console.workspace.test_trigger_oauth_client import _Harness
|
||||
|
||||
|
||||
class _RequestStub:
|
||||
method = "POST"
|
||||
headers = {"x-test": "1"}
|
||||
args = {"a": "b"}
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class _AppScopedEndUserServicesStub:
|
||||
commands: object
|
||||
application_redis = test_trigger_oauth_client.application_redis
|
||||
harness = test_trigger_oauth_client.harness
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AppServicesStub:
|
||||
trigger_status: AppTriggerService
|
||||
class _Webhook:
|
||||
app_id: str
|
||||
webhook_id: str
|
||||
workflow_id: str
|
||||
draft_id: str
|
||||
trigger_id: str
|
||||
node_id: str = "webhook-node"
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"/triggers/webhook/{self.webhook_id}"
|
||||
|
||||
@property
|
||||
def debug_url(self) -> str:
|
||||
return f"/triggers/webhook-debug/{self.webhook_id}"
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class _WorkspaceServicesStub:
|
||||
workflow_dispatch: object
|
||||
|
||||
|
||||
class _ApplicationServicesStub:
|
||||
def __init__(self, commands: object, trigger_status: AppTriggerService) -> None:
|
||||
self.app_scoped_end_users = _AppScopedEndUserServicesStub(commands)
|
||||
self.apps = _AppServicesStub(trigger_status)
|
||||
self.workspaces = _WorkspaceServicesStub(object())
|
||||
|
||||
|
||||
class _EndUserCommandsStub:
|
||||
def __init__(self, end_user: EndUser) -> None:
|
||||
self._end_user = end_user
|
||||
self.calls: list[tuple[EndUserType, str, str, str | None]] = []
|
||||
|
||||
def get_or_create_end_user_by_type(
|
||||
self,
|
||||
type: EndUserType,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
user_id: str | None = None,
|
||||
) -> EndUser:
|
||||
self.calls.append((type, tenant_id, app_id, user_id))
|
||||
return self._end_user
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_request():
|
||||
module.request = _RequestStub()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_jsonify():
|
||||
module.jsonify = lambda payload: payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def end_user_commands(monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]) -> object:
|
||||
commands = object()
|
||||
triggers = AppTriggerService(triggers=SQLAlchemyAppTriggerRepository(session_factory=sqlite_session_factory))
|
||||
monkeypatch.setattr(module, "application_services", lambda: _ApplicationServicesStub(commands, triggers))
|
||||
return commands
|
||||
|
||||
|
||||
def _webhook_trigger() -> WorkflowWebhookTrigger:
|
||||
return WorkflowWebhookTrigger(
|
||||
webhook_id="wh-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
node_id="node-1",
|
||||
created_by="account-1",
|
||||
)
|
||||
|
||||
|
||||
def _workflow() -> Workflow:
|
||||
return make_workflow(workflow_id="workflow-1")
|
||||
|
||||
|
||||
class TestPrepareWebhookExecution:
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
def test_prepare_success(self, mock_extract, mock_get):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
workflow = _workflow()
|
||||
mock_get.return_value = (webhook_trigger, workflow, "node_config")
|
||||
mock_extract.return_value = {"data": "ok"}
|
||||
|
||||
result = module._prepare_webhook_execution("wh-1")
|
||||
|
||||
assert result == (webhook_trigger, workflow, "node_config", {"data": "ok"}, None)
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data", side_effect=ValueError("bad"))
|
||||
def test_prepare_validation_error(self, mock_extract, mock_get):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
workflow = _workflow()
|
||||
mock_get.return_value = (webhook_trigger, workflow, "node_config")
|
||||
|
||||
trigger, workflow, node_config, webhook_data, error = module._prepare_webhook_execution("wh-1")
|
||||
|
||||
assert error == "bad"
|
||||
assert webhook_data["method"] == "POST"
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
def test_prepare_stores_uploads_for_the_trigger_end_user_run(
|
||||
self,
|
||||
mock_get: MagicMock,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
node_config = {
|
||||
"data": {
|
||||
"method": "post",
|
||||
"content_type": "multipart/form-data",
|
||||
"body": [{"name": "chat_history_file", "type": "file", "required": True}],
|
||||
@pytest.fixture
|
||||
def webhook(harness: _Harness) -> _Webhook:
|
||||
harness.app.register_blueprint(bp)
|
||||
app_id, webhook_id = str(uuid4()), uuid4().hex[:24]
|
||||
graph: dict[str, object] = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "webhook-node",
|
||||
"data": {
|
||||
"type": "trigger-webhook",
|
||||
"title": "Incoming webhook",
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"params": [],
|
||||
"body": [],
|
||||
},
|
||||
}
|
||||
}
|
||||
mock_get.return_value = (_webhook_trigger(), _workflow(), node_config)
|
||||
trigger_end_user = make_end_user(
|
||||
end_user_id="trigger-end-user-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
end_user_type=EndUserType.TRIGGER,
|
||||
session_id=DEFAULT_END_USER_SESSION_ID,
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
published, draft = [
|
||||
make_workflow(
|
||||
tenant_id=harness.tenant_id,
|
||||
app_id=app_id,
|
||||
version=version,
|
||||
graph=graph,
|
||||
created_by=harness.account_id,
|
||||
)
|
||||
end_users = _EndUserCommandsStub(trigger_end_user)
|
||||
triggers = AppTriggerService(triggers=SQLAlchemyAppTriggerRepository(session_factory=sqlite_session_factory))
|
||||
monkeypatch.setattr(module, "application_services", lambda: _ApplicationServicesStub(end_users, triggers))
|
||||
monkeypatch.setattr("core.tools.tool_file_manager.storage.save", lambda *_args: None)
|
||||
|
||||
with app.test_request_context(
|
||||
"/triggers/webhook/wh-1",
|
||||
method="POST",
|
||||
data={"chat_history_file": (BytesIO(b"# chat"), "chat.md", "text/markdown")},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
_, _, _, webhook_data, error = module._prepare_webhook_execution("wh-1")
|
||||
|
||||
assert error is None
|
||||
tool_file_id = webhook_data["files"]["chat_history_file"]["related_id"]
|
||||
# The async run executes as the trigger end user, whose scope only loads tool files it owns.
|
||||
run_scope = FileAccessScope(
|
||||
tenant_id="tenant-1",
|
||||
user_id=trigger_end_user.id,
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
for version in ("published", Workflow.VERSION_DRAFT)
|
||||
]
|
||||
with harness.session_factory.begin() as session:
|
||||
trigger = AppTrigger(
|
||||
tenant_id=harness.tenant_id,
|
||||
app_id=app_id,
|
||||
node_id="webhook-node",
|
||||
title="Incoming webhook",
|
||||
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
|
||||
status=AppTriggerStatus.ENABLED,
|
||||
)
|
||||
with bind_file_access_scope(run_scope):
|
||||
file = file_factory.build_from_mapping(
|
||||
mapping={"transfer_method": "tool_file", "tool_file_id": tool_file_id},
|
||||
tenant_id="tenant-1",
|
||||
access_controller=DatabaseFileAccessController(),
|
||||
session.add_all(
|
||||
[
|
||||
published,
|
||||
draft,
|
||||
make_app(app_id=app_id, tenant_id=harness.tenant_id, workflow_id=published.id, mode=AppMode.WORKFLOW),
|
||||
trigger,
|
||||
WorkflowWebhookTrigger(
|
||||
tenant_id=harness.tenant_id,
|
||||
app_id=app_id,
|
||||
webhook_id=webhook_id,
|
||||
node_id="webhook-node",
|
||||
created_by=harness.account_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
return _Webhook(app_id, webhook_id, published.id, draft.id, trigger.id)
|
||||
|
||||
|
||||
def _configure(harness: _Harness, webhook: _Webhook, **values: object) -> None:
|
||||
with harness.session_factory.begin() as session:
|
||||
for workflow_id in (webhook.workflow_id, webhook.draft_id):
|
||||
workflow = session.get(Workflow, workflow_id)
|
||||
assert workflow is not None
|
||||
graph = json.loads(workflow.graph)
|
||||
graph["nodes"][0]["data"].update(values)
|
||||
workflow.graph = json.dumps(graph)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def broker() -> Iterator[SimpleQueue]:
|
||||
previous_app = execute_workflow_sandbox.app
|
||||
app = Celery("native-webhook-http-test", broker="memory://", set_as_current=False)
|
||||
app.conf.update(task_always_eager=False, task_serializer="json")
|
||||
app.set_current()
|
||||
try:
|
||||
with app.connection_for_write() as connection:
|
||||
with SimpleQueue(connection.channel(), AsyncWorkflowQueue.SANDBOX_QUEUE) as queue:
|
||||
queue.clear()
|
||||
try:
|
||||
yield queue
|
||||
finally:
|
||||
queue.clear()
|
||||
finally:
|
||||
previous_app.set_current()
|
||||
app.close()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Billing:
|
||||
requests: list[tuple[str, dict[str, object]]] = field(default_factory=list)
|
||||
open_connections: list[int] = field(default_factory=list)
|
||||
exhausted_feature: str | None = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def billing(sqlite_engine: Engine) -> Iterator[_Billing]:
|
||||
billing = _Billing()
|
||||
pool = sqlite_engine.pool
|
||||
assert isinstance(pool, QueuePool)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
self.respond(
|
||||
{
|
||||
"enabled": True,
|
||||
"subscription": {
|
||||
"plan": "sandbox",
|
||||
"interval": "month",
|
||||
"education": False,
|
||||
"status": "active",
|
||||
"cancel_at_period_end": False,
|
||||
"paid_period_end": 0,
|
||||
},
|
||||
"members": {"size": 1, "limit": 10},
|
||||
"apps": {"size": 1, "limit": 10},
|
||||
"vector_space": None,
|
||||
"knowledge_rate_limit": {"size": 0, "limit": 10},
|
||||
"documents_upload_quota": {"size": 0, "limit": 10},
|
||||
"annotation_quota_limit": {"size": 0, "limit": 10},
|
||||
"docs_processing": "standard",
|
||||
"can_replace_logo": False,
|
||||
"model_load_balancing_enabled": False,
|
||||
"knowledge_pipeline_publish_enabled": False,
|
||||
"next_credit_reset_date": 0,
|
||||
}
|
||||
)
|
||||
assert file.related_id == tool_file_id
|
||||
assert end_users.calls == [(EndUserType.TRIGGER, "tenant-1", "app-1", None)]
|
||||
|
||||
def do_POST(self) -> None:
|
||||
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
billing.requests.append((self.path, payload))
|
||||
billing.open_connections.append(pool.checkedout())
|
||||
if self.path == "/quota/reserve":
|
||||
exhausted = payload["feature_key"] == billing.exhausted_feature
|
||||
self.respond(
|
||||
{
|
||||
"reservation_id": "" if exhausted else str(uuid4()),
|
||||
"available": 0 if exhausted else 10,
|
||||
"reserved": 0 if exhausted else 1,
|
||||
}
|
||||
)
|
||||
elif self.path == "/quota/commit":
|
||||
self.respond({"available": 9, "reserved": 0, "refunded": 0})
|
||||
else:
|
||||
assert self.path == "/quota/release"
|
||||
self.respond({"available": 10, "reserved": 0, "released": 1})
|
||||
|
||||
def respond(self, data: dict[str, object]) -> None:
|
||||
body = json.dumps(data).encode()
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
@override
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
url = f"http://127.0.0.1:{server.server_port}"
|
||||
with config_overrides_context(BILLING_API_URL=url, BILLING_QUOTA_API_URL=url):
|
||||
yield billing
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
class TestHandleWebhook:
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
@patch.object(module.WebhookService, "trigger_workflow_execution")
|
||||
@patch.object(module.WebhookService, "generate_webhook_response")
|
||||
def test_success(
|
||||
self,
|
||||
mock_generate,
|
||||
mock_trigger,
|
||||
mock_extract,
|
||||
mock_get,
|
||||
end_user_commands,
|
||||
):
|
||||
mock_get.return_value = (_webhook_trigger(), _workflow(), "node_config")
|
||||
mock_extract.return_value = {"input": "x"}
|
||||
mock_generate.return_value = ({"ok": True}, 200)
|
||||
def test_success_publishes_actual_workflow_task_and_preserves_actor_and_inputs(
|
||||
harness: _Harness, webhook: _Webhook, broker: SimpleQueue, billing: _Billing
|
||||
) -> None:
|
||||
with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD):
|
||||
response = Context().run(harness.client.post, webhook.url + "?attempt=2", json={"ref": "main"})
|
||||
|
||||
response, status = module.handle_webhook("wh-1")
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.get_json() == {"status": "success", "message": "Webhook processed successfully"}
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
message = broker.get(block=False)
|
||||
with harness.session_factory() as session:
|
||||
log = session.scalar(select(WorkflowTriggerLog).where(WorkflowTriggerLog.app_id == webhook.app_id))
|
||||
actor = session.scalar(select(EndUser).where(EndUser.app_id == webhook.app_id))
|
||||
assert log is not None
|
||||
assert actor is not None
|
||||
assert actor.tenant_id == harness.tenant_id
|
||||
assert actor.type == EndUserType.TRIGGER
|
||||
assert log.created_by == actor.id
|
||||
assert log.tenant_id == harness.tenant_id
|
||||
assert log.workflow_id == webhook.workflow_id
|
||||
assert log.root_node_id == webhook.node_id
|
||||
assert log.status == WorkflowTriggerStatus.QUEUED
|
||||
inputs = json.loads(log.inputs)
|
||||
assert inputs["webhook_body"] == {"ref": "main"}
|
||||
assert inputs["webhook_query_params"] == {"attempt": "2"}
|
||||
assert inputs["webhook_data"]["method"] == "POST"
|
||||
assert message.payload[0] == [{"workflow_trigger_log_id": log.id}]
|
||||
assert message.headers["id"] == log.celery_task_id
|
||||
assert message.headers["task"] == execute_workflow_sandbox.name
|
||||
message.ack()
|
||||
assert broker.qsize() == 0
|
||||
assert billing.open_connections
|
||||
assert not any(billing.open_connections)
|
||||
|
||||
assert status == 200
|
||||
assert response["ok"] is True
|
||||
mock_trigger.assert_called_once()
|
||||
assert mock_trigger.call_args.kwargs["end_users"] is end_user_commands
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data", side_effect=ValueError("bad"))
|
||||
def test_bad_request(self, mock_extract, mock_get):
|
||||
mock_get.return_value = (_webhook_trigger(), _workflow(), "node_config")
|
||||
@pytest.mark.parametrize(
|
||||
("body", "status", "expected"),
|
||||
[
|
||||
('{"accepted":true}', 202, {"accepted": True}),
|
||||
('["one",2]', 201, ["one", 2]),
|
||||
("accepted", 200, {"message": "accepted"}),
|
||||
],
|
||||
)
|
||||
def test_configured_response_preserves_status_and_json_shape(
|
||||
harness: _Harness, webhook: _Webhook, broker: SimpleQueue, body: str, status: int, expected: object
|
||||
) -> None:
|
||||
_configure(harness, webhook, response_body=body, status_code=status)
|
||||
response = Context().run(harness.client.post, webhook.url, json={})
|
||||
assert response.status_code == status
|
||||
assert response.get_json() == expected
|
||||
assert broker.qsize() == 1
|
||||
|
||||
response, status = module.handle_webhook("wh-1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "Bad Request"
|
||||
@pytest.mark.parametrize("debug", [False, True])
|
||||
@pytest.mark.parametrize("problem", ["method", "json", "required"])
|
||||
def test_request_errors_are_400_before_execution(
|
||||
harness: _Harness, webhook: _Webhook, debug: bool, problem: str
|
||||
) -> None:
|
||||
url = webhook.debug_url if debug else webhook.url
|
||||
if problem == "method":
|
||||
response = Context().run(harness.client.get, url)
|
||||
elif problem == "json":
|
||||
response = Context().run(harness.client.post, url, data="{bad", content_type="application/json")
|
||||
else:
|
||||
_configure(harness, webhook, body=[{"name": "required", "type": "string", "required": True}])
|
||||
response = Context().run(harness.client.post, url, json={})
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.get_json()["error"] == "Bad Request"
|
||||
assert response.get_json()["message"]
|
||||
with harness.session_factory() as session:
|
||||
assert session.scalar(select(WorkflowTriggerLog)) is None
|
||||
assert session.scalar(select(EndUser)) is None
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
@patch.object(
|
||||
module.WebhookService,
|
||||
"trigger_workflow_execution",
|
||||
side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1),
|
||||
|
||||
@pytest.mark.parametrize("debug", [False, True])
|
||||
def test_invalid_stored_webhook_configuration_does_not_expose_details(
|
||||
harness: _Harness, webhook: _Webhook, debug: bool
|
||||
) -> None:
|
||||
_configure(harness, webhook, body=[{"name": "amount", "type": "private-config-value", "required": True}])
|
||||
|
||||
response = Context().run(harness.client.post, webhook.debug_url if debug else webhook.url, json={"amount": 1})
|
||||
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
assert response.get_json() == {"error": "Internal server error", "message": "An internal error has occurred."}
|
||||
assert b"private-config-value" not in response.data
|
||||
with harness.session_factory() as session:
|
||||
assert session.scalar(select(WorkflowTriggerLog)) is None
|
||||
assert session.scalar(select(EndUser)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("debug", [False, True])
|
||||
def test_unknown_webhook_is_opaque_noncacheable_404(harness: _Harness, webhook: _Webhook, debug: bool) -> None:
|
||||
url = webhook.debug_url if debug else webhook.url
|
||||
with harness.session_factory.begin() as session:
|
||||
session.execute(delete(WorkflowWebhookTrigger))
|
||||
response = Context().run(harness.client.post, url, json={})
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.content_type == "text/html; charset=utf-8"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
assert webhook.webhook_id not in response.get_data(as_text=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("debug", [False, True])
|
||||
@pytest.mark.parametrize("broken_graph", ["missing-node", "empty-nodes", "malformed"])
|
||||
def test_webhook_distinguishes_missing_node_from_corrupt_workflow(
|
||||
harness: _Harness, webhook: _Webhook, debug: bool, broken_graph: str
|
||||
) -> None:
|
||||
workflow_id = webhook.draft_id if debug else webhook.workflow_id
|
||||
with harness.session_factory.begin() as session:
|
||||
workflow = session.get(Workflow, workflow_id)
|
||||
assert workflow is not None
|
||||
if broken_graph == "missing-node":
|
||||
graph = json.loads(workflow.graph)
|
||||
graph["nodes"][0]["id"] = "different-node"
|
||||
workflow.graph = json.dumps(graph)
|
||||
elif broken_graph == "empty-nodes":
|
||||
workflow.graph = '{"nodes": [], "edges": []}'
|
||||
else:
|
||||
workflow.graph = "{malformed"
|
||||
|
||||
response = Context().run(harness.client.post, webhook.debug_url if debug else webhook.url, json={})
|
||||
|
||||
if broken_graph == "missing-node":
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
else:
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
assert response.get_json() == {"error": "Internal server error", "message": "An internal error has occurred."}
|
||||
with harness.session_factory() as session:
|
||||
assert session.scalar(select(WorkflowTriggerLog)) is None
|
||||
assert session.scalar(select(EndUser)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("debug", [False, True])
|
||||
def test_body_limit_remains_413(harness: _Harness, webhook: _Webhook, debug: bool) -> None:
|
||||
with config_overrides_context(WEBHOOK_REQUEST_BODY_MAX_SIZE=4):
|
||||
response = Context().run(harness.client.post, webhook.debug_url if debug else webhook.url, json={"large": True})
|
||||
assert response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [AppTriggerStatus.DISABLED, AppTriggerStatus.RATE_LIMITED])
|
||||
def test_published_status_blocks_execution_but_draft_still_reaches_request_validation(
|
||||
harness: _Harness, webhook: _Webhook, status: AppTriggerStatus
|
||||
) -> None:
|
||||
with harness.session_factory.begin() as session:
|
||||
trigger = session.get(AppTrigger, webhook.trigger_id)
|
||||
assert trigger is not None
|
||||
trigger.status = status
|
||||
response = Context().run(harness.client.post, webhook.url, json={})
|
||||
assert response.status_code == (
|
||||
HTTPStatus.NOT_FOUND if status == AppTriggerStatus.DISABLED else HTTPStatus.TOO_MANY_REQUESTS
|
||||
)
|
||||
def test_quota_exceeded(self, mock_trigger, mock_extract, mock_get):
|
||||
mock_get.return_value = (_webhook_trigger(), _workflow(), "node_config")
|
||||
mock_extract.return_value = {"input": "x"}
|
||||
|
||||
response, status = module.handle_webhook("wh-1")
|
||||
|
||||
assert status == 429
|
||||
assert response == {
|
||||
if status == AppTriggerStatus.RATE_LIMITED:
|
||||
assert response.get_json() == {
|
||||
"error": "Too Many Requests",
|
||||
"message": "Trigger event quota exceeded. Please upgrade your plan.",
|
||||
}
|
||||
debug = Context().run(harness.client.post, webhook.debug_url, data="{bad", content_type="application/json")
|
||||
assert debug.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
@patch.object(
|
||||
module.WebhookService,
|
||||
"trigger_workflow_execution",
|
||||
side_effect=QuotaExceededError(feature="workflow", tenant_id="tenant-1", required=1),
|
||||
)
|
||||
def test_workflow_quota_exceeded(self, mock_trigger, mock_extract, mock_get):
|
||||
mock_get.return_value = (_webhook_trigger(), _workflow(), "node_config")
|
||||
mock_extract.return_value = {"input": "x"}
|
||||
|
||||
response, status = module.handle_webhook("wh-1")
|
||||
@pytest.mark.parametrize("status", [AppTriggerStatus.DISABLED, AppTriggerStatus.RATE_LIMITED])
|
||||
@pytest.mark.parametrize("broken_workflow", ["missing", "empty-nodes", "malformed-graph"])
|
||||
def test_unavailable_trigger_status_precedes_published_workflow_lookup_and_parsing(
|
||||
harness: _Harness, webhook: _Webhook, status: AppTriggerStatus, broken_workflow: str
|
||||
) -> None:
|
||||
with harness.session_factory.begin() as session:
|
||||
trigger = session.get(AppTrigger, webhook.trigger_id)
|
||||
workflow = session.get(Workflow, webhook.workflow_id)
|
||||
assert trigger is not None
|
||||
assert workflow is not None
|
||||
trigger.status = status
|
||||
if broken_workflow == "missing":
|
||||
session.delete(workflow)
|
||||
elif broken_workflow == "empty-nodes":
|
||||
workflow.graph = '{"nodes": [], "edges": []}'
|
||||
else:
|
||||
workflow.graph = "{malformed"
|
||||
|
||||
assert status == 429
|
||||
assert response == {
|
||||
"error": "Too Many Requests",
|
||||
"message": "Workflow execution quota exceeded. Please upgrade your plan.",
|
||||
}
|
||||
response = Context().run(harness.client.post, webhook.url, json={})
|
||||
|
||||
@patch.object(
|
||||
module.WebhookService,
|
||||
"get_webhook_trigger_and_workflow",
|
||||
side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1),
|
||||
)
|
||||
def test_rate_limited(self, mock_get):
|
||||
response, status = module.handle_webhook("wh-1")
|
||||
|
||||
assert status == 429
|
||||
assert response == {
|
||||
if status == AppTriggerStatus.RATE_LIMITED:
|
||||
assert response.status_code == HTTPStatus.TOO_MANY_REQUESTS
|
||||
assert response.get_json() == {
|
||||
"error": "Too Many Requests",
|
||||
"message": "Trigger event quota exceeded. Please upgrade your plan.",
|
||||
}
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=ValueError("missing"))
|
||||
def test_value_error_not_found(self, mock_get):
|
||||
response = module.handle_webhook("wh-1")
|
||||
assert response.status_code == 404
|
||||
assert response.data == NotFound().get_body().encode()
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=RequestEntityTooLarge())
|
||||
def test_request_entity_too_large(self, mock_get):
|
||||
with pytest.raises(RequestEntityTooLarge):
|
||||
module.handle_webhook("wh-1")
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=Exception("boom"))
|
||||
def test_internal_error(self, mock_get):
|
||||
response, status = module.handle_webhook("wh-1")
|
||||
|
||||
assert status == 500
|
||||
assert response["error"] == "Internal server error"
|
||||
else:
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.content_type == "text/html; charset=utf-8"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
# The intact draft continues to use its own config despite the published status and corruption.
|
||||
debug = Context().run(harness.client.post, webhook.debug_url, data="{bad", content_type="application/json")
|
||||
assert debug.status_code == HTTPStatus.BAD_REQUEST
|
||||
with harness.session_factory() as session:
|
||||
assert session.scalar(select(WorkflowTriggerLog)) is None
|
||||
assert session.scalar(select(EndUser)) is None
|
||||
|
||||
|
||||
class TestHandleWebhookDebug:
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
@patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
|
||||
@patch.object(module.TriggerDebugEventBus, "dispatch", return_value=0)
|
||||
def test_debug_requires_active_listener(
|
||||
self,
|
||||
mock_dispatch,
|
||||
mock_build_inputs,
|
||||
mock_extract,
|
||||
mock_get,
|
||||
):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
mock_get.return_value = (webhook_trigger, None, "node_config")
|
||||
mock_extract.return_value = {"method": "POST"}
|
||||
|
||||
response, status = module.handle_webhook_debug("wh-1")
|
||||
|
||||
assert status == 409
|
||||
assert response["error"] == "No active debug listener"
|
||||
assert response["message"] == (
|
||||
"The webhook debug URL only works while the Variable Inspector is listening. "
|
||||
"Use the published webhook URL to execute the workflow in Celery."
|
||||
@pytest.mark.parametrize(
|
||||
("feature", "message"),
|
||||
[
|
||||
("trigger_event", "Trigger event quota exceeded. Please upgrade your plan."),
|
||||
("api_rate_limit", "Workflow execution quota exceeded. Please upgrade your plan."),
|
||||
],
|
||||
)
|
||||
def test_real_quota_denial_returns_specific_429_without_publishing(
|
||||
harness: _Harness, webhook: _Webhook, broker: SimpleQueue, billing: _Billing, feature: str, message: str
|
||||
) -> None:
|
||||
billing.exhausted_feature = feature
|
||||
with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD):
|
||||
response = Context().run(harness.client.post, webhook.url, json={})
|
||||
assert response.status_code == HTTPStatus.TOO_MANY_REQUESTS
|
||||
assert response.get_json() == {"error": "Too Many Requests", "message": message}
|
||||
assert broker.qsize() == 0
|
||||
with harness.session_factory() as session:
|
||||
log = session.scalar(select(WorkflowTriggerLog))
|
||||
if feature == "trigger_event":
|
||||
assert log is None
|
||||
else:
|
||||
assert log is not None
|
||||
assert log.status == WorkflowTriggerStatus.RATE_LIMITED
|
||||
assert log.celery_task_id is None
|
||||
trigger = session.get(AppTrigger, webhook.trigger_id)
|
||||
assert trigger is not None
|
||||
assert trigger.status == (
|
||||
AppTriggerStatus.RATE_LIMITED if feature == "trigger_event" else AppTriggerStatus.ENABLED
|
||||
)
|
||||
assert response["execution_url"] == webhook_trigger.webhook_url
|
||||
mock_dispatch.assert_called_once()
|
||||
assert not any(billing.open_connections)
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
|
||||
@patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
|
||||
@patch.object(module.TriggerDebugEventBus, "dispatch", return_value=1)
|
||||
@patch.object(module.WebhookService, "generate_webhook_response")
|
||||
def test_debug_success(
|
||||
self,
|
||||
mock_generate,
|
||||
mock_dispatch,
|
||||
mock_build_inputs,
|
||||
mock_extract,
|
||||
mock_get,
|
||||
):
|
||||
mock_get.return_value = (_webhook_trigger(), None, "node_config")
|
||||
mock_extract.return_value = {"method": "POST"}
|
||||
mock_generate.return_value = ({"ok": True}, 200)
|
||||
|
||||
response, status = module.handle_webhook_debug("wh-1")
|
||||
def test_corrupt_stored_workflow_returns_500_instead_of_client_validation_error(
|
||||
harness: _Harness, webhook: _Webhook
|
||||
) -> None:
|
||||
with harness.session_factory.begin() as session:
|
||||
workflow = session.get(Workflow, webhook.workflow_id)
|
||||
assert workflow is not None
|
||||
workflow.graph = '{"nodes": [], "edges": []}'
|
||||
response = Context().run(harness.client.post, webhook.url, json={})
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
assert response.get_json() == {"error": "Internal server error", "message": "An internal error has occurred."}
|
||||
|
||||
assert status == 200
|
||||
assert response["ok"] is True
|
||||
mock_dispatch.assert_called_once()
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
|
||||
@patch.object(module.WebhookService, "extract_and_validate_webhook_data", side_effect=ValueError("bad"))
|
||||
def test_debug_bad_request(self, mock_extract, mock_get):
|
||||
mock_get.return_value = (_webhook_trigger(), None, "node_config")
|
||||
@pytest.mark.parametrize("content_type", ["multipart/form-data", "application/octet-stream"])
|
||||
def test_uploaded_files_use_real_tool_file_storage_and_detached_workflow_inputs(
|
||||
harness: _Harness, webhook: _Webhook, broker: SimpleQueue, content_type: str
|
||||
) -> None:
|
||||
_configure(harness, webhook, content_type=content_type)
|
||||
content = b"webhook upload bytes"
|
||||
data = (
|
||||
{"upload": (BytesIO(content), "sample.txt"), "caption": "incoming"}
|
||||
if content_type == "multipart/form-data"
|
||||
else content
|
||||
)
|
||||
|
||||
response, status = module.handle_webhook_debug("wh-1")
|
||||
def send() -> TestResponse:
|
||||
# The minimal Flask harness binds the same concrete file runtime as production DifyAppContext.
|
||||
with use_workflow_file_runtime(create_dify_workflow_file_runtime()):
|
||||
return harness.client.post(webhook.url, data=data, content_type=content_type)
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "Bad Request"
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=ValueError("missing"))
|
||||
def test_debug_not_found(self, mock_get):
|
||||
response = module.handle_webhook_debug("wh-1")
|
||||
assert response.status_code == 404
|
||||
assert response.data == NotFound().get_body().encode()
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=RequestEntityTooLarge())
|
||||
def test_debug_request_entity_too_large(self, mock_get):
|
||||
with pytest.raises(RequestEntityTooLarge):
|
||||
module.handle_webhook_debug("wh-1")
|
||||
|
||||
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=Exception("boom"))
|
||||
def test_debug_internal_error(self, mock_get):
|
||||
response, status = module.handle_webhook_debug("wh-1")
|
||||
|
||||
assert status == 500
|
||||
assert response["error"] == "Internal server error"
|
||||
response = Context().run(send)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert broker.qsize() == 1
|
||||
with harness.session_factory() as session:
|
||||
tool_file = session.scalar(select(ToolFile))
|
||||
actor = session.scalar(select(EndUser).where(EndUser.app_id == webhook.app_id))
|
||||
assert tool_file is not None
|
||||
assert actor is not None
|
||||
assert tool_file.tenant_id == harness.tenant_id
|
||||
assert tool_file.user_id == actor.id
|
||||
assert tool_file.size == len(content)
|
||||
file_key = tool_file.file_key
|
||||
log = session.scalar(select(WorkflowTriggerLog).where(WorkflowTriggerLog.app_id == webhook.app_id))
|
||||
assert log is not None
|
||||
assert log.created_by == actor.id
|
||||
inputs = json.loads(log.inputs)
|
||||
saved_file = (
|
||||
inputs["webhook_data"]["files"]["upload"]
|
||||
if content_type == "multipart/form-data"
|
||||
else inputs["webhook_body"]["raw"]
|
||||
)
|
||||
assert saved_file["transfer_method"] == "tool_file"
|
||||
assert saved_file["size"] == len(content)
|
||||
assert saved_file["mime_type"] == tool_file.mimetype
|
||||
assert storage.load_once(file_key) == content
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Real HTTP requests and value conversion retain the native webhook contract."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
from flask import Flask, request
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
|
||||
from controllers.trigger.webhook_request import WebhookRequestParser
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from core.workflow.nodes.trigger_webhook.entities import (
|
||||
ContentType,
|
||||
WebhookBodyParameter,
|
||||
WebhookData,
|
||||
WebhookParameter,
|
||||
)
|
||||
from graphon.entities.graph_config import NodeConfigDict
|
||||
from graphon.variables.types import SegmentType
|
||||
from services.trigger.webhook_gateway import ToolWebhookFiles
|
||||
from services.trigger.webhook_service import RawWebhookDataDict, WebhookContext, build_webhook_workflow_inputs
|
||||
from tests.helpers.unit.config_override import config_overrides_context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app() -> Flask:
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser() -> WebhookRequestParser:
|
||||
return WebhookRequestParser(
|
||||
request=request, files=ToolWebhookFiles(manager=ToolFileManager(), access=DatabaseFileAccessController())
|
||||
)
|
||||
|
||||
|
||||
def _webhook_trigger() -> WebhookContext:
|
||||
return WebhookContext(
|
||||
tenant_id="tenant-123",
|
||||
app_id="app-123",
|
||||
node_id="node-123",
|
||||
created_by="account-123",
|
||||
webhook_id="webhook-123",
|
||||
workflow_id="workflow-123",
|
||||
node_config=_node({}),
|
||||
status=None,
|
||||
)
|
||||
|
||||
|
||||
def _workflow_trigger() -> WebhookContext:
|
||||
return _webhook_trigger()
|
||||
|
||||
|
||||
class TestWebhookRequestParserUnit:
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("1234567890123456789", 1234567890123456789),
|
||||
("9007199254740993", 9007199254740993),
|
||||
("-9007199254740993", -9007199254740993),
|
||||
("1e3", 1000),
|
||||
],
|
||||
)
|
||||
def test_form_number_preserves_exact_integers(self, parser: WebhookRequestParser, raw: str, expected: int) -> None:
|
||||
assert parser._convert_form_value("amount", raw, SegmentType.NUMBER) == expected
|
||||
|
||||
@pytest.mark.parametrize("raw", ["nan", "NaN", "inf", "-Infinity", "1e400"])
|
||||
def test_form_number_rejects_non_finite_values(self, parser: WebhookRequestParser, raw: str) -> None:
|
||||
with pytest.raises(ValueError, match="to number"):
|
||||
parser._convert_form_value("amount", raw, SegmentType.NUMBER)
|
||||
|
||||
@pytest.mark.parametrize(("value", "kind"), [(True, "number"), ([1, True], "array[number]")])
|
||||
def test_json_number_rejects_booleans(self, parser: WebhookRequestParser, value: object, kind: str) -> None:
|
||||
with pytest.raises(ValueError, match="Expected"):
|
||||
parser._validate_json_value("amount", value, kind)
|
||||
|
||||
def test_rejects_oversized_request(self, parser: WebhookRequestParser, flask_app: Flask) -> None:
|
||||
with config_overrides_context(WEBHOOK_REQUEST_BODY_MAX_SIZE=1):
|
||||
with flask_app.test_request_context("/webhook", method="POST", data="ab"):
|
||||
with pytest.raises(RequestEntityTooLarge):
|
||||
parser.extract_webhook_data(_webhook_trigger())
|
||||
|
||||
def test_extract_webhook_data_json(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook data extraction from JSON request."""
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json", "Authorization": "Bearer token"},
|
||||
query_string="version=1&format=json",
|
||||
json={"message": "hello", "count": 42},
|
||||
):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
webhook_data = parser.extract_webhook_data(webhook_trigger)
|
||||
assert webhook_data["method"] == "POST"
|
||||
assert webhook_data["headers"]["Authorization"] == "Bearer token"
|
||||
assert webhook_data["query_params"]["version"] == "1"
|
||||
assert webhook_data["query_params"]["format"] == "json"
|
||||
assert webhook_data["body"]["message"] == "hello"
|
||||
assert webhook_data["body"]["count"] == 42
|
||||
assert webhook_data["files"] == {}
|
||||
|
||||
def test_extract_webhook_data_query_params_remain_strings(self, parser: WebhookRequestParser) -> None:
|
||||
"""Query parameters should be extracted as raw strings without automatic conversion."""
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook",
|
||||
method="GET",
|
||||
headers={"Content-Type": "application/json"},
|
||||
query_string="count=42&threshold=3.14&enabled=true¬e=text",
|
||||
):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
webhook_data = parser.extract_webhook_data(webhook_trigger)
|
||||
assert webhook_data["query_params"]["count"] == "42"
|
||||
assert webhook_data["query_params"]["threshold"] == "3.14"
|
||||
assert webhook_data["query_params"]["enabled"] == "true"
|
||||
assert webhook_data["query_params"]["note"] == "text"
|
||||
|
||||
def test_extract_webhook_data_form_urlencoded(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook data extraction from form URL encoded request."""
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={"username": "test", "password": "secret"},
|
||||
):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
webhook_data = parser.extract_webhook_data(webhook_trigger)
|
||||
assert webhook_data["method"] == "POST"
|
||||
assert webhook_data["body"]["username"] == "test"
|
||||
assert webhook_data["body"]["password"] == "secret"
|
||||
|
||||
def test_extract_webhook_data_raw_text(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook data extraction from raw text request."""
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook", method="POST", headers={"Content-Type": "text/plain"}, data="raw text content"
|
||||
):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
webhook_data = parser.extract_webhook_data(webhook_trigger)
|
||||
assert webhook_data["method"] == "POST"
|
||||
assert webhook_data["body"]["raw"] == "raw text content"
|
||||
|
||||
def test_extract_webhook_data_invalid_json(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook data extraction with invalid JSON."""
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook", method="POST", headers={"Content-Type": "application/json"}, data="invalid json"
|
||||
):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
with pytest.raises(ValueError, match="Invalid JSON body"):
|
||||
parser.extract_webhook_data(webhook_trigger)
|
||||
|
||||
def test_generate_webhook_response_default(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook response generation with default values."""
|
||||
node_config = _node({})
|
||||
response_data, status_code = parser.generate_webhook_response(node_config)
|
||||
assert status_code == 200
|
||||
assert response_data["status"] == "success"
|
||||
assert "Webhook processed successfully" in response_data["message"]
|
||||
|
||||
def test_generate_webhook_response_custom_json(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook response generation with custom JSON response."""
|
||||
node_config = _node({"status_code": 201, "response_body": '{"result": "created", "id": 123}'})
|
||||
response_data, status_code = parser.generate_webhook_response(node_config)
|
||||
assert status_code == 201
|
||||
assert response_data["result"] == "created"
|
||||
assert response_data["id"] == 123
|
||||
|
||||
def test_generate_webhook_response_custom_text(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook response generation with custom text response."""
|
||||
node_config = _node({"status_code": 202, "response_body": "Request accepted for processing"})
|
||||
response_data, status_code = parser.generate_webhook_response(node_config)
|
||||
assert status_code == 202
|
||||
assert response_data["message"] == "Request accepted for processing"
|
||||
|
||||
def test_generate_webhook_response_invalid_json(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook response generation with invalid JSON response."""
|
||||
node_config = _node({"status_code": 400, "response_body": '{"invalid": json}'})
|
||||
response_data, status_code = parser.generate_webhook_response(node_config)
|
||||
assert status_code == 400
|
||||
assert response_data["message"] == '{"invalid": json}'
|
||||
|
||||
def test_generate_webhook_response_empty_response_body(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook response generation with empty response body."""
|
||||
node_config = _node({"status_code": 204, "response_body": ""})
|
||||
response_data, status_code = parser.generate_webhook_response(node_config)
|
||||
assert status_code == 204
|
||||
assert response_data["status"] == "success"
|
||||
assert "Webhook processed successfully" in response_data["message"]
|
||||
|
||||
def test_generate_webhook_response_array_json(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test webhook response generation with JSON array response."""
|
||||
node_config = _node({"status_code": 200, "response_body": '[{"id": 1}, {"id": 2}]'})
|
||||
response_data, status_code = parser.generate_webhook_response(node_config)
|
||||
assert status_code == 200
|
||||
assert isinstance(response_data, list)
|
||||
assert len(response_data) == 2
|
||||
assert response_data[0]["id"] == 1
|
||||
assert response_data[1]["id"] == 2
|
||||
|
||||
def test_validate_json_value_string(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test JSON value validation for string type."""
|
||||
result = parser._validate_json_value("name", "hello", "string")
|
||||
assert result == "hello"
|
||||
with pytest.raises(ValueError, match="Expected string, got int"):
|
||||
parser._validate_json_value("name", 123, "string")
|
||||
|
||||
def test_validate_json_value_number(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test JSON value validation for number type."""
|
||||
result = parser._validate_json_value("count", 42, "number")
|
||||
assert result == 42
|
||||
result = parser._validate_json_value("price", 19.99, "number")
|
||||
assert result == 19.99
|
||||
with pytest.raises(ValueError, match="Expected number, got str"):
|
||||
parser._validate_json_value("count", "42", "number")
|
||||
|
||||
def test_validate_json_value_bool(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test JSON value validation for boolean type."""
|
||||
result = parser._validate_json_value("enabled", True, "boolean")
|
||||
assert result is True
|
||||
result = parser._validate_json_value("enabled", False, "boolean")
|
||||
assert result is False
|
||||
with pytest.raises(ValueError, match="Expected boolean, got str"):
|
||||
parser._validate_json_value("enabled", "true", "boolean")
|
||||
|
||||
def test_validate_json_value_object(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test JSON value validation for object type."""
|
||||
result = parser._validate_json_value("user", {"name": "John", "age": 30}, "object")
|
||||
assert result == {"name": "John", "age": 30}
|
||||
with pytest.raises(ValueError, match="Expected object, got str"):
|
||||
parser._validate_json_value("user", "not_an_object", "object")
|
||||
|
||||
def test_validate_json_value_array_string(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test JSON value validation for array[string] type."""
|
||||
result = parser._validate_json_value("tags", ["tag1", "tag2", "tag3"], "array[string]")
|
||||
assert result == ["tag1", "tag2", "tag3"]
|
||||
with pytest.raises(ValueError, match="Expected array of strings, got str"):
|
||||
parser._validate_json_value("tags", "not_an_array", "array[string]")
|
||||
with pytest.raises(ValueError, match="Expected array of strings, got list"):
|
||||
parser._validate_json_value("tags", ["tag1", 123, "tag3"], "array[string]")
|
||||
|
||||
def test_validate_json_value_array_number(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test JSON value validation for array[number] type."""
|
||||
result = parser._validate_json_value("scores", [1, 2.5, 3, 4.7], "array[number]")
|
||||
assert result == [1, 2.5, 3, 4.7]
|
||||
with pytest.raises(ValueError, match="Expected array of numbers, got list"):
|
||||
parser._validate_json_value("scores", [1, "2", 3], "array[number]")
|
||||
|
||||
def test_validate_json_value_array_bool(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test JSON value validation for array[boolean] type."""
|
||||
result = parser._validate_json_value("flags", [True, False, True], "array[boolean]")
|
||||
assert result == [True, False, True]
|
||||
with pytest.raises(ValueError, match="Expected array of booleans, got list"):
|
||||
parser._validate_json_value("flags", [True, "false", True], "array[boolean]")
|
||||
|
||||
def test_validate_json_value_array_object(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test JSON value validation for array[object] type."""
|
||||
result = parser._validate_json_value("users", [{"name": "John"}, {"name": "Jane"}], "array[object]")
|
||||
assert result == [{"name": "John"}, {"name": "Jane"}]
|
||||
with pytest.raises(ValueError, match="Expected array of objects, got list"):
|
||||
parser._validate_json_value("users", [{"name": "John"}, "not_object"], "array[object]")
|
||||
|
||||
def test_convert_form_value_string(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test form value conversion for string type."""
|
||||
result = parser._convert_form_value("test", "hello", "string")
|
||||
assert result == "hello"
|
||||
|
||||
def test_convert_form_value_number(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test form value conversion for number type."""
|
||||
result = parser._convert_form_value("count", "42", "number")
|
||||
assert result == 42
|
||||
result = parser._convert_form_value("price", "19.99", "number")
|
||||
assert result == 19.99
|
||||
with pytest.raises(ValueError, match="Cannot convert 'not_a_number' to number"):
|
||||
parser._convert_form_value("count", "not_a_number", "number")
|
||||
|
||||
def test_convert_form_value_boolean(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test form value conversion for boolean type."""
|
||||
assert parser._convert_form_value("flag", "true", "boolean") is True
|
||||
assert parser._convert_form_value("flag", "1", "boolean") is True
|
||||
assert parser._convert_form_value("flag", "yes", "boolean") is True
|
||||
assert parser._convert_form_value("flag", "false", "boolean") is False
|
||||
assert parser._convert_form_value("flag", "0", "boolean") is False
|
||||
assert parser._convert_form_value("flag", "no", "boolean") is False
|
||||
with pytest.raises(ValueError, match="Cannot convert 'maybe' to boolean"):
|
||||
parser._convert_form_value("flag", "maybe", "boolean")
|
||||
|
||||
def test_extract_and_validate_webhook_data_success(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test successful unified data extraction and validation."""
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
query_string="count=42&enabled=true",
|
||||
json={"message": "hello", "age": 25},
|
||||
):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
node_config = _node(
|
||||
{
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"params": [
|
||||
{"name": "count", "type": "number", "required": True},
|
||||
{"name": "enabled", "type": "boolean", "required": True},
|
||||
],
|
||||
"body": [
|
||||
{"name": "message", "type": "string", "required": True},
|
||||
{"name": "age", "type": "number", "required": True},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = parser.extract_and_validate_webhook_data(webhook_trigger, node_config)
|
||||
assert result["query_params"]["count"] == 42
|
||||
assert result["query_params"]["enabled"] is True
|
||||
assert result["body"]["message"] == "hello"
|
||||
assert result["body"]["age"] == 25
|
||||
|
||||
def test_extract_and_validate_webhook_data_invalid_json_error(self, parser: WebhookRequestParser) -> None:
|
||||
"""Invalid JSON should bubble up as a ValueError with details."""
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook", method="POST", headers={"Content-Type": "application/json"}, data='{"invalid": }'
|
||||
):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
node_config = _node({"method": "post", "content_type": "application/json"})
|
||||
with pytest.raises(ValueError, match="Invalid JSON body"):
|
||||
parser.extract_and_validate_webhook_data(webhook_trigger, node_config)
|
||||
|
||||
def test_extract_and_validate_webhook_data_validation_error(self, parser: WebhookRequestParser) -> None:
|
||||
"""Test unified data extraction with validation error."""
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context("/webhook", method="GET", headers={"Content-Type": "application/json"}):
|
||||
webhook_trigger = _webhook_trigger()
|
||||
node_config = _node({"method": "post", "content_type": "application/json"})
|
||||
with pytest.raises(ValueError, match="HTTP method mismatch"):
|
||||
parser.extract_and_validate_webhook_data(webhook_trigger, node_config)
|
||||
|
||||
def test_extract_and_validate_webhook_request_missing_required_header(self, parser: WebhookRequestParser) -> None:
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context("/webhook", method="POST", headers={"Content-Type": "application/json"}):
|
||||
node_config = _node(
|
||||
{
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"headers": [{"name": "Authorization", "required": True}],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="Required header missing: Authorization"):
|
||||
parser.extract_and_validate_webhook_data(_webhook_trigger(), node_config)
|
||||
|
||||
def test_extract_and_validate_webhook_request_case_insensitive_headers(self, parser: WebhookRequestParser) -> None:
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json", "authorization": "Bearer token"},
|
||||
json={"message": "hello"},
|
||||
):
|
||||
node_config = _node(
|
||||
{
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"headers": [{"name": "Authorization", "required": True}],
|
||||
"body": [{"name": "message", "type": "string", "required": True}],
|
||||
}
|
||||
)
|
||||
result = parser.extract_and_validate_webhook_data(_webhook_trigger(), node_config)
|
||||
assert result["headers"].get("Authorization") == "Bearer token"
|
||||
|
||||
def test_extract_and_validate_webhook_request_missing_required_param(self, parser: WebhookRequestParser) -> None:
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook", method="POST", headers={"Content-Type": "application/json"}, json={"message": "hello"}
|
||||
):
|
||||
node_config = _node(
|
||||
{
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"params": [{"name": "version", "required": True}],
|
||||
"body": [{"name": "message", "type": "string", "required": True}],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="Required parameter missing: version"):
|
||||
parser.extract_and_validate_webhook_data(_webhook_trigger(), node_config)
|
||||
|
||||
def test_extract_and_validate_webhook_request_missing_required_body_param(
|
||||
self, parser: WebhookRequestParser
|
||||
) -> None:
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context("/webhook", method="POST", headers={"Content-Type": "application/json"}, json={}):
|
||||
node_config = _node(
|
||||
{
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"body": [{"name": "message", "type": "string", "required": True}],
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="Required body parameter missing: message"):
|
||||
parser.extract_and_validate_webhook_data(_webhook_trigger(), node_config)
|
||||
|
||||
def test_extract_and_validate_webhook_request_missing_required_file(self, parser: WebhookRequestParser) -> None:
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/webhook", method="POST", data={"note": "test"}, content_type="multipart/form-data"
|
||||
):
|
||||
node_config = _node(
|
||||
{
|
||||
"method": "post",
|
||||
"content_type": "multipart/form-data",
|
||||
"body": [{"name": "file", "type": "file", "required": True}],
|
||||
}
|
||||
)
|
||||
result = parser.extract_and_validate_webhook_data(_webhook_trigger(), node_config)
|
||||
assert result["files"] == {}
|
||||
|
||||
|
||||
class TestWebhookRequestParserExtractionFallbacks:
|
||||
def test_extract_octet_stream_body_should_return_none_when_empty_payload(
|
||||
self, parser: WebhookRequestParser, flask_app: Flask
|
||||
) -> None:
|
||||
webhook_trigger = _workflow_trigger()
|
||||
with flask_app.test_request_context("/webhook", method="POST", data=b""):
|
||||
body, files = parser._extract_octet_stream_body(webhook_trigger)
|
||||
assert body == {"raw": None}
|
||||
assert files == {}
|
||||
|
||||
|
||||
class TestWebhookRequestParserValidationAndConversion:
|
||||
@pytest.mark.parametrize(
|
||||
("raw_value", "param_type", "expected"),
|
||||
[
|
||||
("42", SegmentType.NUMBER, 42),
|
||||
("3.14", SegmentType.NUMBER, float("3.14")),
|
||||
("yes", SegmentType.BOOLEAN, True),
|
||||
("no", SegmentType.BOOLEAN, False),
|
||||
],
|
||||
)
|
||||
def test_convert_form_value_should_convert_supported_types(
|
||||
self, parser: WebhookRequestParser, raw_value: str, param_type: str, expected: object
|
||||
) -> None:
|
||||
result = parser._convert_form_value("param", raw_value, param_type)
|
||||
assert result == expected
|
||||
|
||||
def test_convert_form_value_should_raise_for_unsupported_type(self, parser: WebhookRequestParser) -> None:
|
||||
with pytest.raises(ValueError, match="Unsupported type"):
|
||||
parser._convert_form_value("p", "x", SegmentType.FILE)
|
||||
|
||||
def test_validate_and_convert_value_should_wrap_conversion_errors(self, parser: WebhookRequestParser) -> None:
|
||||
with pytest.raises(ValueError, match="validation failed"):
|
||||
parser._validate_and_convert_value("param", "bad", SegmentType.NUMBER, is_form_data=True)
|
||||
|
||||
def test_process_parameters_should_raise_when_required_parameter_missing(
|
||||
self, parser: WebhookRequestParser
|
||||
) -> None:
|
||||
raw_params = {"optional": "x"}
|
||||
config = [WebhookParameter(name="required_param", type=SegmentType.STRING, required=True)]
|
||||
with pytest.raises(ValueError, match="Required parameter missing"):
|
||||
parser._process_parameters(raw_params, config, is_form_data=True)
|
||||
|
||||
def test_process_parameters_should_include_unconfigured_parameters(self, parser: WebhookRequestParser) -> None:
|
||||
raw_params = {"known": "1", "unknown": "x"}
|
||||
config = [WebhookParameter(name="known", type=SegmentType.NUMBER, required=False)]
|
||||
result = parser._process_parameters(raw_params, config, is_form_data=True)
|
||||
assert result == {"known": 1, "unknown": "x"}
|
||||
|
||||
def test_process_body_parameters_should_raise_when_required_text_raw_is_missing(
|
||||
self, parser: WebhookRequestParser
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match="Required body content missing"):
|
||||
parser._process_body_parameters(
|
||||
raw_body={"raw": ""},
|
||||
body_configs=[WebhookBodyParameter(name="raw", required=True)],
|
||||
content_type=ContentType.TEXT,
|
||||
)
|
||||
|
||||
def test_process_body_parameters_should_skip_file_config_for_multipart_form_data(
|
||||
self, parser: WebhookRequestParser
|
||||
) -> None:
|
||||
raw_body = {"message": "hello", "extra": "x"}
|
||||
body_configs = [
|
||||
WebhookBodyParameter(name="upload", type=SegmentType.FILE, required=True),
|
||||
WebhookBodyParameter(name="message", type=SegmentType.STRING, required=True),
|
||||
]
|
||||
result = parser._process_body_parameters(raw_body, body_configs, ContentType.FORM_DATA)
|
||||
assert result == {"message": "hello", "extra": "x"}
|
||||
|
||||
def test_validate_required_headers_should_accept_sanitized_header_names(self, parser: WebhookRequestParser) -> None:
|
||||
headers = {"x_api_key": "123"}
|
||||
configs = [WebhookParameter(name="x-api-key", required=True)]
|
||||
parser._validate_required_headers(headers, configs)
|
||||
|
||||
def test_validate_required_headers_should_raise_when_required_header_missing(
|
||||
self, parser: WebhookRequestParser
|
||||
) -> None:
|
||||
headers = {"x-other": "123"}
|
||||
configs = [WebhookParameter(name="x-api-key", required=True)]
|
||||
with pytest.raises(ValueError, match="Required header missing"):
|
||||
parser._validate_required_headers(headers, configs)
|
||||
|
||||
def test_validate_http_metadata_should_return_content_type_mismatch_error(
|
||||
self, parser: WebhookRequestParser
|
||||
) -> None:
|
||||
webhook_data: RawWebhookDataDict = {
|
||||
"method": "POST",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"query_params": {},
|
||||
"body": {},
|
||||
"files": {},
|
||||
}
|
||||
node_data = WebhookData(method="post", content_type=ContentType.TEXT)
|
||||
result = parser._validate_http_metadata(webhook_data, node_data)
|
||||
assert result["valid"] is False
|
||||
assert "Content-type mismatch" in result["error"]
|
||||
|
||||
def test_extract_content_type_should_fallback_to_lowercase_header_key(self, parser: WebhookRequestParser) -> None:
|
||||
headers = {"content-type": "application/json; charset=utf-8"}
|
||||
assert parser._extract_content_type(headers) == "application/json"
|
||||
|
||||
def test_build_workflow_inputs_should_include_expected_keys(self) -> None:
|
||||
webhook_data: RawWebhookDataDict = {
|
||||
"headers": {"h": "v"},
|
||||
"query_params": {"q": 1},
|
||||
"body": {"b": 2},
|
||||
"method": "POST",
|
||||
"files": {},
|
||||
}
|
||||
result = build_webhook_workflow_inputs(webhook_data)
|
||||
assert result["webhook_data"] == webhook_data
|
||||
assert result["webhook_headers"] == {"h": "v"}
|
||||
assert result["webhook_query_params"] == {"q": 1}
|
||||
assert result["webhook_body"] == {"b": 2}
|
||||
|
||||
|
||||
class TestWebhookRequestParserUtilities:
|
||||
def test_generate_webhook_response_should_fallback_when_response_body_is_not_json(
|
||||
self, parser: WebhookRequestParser
|
||||
) -> None:
|
||||
node_config = _node({"status_code": 200, "response_body": "{bad-json"})
|
||||
body, status = parser.generate_webhook_response(node_config)
|
||||
assert status == 200
|
||||
assert "message" in body
|
||||
|
||||
|
||||
def _node(data: Mapping[str, object]) -> NodeConfigDict:
|
||||
return {"id": "hook", "data": WebhookData.model_validate(data)}
|
||||
@@ -232,15 +232,16 @@ class TestDebugEventSelectorsCases:
|
||||
)
|
||||
mock_bus.poll.return_value = event
|
||||
|
||||
with patch("services.trigger.webhook_service.WebhookService") as mock_webhook_svc:
|
||||
mock_webhook_svc.build_workflow_inputs.return_value = {"parsed": "data"}
|
||||
|
||||
poller = WebhookTriggerDebugEventPoller(**_make_poller_args())
|
||||
result = poller.poll()
|
||||
poller = WebhookTriggerDebugEventPoller(**_make_poller_args())
|
||||
result = poller.poll()
|
||||
|
||||
assert result is not None
|
||||
assert result.workflow_args["inputs"] == {"parsed": "data"}
|
||||
mock_webhook_svc.build_workflow_inputs.assert_called_once_with({"body": "raw"})
|
||||
assert result.workflow_args["inputs"] == {
|
||||
"webhook_data": {"body": "raw"},
|
||||
"webhook_headers": {},
|
||||
"webhook_query_params": {},
|
||||
"webhook_body": "raw",
|
||||
}
|
||||
|
||||
@patch("core.trigger.debug.event_selectors.TriggerDebugEventBus")
|
||||
def test_returns_none_when_no_event(self, mock_bus: MagicMock) -> None:
|
||||
|
||||
@@ -1,32 +1,102 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
"""Webhook listeners use real application wiring and caller-owned publication SQL."""
|
||||
|
||||
from events.event_handlers import sync_webhook_when_app_created
|
||||
from events.event_handlers.sync_webhook_when_app_created import handle
|
||||
from models.model import App
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated
|
||||
from events.event_handlers.sync_webhook_when_app_created import handle, handle_published
|
||||
from models.model import App, AppMode
|
||||
from models.trigger import WorkflowWebhookTrigger
|
||||
from models.workflow import Workflow
|
||||
from tests.unit_tests.events.event_handlers.test_sync_plugin_trigger_when_app_created import binding_app # noqa: F401
|
||||
from tests.unit_tests.repositories.trigger.test_webhook_runtime import WebhookSeed
|
||||
from tests.unit_tests.repositories.trigger.test_webhook_runtime import webhook_seed as _webhook_seed
|
||||
|
||||
webhook_seed = _webhook_seed
|
||||
|
||||
|
||||
def test_draft_sync_preserves_stale_webhook_relationships() -> None:
|
||||
app = cast(App, SimpleNamespace(mode="workflow"))
|
||||
workflow = cast(Workflow, SimpleNamespace())
|
||||
def _workflow(app: App, node_ids: list[str]) -> Workflow:
|
||||
return Workflow.new(
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
type="workflow",
|
||||
version="published",
|
||||
graph=json.dumps(
|
||||
{"nodes": [{"id": node_id, "data": {"type": "trigger-webhook"}} for node_id in node_ids], "edges": []}
|
||||
),
|
||||
features="{}",
|
||||
created_by=str(uuid4()),
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"events.event_handlers.sync_webhook_when_app_created.WebhookService.sync_webhook_relationships"
|
||||
) as sync_webhook_relationships:
|
||||
|
||||
def _bindings(session: Session, app_id: str) -> dict[str, str]:
|
||||
return {
|
||||
binding.node_id: binding.webhook_id
|
||||
for binding in session.scalars(select(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.app_id == app_id))
|
||||
}
|
||||
|
||||
|
||||
def test_draft_listener_preserves_url_for_undo(
|
||||
sqlite_session_factory: sessionmaker[Session], webhook_seed: WebhookSeed
|
||||
) -> None:
|
||||
with sqlite_session_factory() as session:
|
||||
app = session.get(App, webhook_seed.app_id)
|
||||
assert app is not None
|
||||
session.expunge(app)
|
||||
handle(app, synced_draft_workflow=_workflow(app, ["new"]))
|
||||
with sqlite_session_factory() as session:
|
||||
bindings = _bindings(session, app.id)
|
||||
assert bindings["hook"] == webhook_seed.webhook_id
|
||||
assert len(bindings["new"]) == 24
|
||||
handle(app, synced_draft_workflow=_workflow(app, ["hook"]))
|
||||
with sqlite_session_factory() as session:
|
||||
assert _bindings(session, app.id) == bindings
|
||||
|
||||
|
||||
@pytest.mark.parametrize("commit", [True, False], ids=["commit", "rollback"])
|
||||
def test_published_listener_removes_stale_bindings_in_publishers_transaction(
|
||||
sqlite_session_factory: sessionmaker[Session], webhook_seed: WebhookSeed, commit: bool
|
||||
) -> None:
|
||||
with sqlite_session_factory() as session:
|
||||
app = session.get(App, webhook_seed.app_id)
|
||||
assert app is not None
|
||||
published = _workflow(app, ["new"])
|
||||
session.add(published)
|
||||
app.workflow_id = published.id
|
||||
handle_published(app, published_workflow=published, session=session)
|
||||
assert set(_bindings(session, app.id)) == {"new"}
|
||||
if commit:
|
||||
session.commit()
|
||||
else:
|
||||
session.rollback()
|
||||
with sqlite_session_factory() as session:
|
||||
app = session.get(App, webhook_seed.app_id)
|
||||
assert app is not None
|
||||
assert app.workflow_id == (published.id if commit else webhook_seed.workflow_id)
|
||||
bindings = _bindings(session, app.id)
|
||||
assert set(bindings) == ({"new"} if commit else {"hook"})
|
||||
if not commit:
|
||||
assert bindings["hook"] == webhook_seed.webhook_id
|
||||
|
||||
|
||||
def test_non_workflow_app_is_ignored(sqlite_session_factory: sessionmaker[Session], webhook_seed: WebhookSeed) -> None:
|
||||
with sqlite_session_factory() as session:
|
||||
app = session.get(App, webhook_seed.app_id)
|
||||
assert app is not None
|
||||
app.mode = AppMode.CHAT
|
||||
workflow = _workflow(app, ["new"])
|
||||
handle(app, synced_draft_workflow=workflow)
|
||||
|
||||
sync_webhook_relationships.assert_called_once_with(app, workflow, remove_stale=False)
|
||||
handle_published(app, published_workflow=workflow, session=session)
|
||||
assert _bindings(session, app.id) == {"hook": webhook_seed.webhook_id}
|
||||
|
||||
|
||||
def test_published_sync_removes_stale_webhook_relationships() -> None:
|
||||
app = cast(App, SimpleNamespace(mode="workflow"))
|
||||
workflow = cast(Workflow, SimpleNamespace())
|
||||
|
||||
with patch(
|
||||
"events.event_handlers.sync_webhook_when_app_created.WebhookService.sync_webhook_relationships"
|
||||
) as sync_webhook_relationships:
|
||||
sync_webhook_when_app_created.handle_published(app, published_workflow=workflow)
|
||||
|
||||
sync_webhook_relationships.assert_called_once_with(app, workflow, remove_stale=True)
|
||||
def test_webhook_listeners_are_registered() -> None:
|
||||
assert handle in app_draft_workflow_was_synced.receivers_for(App())
|
||||
assert handle_published in app_published_workflow_was_updated.receivers_for(App())
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Real SQL ownership and publication transactions for native webhooks."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
from models.enums import AppTriggerStatus, AppTriggerType
|
||||
from models.model import App, AppMode
|
||||
from models.trigger import AppTrigger, WorkflowWebhookTrigger
|
||||
from models.workflow import Workflow, WorkflowDataError
|
||||
from repositories.trigger.app_repository import SQLAlchemyAppTriggerRepository
|
||||
from services.trigger.webhook_service import WebhookBindingService, WebhookContext, WebhookNotFoundError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebhookSeed:
|
||||
tenant_id: str
|
||||
app_id: str
|
||||
account_id: str
|
||||
webhook_id: str
|
||||
workflow_id: str
|
||||
draft_id: str
|
||||
trigger_id: str
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webhook_seed(sqlite_session_factory: sessionmaker[Session]) -> WebhookSeed:
|
||||
tenant_id, account_id = str(uuid4()), str(uuid4())
|
||||
with sqlite_session_factory.begin() as session:
|
||||
app = App(
|
||||
tenant_id=tenant_id,
|
||||
name="Webhook app",
|
||||
mode=AppMode.WORKFLOW,
|
||||
created_by=account_id,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
session.add(app)
|
||||
session.flush()
|
||||
graph = json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "hook",
|
||||
"data": {
|
||||
"type": "trigger-webhook",
|
||||
"title": "Webhook",
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"params": [],
|
||||
"body": [],
|
||||
},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
workflows = [
|
||||
Workflow.new(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app.id,
|
||||
type="workflow",
|
||||
version=version,
|
||||
graph=graph,
|
||||
features="{}",
|
||||
created_by=account_id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
for version in ("published", Workflow.VERSION_DRAFT)
|
||||
]
|
||||
app.workflow_id = workflows[0].id
|
||||
webhook = WorkflowWebhookTrigger(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app.id,
|
||||
node_id="hook",
|
||||
created_by=account_id,
|
||||
webhook_id="abcdefghijklmnopqrstuvwx",
|
||||
)
|
||||
trigger = AppTrigger(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app.id,
|
||||
node_id="hook",
|
||||
title="Webhook",
|
||||
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
|
||||
status=AppTriggerStatus.ENABLED,
|
||||
)
|
||||
session.add_all([*workflows, webhook, trigger])
|
||||
session.flush()
|
||||
return WebhookSeed(
|
||||
tenant_id, app.id, account_id, webhook.webhook_id, workflows[0].id, workflows[1].id, trigger.id
|
||||
)
|
||||
|
||||
|
||||
def test_webhook_context_is_detached_and_uses_the_selected_version(
|
||||
sqlite_session_factory: sessionmaker[Session], sqlite_engine: Engine, webhook_seed: WebhookSeed
|
||||
) -> None:
|
||||
repository = SQLAlchemyAppTriggerRepository(session_factory=sqlite_session_factory)
|
||||
live = repository.get_webhook_context(webhook_id=webhook_seed.webhook_id, debug=False)
|
||||
draft = repository.get_webhook_context(webhook_id=webhook_seed.webhook_id, debug=True)
|
||||
assert isinstance(live, WebhookContext)
|
||||
assert isinstance(draft, WebhookContext)
|
||||
assert live.workflow_id == webhook_seed.workflow_id
|
||||
assert live.status == AppTriggerStatus.ENABLED
|
||||
assert draft.workflow_id == webhook_seed.draft_id
|
||||
assert draft.status is None
|
||||
pool = sqlite_engine.pool
|
||||
assert isinstance(pool, QueuePool)
|
||||
assert pool.checkedout() == 0
|
||||
assert live.node_config["data"]["type"] == "trigger-webhook"
|
||||
live.node_config["id"] = "local mutation"
|
||||
refreshed = repository.get_webhook_context(webhook_id=webhook_seed.webhook_id, debug=False)
|
||||
assert isinstance(refreshed, WebhookContext)
|
||||
assert refreshed.node_config["data"]["title"] == "Webhook"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mismatch", ["tenant", "app", "node", "type", "missing"])
|
||||
def test_published_webhook_requires_matching_app_trigger(
|
||||
sqlite_session_factory: sessionmaker[Session], webhook_seed: WebhookSeed, mismatch: str
|
||||
) -> None:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
trigger = session.get(AppTrigger, webhook_seed.trigger_id)
|
||||
assert trigger is not None
|
||||
if mismatch == "tenant":
|
||||
trigger.tenant_id = str(uuid4())
|
||||
elif mismatch == "app":
|
||||
trigger.app_id = str(uuid4())
|
||||
elif mismatch == "node":
|
||||
trigger.node_id = "decoy"
|
||||
elif mismatch == "type":
|
||||
trigger.trigger_type = AppTriggerType.TRIGGER_PLUGIN
|
||||
else:
|
||||
session.delete(trigger)
|
||||
repository = SQLAlchemyAppTriggerRepository(session_factory=sqlite_session_factory)
|
||||
with pytest.raises(WebhookNotFoundError):
|
||||
repository.get_webhook_context(webhook_id=webhook_seed.webhook_id, debug=False)
|
||||
draft = repository.get_webhook_context(webhook_id=webhook_seed.webhook_id, debug=True)
|
||||
assert isinstance(draft, WebhookContext)
|
||||
assert draft.workflow_id == webhook_seed.draft_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mismatch", ["tenant", "app", "draft", "missing_node"])
|
||||
def test_published_webhook_rejects_invalid_workflow_owner_chain(
|
||||
sqlite_session_factory: sessionmaker[Session], webhook_seed: WebhookSeed, mismatch: str
|
||||
) -> None:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
workflow = session.get(Workflow, webhook_seed.workflow_id)
|
||||
assert workflow is not None
|
||||
if mismatch == "tenant":
|
||||
workflow.tenant_id = str(uuid4())
|
||||
elif mismatch == "app":
|
||||
workflow.app_id = str(uuid4())
|
||||
elif mismatch == "draft":
|
||||
workflow.version = Workflow.VERSION_DRAFT
|
||||
else:
|
||||
workflow.graph = '{"nodes": [], "edges": []}'
|
||||
repository = SQLAlchemyAppTriggerRepository(session_factory=sqlite_session_factory)
|
||||
error = WorkflowDataError if mismatch == "missing_node" else WebhookNotFoundError
|
||||
with pytest.raises(error):
|
||||
repository.get_webhook_context(webhook_id=webhook_seed.webhook_id, debug=False)
|
||||
|
||||
|
||||
def test_draft_undo_keeps_url_and_published_delete_joins_caller_transaction(
|
||||
sqlite_session_factory: sessionmaker[Session], webhook_seed: WebhookSeed
|
||||
) -> None:
|
||||
repository = SQLAlchemyAppTriggerRepository(session_factory=sqlite_session_factory)
|
||||
service = WebhookBindingService(sync_bindings=repository.sync_draft_webhooks)
|
||||
service.sync(
|
||||
tenant_id=webhook_seed.tenant_id, app_id=webhook_seed.app_id, created_by=webhook_seed.account_id, node_ids=[]
|
||||
)
|
||||
service.sync(
|
||||
tenant_id=webhook_seed.tenant_id,
|
||||
app_id=webhook_seed.app_id,
|
||||
created_by=webhook_seed.account_id,
|
||||
node_ids=["hook", "new"],
|
||||
)
|
||||
with sqlite_session_factory() as session:
|
||||
rows = session.scalars(select(WorkflowWebhookTrigger)).all()
|
||||
assert len(rows) == 2
|
||||
assert {r.webhook_id for r in rows if r.node_id == "hook"} == {webhook_seed.webhook_id}
|
||||
repository.sync_webhooks(
|
||||
session=session,
|
||||
tenant_id=webhook_seed.tenant_id,
|
||||
app_id=webhook_seed.app_id,
|
||||
created_by=webhook_seed.account_id,
|
||||
node_ids=[],
|
||||
remove_stale=True,
|
||||
)
|
||||
session.flush()
|
||||
assert session.scalars(select(WorkflowWebhookTrigger)).all() == []
|
||||
session.rollback()
|
||||
with sqlite_session_factory.begin() as session:
|
||||
assert len(session.scalars(select(WorkflowWebhookTrigger)).all()) == 2
|
||||
repository.sync_webhooks(
|
||||
session=session,
|
||||
tenant_id=webhook_seed.tenant_id,
|
||||
app_id=webhook_seed.app_id,
|
||||
created_by=webhook_seed.account_id,
|
||||
node_ids=["new"],
|
||||
remove_stale=True,
|
||||
)
|
||||
with sqlite_session_factory() as session:
|
||||
assert [r.node_id for r in session.scalars(select(WorkflowWebhookTrigger))] == ["new"]
|
||||
|
||||
|
||||
def test_missing_app_creator_is_rejected_only_when_creating_webhooks(
|
||||
sqlite_session_factory: sessionmaker[Session], webhook_seed: WebhookSeed
|
||||
) -> None:
|
||||
repository = SQLAlchemyAppTriggerRepository(session_factory=sqlite_session_factory)
|
||||
repository.sync_draft_webhooks(
|
||||
tenant_id=webhook_seed.tenant_id, app_id=webhook_seed.app_id, created_by=None, node_ids=["hook"]
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="App is missing created_by"):
|
||||
repository.sync_draft_webhooks(
|
||||
tenant_id=webhook_seed.tenant_id, app_id=webhook_seed.app_id, created_by=None, node_ids=["hook", "new"]
|
||||
)
|
||||
|
||||
|
||||
def test_binding_limit_validated_before_writing(
|
||||
sqlite_session_factory: sessionmaker[Session], webhook_seed: WebhookSeed
|
||||
) -> None:
|
||||
repository = SQLAlchemyAppTriggerRepository(session_factory=sqlite_session_factory)
|
||||
service = WebhookBindingService(sync_bindings=repository.sync_draft_webhooks)
|
||||
with pytest.raises(ValueError, match="maximum webhook node limit"):
|
||||
service.sync(
|
||||
tenant_id=webhook_seed.tenant_id,
|
||||
app_id=webhook_seed.app_id,
|
||||
created_by=webhook_seed.account_id,
|
||||
node_ids=[str(i) for i in range(6)],
|
||||
)
|
||||
with sqlite_session_factory() as session:
|
||||
assert len(session.scalars(select(WorkflowWebhookTrigger)).all()) == 1
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user