refactor(api): isolate native webhook runtime (#45912)

Co-authored-by: Stream <Stream_2@qq.com>
This commit is contained in:
非法操作andStream authored and GitHub committed 2026-11-10 05:08:50 +00:00
1 parent af37c00fb3
commit 9fbd9dcd33
22 files changed
+2500 -3931

No files matched your search

+1
View File
@@ -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
+50 -66
View File
@@ -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
+673
View File
@@ -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
+2 -2
View File
@@ -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,
+123 -2
View File
@@ -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:
+22
View File
@@ -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,
)
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&note=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