diff --git a/api/controllers/trigger/webhook.py b/api/controllers/trigger/webhook.py index 15adb567ad..828103129d 100644 --- a/api/controllers/trigger/webhook.py +++ b/api/controllers/trigger/webhook.py @@ -12,14 +12,17 @@ logger = logging.getLogger(__name__) def _prepare_webhook_execution(webhook_id: str): - """Fetch trigger context, extract request data, and validate payload.""" + """Fetch trigger context, extract request data, and validate payload using unified processing.""" webhook_trigger, workflow, node_config = WebhookService.get_webhook_trigger_and_workflow(webhook_id) - webhook_data = WebhookService.extract_webhook_data(webhook_trigger) - validation_result = WebhookService.validate_webhook_request(webhook_data, node_config) - if not validation_result["valid"]: - return webhook_trigger, workflow, node_config, webhook_data, validation_result["error"] - return webhook_trigger, workflow, node_config, webhook_data, None + try: + # Use new unified extraction and validation + webhook_data = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) + return webhook_trigger, workflow, node_config, webhook_data, None + except ValueError as e: + # Fall back to raw extraction for error reporting + webhook_data = WebhookService.extract_webhook_data(webhook_trigger) + return webhook_trigger, workflow, node_config, webhook_data, str(e) @bp.route("/webhook/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) @@ -55,7 +58,7 @@ def handle_webhook(webhook_id: str): def handle_webhook_debug(webhook_id: str): """Handle webhook debug calls without triggering production workflow execution.""" try: - webhook_trigger, workflow, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id) + webhook_trigger, _, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id) if error: return jsonify({"error": "Bad Request", "message": error}), 400 diff --git a/api/services/webhook_service.py b/api/services/webhook_service.py index cf7216a48f..de2f110abb 100644 --- a/api/services/webhook_service.py +++ b/api/services/webhook_service.py @@ -82,15 +82,34 @@ class WebhookService: return webhook_trigger, workflow, node_config + @classmethod + def extract_and_validate_webhook_data( + cls, webhook_trigger: WorkflowWebhookTrigger, node_config: Mapping[str, Any] + ) -> dict[str, Any]: + """Extract and validate webhook data in a single unified process.""" + # Extract raw data first + raw_data = cls.extract_webhook_data(webhook_trigger) + + # Validate HTTP metadata (method, content-type) + node_data = node_config.get("data", {}) + validation_result = cls._validate_http_metadata(raw_data, node_data) + if not validation_result["valid"]: + raise ValueError(validation_result["error"]) + + # Process and validate data according to configuration + processed_data = cls._process_and_validate_data(raw_data, node_data) + + return processed_data + @classmethod def extract_webhook_data(cls, webhook_trigger: WorkflowWebhookTrigger) -> dict[str, Any]: - """Extract and process data from incoming webhook request.""" + """Extract raw data from incoming webhook request without type conversion.""" cls._validate_content_length() data = { "method": request.method, "headers": dict(request.headers), - "query_params": cls._extract_query_params(), + "query_params": dict(request.args), "body": {}, "files": {}, } @@ -121,41 +140,25 @@ class WebhookService: return data @classmethod - def _extract_query_params(cls) -> dict[str, Any]: - """Extract query parameters preserving multi-value entries.""" - if not request.args: - return {} + def _process_and_validate_data(cls, raw_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: + """Process and validate webhook data according to node configuration.""" + result = raw_data.copy() - query_params: dict[str, Any] = {} - for key, value in request.args.items(): - query_params[key] = cls._convert_query_param_value(value) + # Validate and process headers + cls._validate_required_headers(raw_data["headers"], node_data.get("headers", [])) - return query_params + # Process query parameters with type conversion and validation + result["query_params"] = cls._process_parameters( + raw_data["query_params"], node_data.get("params", []), is_form_data=True + ) - @classmethod - def _convert_query_param_value(cls, value: str) -> Any: - """Convert query parameter strings to numbers or booleans when applicable.""" - lower_value = value.lower() - bool_map = { - "true": True, - "false": False, - "yes": True, - "no": False, - } + # Process body parameters based on content type + configured_content_type = node_data.get("content_type", "application/json").lower() + result["body"] = cls._process_body_parameters( + raw_data["body"], node_data.get("body", []), configured_content_type + ) - if lower_value in bool_map: - return bool_map[lower_value] - - if cls._can_convert_to_number(value): - try: - numeric_value = float(value) - if numeric_value.is_integer(): - return int(numeric_value) - return numeric_value - except ValueError: - return value - - return value + return result @classmethod def _validate_content_length(cls) -> None: @@ -260,26 +263,156 @@ class WebhookService: ) @classmethod - def validate_webhook_request(cls, webhook_data: dict[str, Any], node_config: Mapping[str, Any]) -> dict[str, Any]: - """Validate webhook request against node configuration.""" - if node_config is None: - return cls._validation_error("Validation failed: Invalid node configuration") + def _process_parameters( + cls, raw_params: dict[str, str], param_configs: list, is_form_data: bool = False + ) -> dict[str, Any]: + """Process parameters with unified validation and type conversion.""" + processed = {} + configured_params = {config.get("name", ""): config for config in param_configs} - node_data = node_config.get("data", {}) + # Process configured parameters + for param_config in param_configs: + name = param_config.get("name", "") + param_type = param_config.get("type", SegmentType.STRING) + required = param_config.get("required", False) - # Early validation of HTTP method and content-type - validation_result = cls._validate_http_metadata(webhook_data, node_data) - if not validation_result["valid"]: - return validation_result + # Check required parameters + if required and name not in raw_params: + raise ValueError(f"Required parameter missing: {name}") - # Validate headers and query params - validation_result = cls._validate_headers_and_params(webhook_data, node_data) - if not validation_result["valid"]: - return validation_result + if name in raw_params: + raw_value = raw_params[name] + processed[name] = cls._validate_and_convert_value(name, raw_value, param_type, is_form_data) - # Validate body based on content type - configured_content_type = node_data.get("content_type", "application/json").lower() - return cls._validate_body_by_content_type(webhook_data, node_data, configured_content_type) + # Include unconfigured parameters as strings + for name, value in raw_params.items(): + if name not in configured_params: + processed[name] = value + + return processed + + @classmethod + def _process_body_parameters( + cls, raw_body: dict[str, Any], body_configs: list, content_type: str + ) -> dict[str, Any]: + """Process body parameters based on content type and configuration.""" + if content_type in ["text/plain", "application/octet-stream"]: + # For text/plain and octet-stream, validate required content exists + if body_configs and any(config.get("required", False) for config in body_configs): + raw_content = raw_body.get("raw") + if not raw_content: + raise ValueError(f"Required body content missing for {content_type} request") + return raw_body + + # For structured data (JSON, form-data, etc.) + processed = {} + configured_params = {config.get("name", ""): config for config in body_configs} + + for body_config in body_configs: + name = body_config.get("name", "") + param_type = body_config.get("type", SegmentType.STRING) + required = body_config.get("required", False) + + # Handle file parameters for multipart data + if param_type == SegmentType.FILE and content_type == "multipart/form-data": + # File validation is handled separately in extract phase + continue + + # Check required parameters + if required and name not in raw_body: + raise ValueError(f"Required body parameter missing: {name}") + + if name in raw_body: + raw_value = raw_body[name] + is_form_data = content_type in ["application/x-www-form-urlencoded", "multipart/form-data"] + processed[name] = cls._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 + + @classmethod + def _validate_and_convert_value(cls, param_name: str, value: Any, param_type: str, is_form_data: bool) -> Any: + """Unified validation and type conversion for parameter values.""" + try: + if is_form_data: + # Form data comes as strings and needs conversion + return cls._convert_form_value(param_name, value, param_type) + else: + # JSON data should already be in correct types, just validate + return cls._validate_json_value(param_name, value, param_type) + except Exception as e: + raise ValueError(f"Parameter '{param_name}' validation failed: {str(e)}") + + @classmethod + def _convert_form_value(cls, param_name: str, value: str, param_type: str) -> Any: + """Convert form data string values to specified types.""" + if param_type == SegmentType.STRING: + return value + elif param_type == SegmentType.NUMBER: + if not cls._can_convert_to_number(value): + raise ValueError(f"Cannot convert '{value}' to number") + numeric_value = float(value) + return int(numeric_value) if numeric_value.is_integer() else numeric_value + elif param_type == 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 ValueError(f"Cannot convert '{value}' to boolean") + return bool_map[lower_value] + else: + raise ValueError(f"Unsupported type '{param_type}' for form data parameter '{param_name}'") + + @classmethod + def _validate_json_value(cls, param_name: str, value: Any, param_type: str) -> Any: + """Validate JSON values against expected types.""" + type_validators = { + SegmentType.STRING: (lambda v: isinstance(v, str), "string"), + SegmentType.NUMBER: (lambda v: isinstance(v, (int, float)), "number"), + SegmentType.BOOLEAN: (lambda v: isinstance(v, bool), "boolean"), + SegmentType.OBJECT: (lambda v: isinstance(v, dict), "object"), + SegmentType.ARRAY_STRING: ( + lambda v: isinstance(v, list) and all(isinstance(item, str) for item in v), + "array of strings", + ), + SegmentType.ARRAY_NUMBER: ( + lambda v: isinstance(v, list) and all(isinstance(item, (int, float)) for item in v), + "array of numbers", + ), + SegmentType.ARRAY_BOOLEAN: ( + lambda v: isinstance(v, list) and all(isinstance(item, bool) for item in v), + "array of booleans", + ), + SegmentType.ARRAY_OBJECT: ( + lambda v: isinstance(v, list) and all(isinstance(item, dict) for item in v), + "array of objects", + ), + } + + validator_info = type_validators.get(SegmentType(param_type)) + if not validator_info: + logger.warning("Unknown parameter type: %s for parameter %s", param_type, param_name) + return value + + validator, expected_type = validator_info + if not validator(value): + actual_type = type(value).__name__ + raise ValueError(f"Expected {expected_type}, got {actual_type}") + + return value + + @classmethod + def _validate_required_headers(cls, headers: dict[str, Any], header_configs: list) -> None: + """Validate required headers are present.""" + headers_lower = {k.lower(): v for k, v in headers.items()} + for header_config in header_configs: + if header_config.get("required", False): + header_name = header_config.get("name", "") + if header_name.lower() not in headers_lower: + raise ValueError(f"Required header missing: {header_name}") @classmethod def _validate_http_metadata(cls, webhook_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: @@ -310,259 +443,11 @@ class WebhookService: # Extract the main content type (ignore parameters like boundary) return content_type.split(";")[0].strip() - @classmethod - def _validate_headers_and_params(cls, webhook_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: - """Validate required headers and query parameters.""" - # Validate required headers (case-insensitive) - webhook_headers_lower = {k.lower(): v for k, v in webhook_data["headers"].items()} - for header in node_data.get("headers", []): - if header.get("required", False): - header_name = header.get("name", "") - if header_name.lower() not in webhook_headers_lower: - return cls._validation_error(f"Required header missing: {header_name}") - - # Validate required query parameters - query_params = webhook_data.get("query_params", {}) - for param in node_data.get("params", []): - param_name = param.get("name", "") - param_type = param.get("type", SegmentType.STRING) - is_required = param.get("required", False) - - param_exists = param_name in query_params - if is_required and not param_exists: - return cls._validation_error(f"Required query parameter missing: {param_name}") - - if not param_exists: - continue - - if param_exists and param_type != SegmentType.STRING: - param_value = query_params[param_name] - validation_result = cls._validate_form_parameter_type(param_name, param_value, param_type) - if not validation_result["valid"]: - return validation_result - - return {"valid": True} - - @classmethod - def _validate_body_by_content_type( - cls, webhook_data: dict[str, Any], node_data: dict[str, Any], content_type: str - ) -> dict[str, Any]: - """Route body validation to appropriate validator based on content type.""" - validators = { - "text/plain": cls._validate_text_plain_body, - "application/octet-stream": cls._validate_octet_stream_body, - "application/json": cls._validate_json_body, - "application/x-www-form-urlencoded": cls._validate_form_urlencoded_body, - "multipart/form-data": cls._validate_multipart_body, - } - - validator = validators.get(content_type) - if not validator: - raise ValueError(f"Unsupported Content-Type for validation: {content_type}") - - return validator(webhook_data, node_data) - - @classmethod - def _validate_text_plain_body(cls, webhook_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: - """Validate text/plain body.""" - body_params = node_data.get("body", []) - if body_params and any(param.get("required", False) for param in body_params): - body_data = webhook_data.get("body", {}) - raw_content = body_data.get("raw", "") - if not raw_content or not isinstance(raw_content, str): - return cls._validation_error("Required body content missing for text/plain request") - return {"valid": True} - - @classmethod - def _validate_octet_stream_body(cls, webhook_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: - """Validate application/octet-stream body.""" - body_params = node_data.get("body", []) - if body_params and any(param.get("required", False) for param in body_params): - body_data = webhook_data.get("body", {}) - raw_content = body_data.get("raw", "") - if not raw_content or not isinstance(raw_content, bytes): - return cls._validation_error("Required body content missing for application/octet-stream request") - return {"valid": True} - - @classmethod - def _validate_json_body(cls, webhook_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: - """Validate application/json body.""" - body_params = node_data.get("body", []) - body_data = webhook_data.get("body", {}) - - for body_param in body_params: - param_name = body_param.get("name", "") - param_type = body_param.get("type", SegmentType.STRING) - is_required = body_param.get("required", False) - - param_exists = param_name in body_data - - if is_required and not param_exists: - return cls._validation_error(f"Required body parameter missing: {param_name}") - - if param_exists: - param_value = body_data[param_name] - validation_result = cls._validate_json_parameter_type(param_name, param_value, param_type) - if not validation_result["valid"]: - return validation_result - - return {"valid": True} - - @classmethod - def _validate_form_urlencoded_body(cls, webhook_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: - """Validate application/x-www-form-urlencoded body.""" - body_params = node_data.get("body", []) - body_data = webhook_data.get("body", {}) - - for body_param in body_params: - param_name = body_param.get("name", "") - param_type = body_param.get("type", SegmentType.STRING) - is_required = body_param.get("required", False) - - param_exists = param_name in body_data - if is_required and not param_exists: - return cls._validation_error(f"Required body parameter missing: {param_name}") - - if param_exists and param_type != SegmentType.STRING: - param_value = body_data[param_name] - validation_result = cls._validate_form_parameter_type(param_name, param_value, param_type) - if not validation_result["valid"]: - return validation_result - - return {"valid": True} - - @classmethod - def _validate_multipart_body(cls, webhook_data: dict[str, Any], node_data: dict[str, Any]) -> dict[str, Any]: - """Validate multipart/form-data body.""" - body_params = node_data.get("body", []) - body_data = webhook_data.get("body", {}) - - for body_param in body_params: - param_name = body_param.get("name", "") - param_type = body_param.get("type", SegmentType.STRING) - is_required = body_param.get("required", False) - - if param_type == SegmentType.FILE: - file_obj = webhook_data.get("files", {}).get(param_name) - if is_required and not file_obj: - return cls._validation_error(f"Required file parameter missing: {param_name}") - else: - param_exists = param_name in body_data - - if is_required and not param_exists: - return cls._validation_error(f"Required body parameter missing: {param_name}") - - if param_exists and param_type != SegmentType.STRING: - param_value = body_data[param_name] - validation_result = cls._validate_form_parameter_type(param_name, param_value, param_type) - if not validation_result["valid"]: - return validation_result - - return {"valid": True} - @classmethod def _validation_error(cls, error_message: str) -> dict[str, Any]: """Create a standard validation error response.""" return {"valid": False, "error": error_message} - @classmethod - def _validate_json_parameter_type(cls, param_name: str, param_value: Any, param_type: str) -> dict[str, Any]: - """Validate JSON parameter type against expected type.""" - try: - # Define type validators - type_validators = { - SegmentType.STRING: (lambda v: isinstance(v, str), "string"), - SegmentType.NUMBER: (lambda v: isinstance(v, (int, float)), "number"), - SegmentType.BOOLEAN: (lambda v: isinstance(v, bool), "boolean"), - SegmentType.OBJECT: (lambda v: isinstance(v, dict), "object"), - SegmentType.ARRAY_STRING: ( - lambda v: isinstance(v, list) and all(isinstance(item, str) for item in v), - "array of strings", - ), - SegmentType.ARRAY_NUMBER: ( - lambda v: isinstance(v, list) and all(isinstance(item, (int, float)) for item in v), - "array of numbers", - ), - SegmentType.ARRAY_BOOLEAN: ( - lambda v: isinstance(v, list) and all(isinstance(item, bool) for item in v), - "array of booleans", - ), - SegmentType.ARRAY_OBJECT: ( - lambda v: isinstance(v, list) and all(isinstance(item, dict) for item in v), - "array of objects", - ), - } - - # Get validator for the type - validator_info = type_validators.get(SegmentType(param_type)) - if not validator_info: - logger.warning("Unknown parameter type: %s for parameter %s", param_type, param_name) - return {"valid": True} - - validator, expected_type = validator_info - - # Validate the parameter - if not validator(param_value): - # Check if it's an array type first - if param_type.startswith("array") and not isinstance(param_value, list): - actual_type = type(param_value).__name__ - error_msg = f"Parameter '{param_name}' must be an array, got {actual_type}" - else: - actual_type = type(param_value).__name__ - # Format error message based on expected type - if param_type.startswith("array"): - error_msg = f"Parameter '{param_name}' must be an {expected_type}" - elif expected_type in ["string", "number", "boolean"]: - error_msg = f"Parameter '{param_name}' must be a {expected_type}, got {actual_type}" - else: - error_msg = f"Parameter '{param_name}' must be an {expected_type}, got {actual_type}" - - return {"valid": False, "error": error_msg} - - return {"valid": True} - - except Exception: - logger.exception("Type validation error for parameter %s", param_name) - return {"valid": False, "error": f"Type validation failed for parameter '{param_name}'"} - - @classmethod - def _validate_form_parameter_type(cls, param_name: str, param_value: str, param_type: str) -> dict[str, Any]: - """Validate form parameter type against expected type. Form data are always strings but can be converted.""" - try: - # Define form type converters and validators - form_validators = { - SegmentType.STRING: (lambda _: True, None), # String is always valid - SegmentType.NUMBER: (lambda v: cls._can_convert_to_number(v), "a valid number"), - SegmentType.BOOLEAN: ( - lambda v: v.lower() in ["true", "false", "1", "0", "yes", "no"], - "a boolean value", - ), - } - - # Get validator for the type - validator_info = form_validators.get(SegmentType(param_type)) - if not validator_info: - # Unsupported type for form data - return { - "valid": False, - "error": f"Parameter '{param_name}' type '{param_type}' is not supported for form data.", - } - - validator, expected_format = validator_info - - # Validate the parameter - if not validator(param_value): - return { - "valid": False, - "error": f"Parameter '{param_name}' must be {expected_format}, got '{param_value}'", - } - - return {"valid": True} - - except Exception: - logger.exception("Form type validation error for parameter %s", param_name) - return {"valid": False, "error": f"Form type validation failed for parameter '{param_name}'"} - @classmethod def _can_convert_to_number(cls, value: str) -> bool: """Check if a string can be converted to a number.""" diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index 20bfcca6cc..0cb5b923a5 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -1,6 +1,7 @@ from io import BytesIO from unittest.mock import MagicMock, patch +import pytest from flask import Flask from werkzeug.datastructures import FileStorage @@ -26,14 +27,15 @@ class TestWebhookServiceUnit: assert webhook_data["method"] == "POST" assert webhook_data["headers"]["Authorization"] == "Bearer token" - assert webhook_data["query_params"]["version"] == 1 + # Query params are now extracted as raw strings + 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): - """Query parameters remain raw strings during extraction.""" + """Query parameters should be extracted as raw strings without automatic conversion.""" app = Flask(__name__) with app.test_request_context( @@ -45,9 +47,10 @@ class TestWebhookServiceUnit: webhook_trigger = MagicMock() webhook_data = WebhookService.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 + # After refactoring, raw extraction keeps query params as strings + 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): @@ -120,275 +123,6 @@ class TestWebhookServiceUnit: assert webhook_data["method"] == "POST" assert webhook_data["body"] == {} # Should default to empty dict - def test_validate_webhook_request_success(self): - """Test successful webhook request validation.""" - webhook_data = { - "method": "POST", - "headers": {"Authorization": "Bearer token", "Content-Type": "application/json"}, - "query_params": {"version": "1"}, - "body": {"message": "hello"}, - "files": {}, - } - - node_config = { - "data": { - "method": "post", - "headers": [{"name": "Authorization", "required": True}, {"name": "Content-Type", "required": False}], - "params": [{"name": "version", "required": True}], - "body": [{"name": "message", "type": "string", "required": True}], - } - } - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is True - - def test_validate_webhook_request_method_mismatch(self): - """Test webhook validation with HTTP method mismatch.""" - webhook_data = {"method": "GET", "headers": {}, "query_params": {}, "body": {}, "files": {}} - - node_config = {"data": {"method": "post"}} - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is False - assert "HTTP method mismatch" in result["error"] - assert "Expected POST, got GET" in result["error"] - - def test_validate_webhook_request_missing_required_header(self): - """Test webhook validation with missing required header.""" - webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}} - - node_config = {"data": {"method": "post", "headers": [{"name": "Authorization", "required": True}]}} - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is False - assert "Required header missing: Authorization" in result["error"] - - def test_validate_webhook_request_case_insensitive_headers(self): - """Test webhook validation with case-insensitive header matching.""" - webhook_data = { - "method": "POST", - "headers": {"authorization": "Bearer token"}, # lowercase - "query_params": {}, - "body": {}, - "files": {}, - } - - node_config = { - "data": { - "method": "post", - "headers": [ - {"name": "Authorization", "required": True} # Pascal case - ], - } - } - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is True - - def test_validate_webhook_request_missing_required_param(self): - """Test webhook validation with missing required query parameter.""" - webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}} - - node_config = {"data": {"method": "post", "params": [{"name": "version", "required": True}]}} - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is False - assert "Required query parameter missing: version" in result["error"] - - def test_validate_webhook_request_query_param_number_type(self): - """Numeric query parameters should validate with numeric types.""" - webhook_data = { - "method": "POST", - "headers": {}, - "query_params": {"count": "42"}, - "body": {}, - "files": {}, - } - - node_config = { - "data": { - "method": "post", - "params": [{"name": "count", "required": True, "type": "number"}], - } - } - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is True - - def test_validate_webhook_request_query_param_number_type_invalid(self): - """Numeric query parameter validation should fail for non-numeric values.""" - webhook_data = { - "method": "POST", - "headers": {}, - "query_params": {"count": "forty-two"}, - "body": {}, - "files": {}, - } - - node_config = { - "data": { - "method": "post", - "params": [{"name": "count", "required": True, "type": "number"}], - } - } - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is False - assert "must be a valid number" in result["error"] - - def test_validate_webhook_request_query_param_boolean_type(self): - """Boolean query parameters should validate with supported boolean strings.""" - webhook_data = { - "method": "POST", - "headers": {}, - "query_params": {"enabled": "true"}, - "body": {}, - "files": {}, - } - - node_config = { - "data": { - "method": "post", - "params": [{"name": "enabled", "required": True, "type": "boolean"}], - } - } - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is True - - def test_validate_webhook_request_query_param_string_type_preserved(self): - """String typed query parameters remain as strings even if boolean-like.""" - webhook_data = { - "method": "POST", - "headers": {}, - "query_params": {"flag": "true"}, - "body": {}, - "files": {}, - } - - node_config = { - "data": { - "method": "post", - "params": [{"name": "flag", "required": True, "type": "string"}], - } - } - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is True - assert webhook_data["query_params"]["flag"] == "true" - - def test_validate_webhook_request_missing_required_body_param(self): - """Test webhook validation with missing required body parameter.""" - webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}} - - node_config = {"data": {"method": "post", "body": [{"name": "message", "type": "string", "required": True}]}} - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is False - assert "Required body parameter missing: message" in result["error"] - - def test_validate_webhook_request_missing_required_file(self): - """Test webhook validation with missing required file parameter.""" - webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}} - - node_config = {"data": {"method": "post", "body": [{"name": "upload", "type": "file", "required": True}]}} - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is False - assert "Required body parameter missing: upload" in result["error"] - - def test_validate_webhook_request_text_plain_with_required_body(self): - """Test webhook validation for text/plain content type with required body content.""" - # Test case 1: text/plain with raw content - should pass - webhook_data = { - "method": "POST", - "headers": {"content-type": "text/plain"}, - "query_params": {}, - "body": {"raw": "Hello World"}, - "files": {}, - } - - node_config = { - "data": { - "method": "post", - "content_type": "text/plain", - "body": [{"name": "message", "type": "string", "required": True}], - } - } - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - assert result["valid"] is True - - # Test case 2: text/plain without raw content but required - should fail - webhook_data_no_body = { - "method": "POST", - "headers": {"content-type": "text/plain"}, - "query_params": {}, - "body": {}, - "files": {}, - } - - result = WebhookService.validate_webhook_request(webhook_data_no_body, node_config) - assert result["valid"] is False - assert "Required body content missing for text/plain request" in result["error"] - - # Test case 3: text/plain with empty raw content but required - should fail - webhook_data_empty_body = { - "method": "POST", - "headers": {"content-type": "text/plain"}, - "query_params": {}, - "body": {"raw": ""}, - "files": {}, - } - - result = WebhookService.validate_webhook_request(webhook_data_empty_body, node_config) - assert result["valid"] is False - assert "Required body content missing for text/plain request" in result["error"] - - def test_validate_webhook_request_text_plain_no_body_params(self): - """Test webhook validation for text/plain content type with no body params configured.""" - webhook_data = { - "method": "POST", - "headers": {"content-type": "text/plain"}, - "query_params": {}, - "body": {"raw": "Hello World"}, - "files": {}, - } - - node_config = { - "data": { - "method": "post", - "content_type": "text/plain", - "body": [], # No body params configured - } - } - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - assert result["valid"] is True - - def test_validate_webhook_request_validation_exception(self): - """Test webhook validation with exception handling.""" - webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}} - - # Invalid node config that will cause an exception - node_config = None - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - - assert result["valid"] is False - assert "Validation failed" in result["error"] - def test_generate_webhook_response_default(self): """Test webhook response generation with default values.""" node_config = {"data": {}} @@ -540,199 +274,183 @@ class TestWebhookServiceUnit: # Should skip files without filenames assert len(result) == 0 - def test_validate_json_parameter_type_string(self): - """Test JSON parameter type validation for string type.""" + def test_validate_json_value_string(self): + """Test JSON value validation for string type.""" # Valid string - result = WebhookService._validate_json_parameter_type("name", "hello", "string") - assert result["valid"] is True + result = WebhookService._validate_json_value("name", "hello", "string") + assert result == "hello" - # Invalid string (number) - result = WebhookService._validate_json_parameter_type("name", 123, "string") - assert result["valid"] is False - assert "must be a string, got int" in result["error"] + # Invalid string (number) - should raise ValueError + with pytest.raises(ValueError, match="Expected string, got int"): + WebhookService._validate_json_value("name", 123, "string") - def test_validate_json_parameter_type_number(self): - """Test JSON parameter type validation for number type.""" + def test_validate_json_value_number(self): + """Test JSON value validation for number type.""" # Valid integer - result = WebhookService._validate_json_parameter_type("count", 42, "number") - assert result["valid"] is True + result = WebhookService._validate_json_value("count", 42, "number") + assert result == 42 # Valid float - result = WebhookService._validate_json_parameter_type("price", 19.99, "number") - assert result["valid"] is True + result = WebhookService._validate_json_value("price", 19.99, "number") + assert result == 19.99 - # Invalid number (string) - result = WebhookService._validate_json_parameter_type("count", "42", "number") - assert result["valid"] is False - assert "must be a number, got str" in result["error"] + # Invalid number (string) - should raise ValueError + with pytest.raises(ValueError, match="Expected number, got str"): + WebhookService._validate_json_value("count", "42", "number") - def test_validate_json_parameter_type_bool(self): - """Test JSON parameter type validation for boolean type.""" + def test_validate_json_value_bool(self): + """Test JSON value validation for boolean type.""" # Valid boolean - result = WebhookService._validate_json_parameter_type("enabled", True, "boolean") - assert result["valid"] is True + result = WebhookService._validate_json_value("enabled", True, "boolean") + assert result is True - result = WebhookService._validate_json_parameter_type("enabled", False, "boolean") - assert result["valid"] is True + result = WebhookService._validate_json_value("enabled", False, "boolean") + assert result is False - # Invalid boolean (string) - result = WebhookService._validate_json_parameter_type("enabled", "true", "boolean") - assert result["valid"] is False - assert "must be a boolean, got str" in result["error"] + # Invalid boolean (string) - should raise ValueError + with pytest.raises(ValueError, match="Expected boolean, got str"): + WebhookService._validate_json_value("enabled", "true", "boolean") - def test_validate_json_parameter_type_object(self): - """Test JSON parameter type validation for object type.""" + def test_validate_json_value_object(self): + """Test JSON value validation for object type.""" # Valid object - result = WebhookService._validate_json_parameter_type("user", {"name": "John", "age": 30}, "object") - assert result["valid"] is True + result = WebhookService._validate_json_value("user", {"name": "John", "age": 30}, "object") + assert result == {"name": "John", "age": 30} - # Invalid object (string) - result = WebhookService._validate_json_parameter_type("user", "not_an_object", "object") - assert result["valid"] is False - assert "must be an object, got str" in result["error"] + # Invalid object (string) - should raise ValueError + with pytest.raises(ValueError, match="Expected object, got str"): + WebhookService._validate_json_value("user", "not_an_object", "object") - def test_validate_json_parameter_type_array_string(self): - """Test JSON parameter type validation for array[string] type.""" + def test_validate_json_value_array_string(self): + """Test JSON value validation for array[string] type.""" # Valid array of strings - result = WebhookService._validate_json_parameter_type("tags", ["tag1", "tag2", "tag3"], "array[string]") - assert result["valid"] is True + result = WebhookService._validate_json_value("tags", ["tag1", "tag2", "tag3"], "array[string]") + assert result == ["tag1", "tag2", "tag3"] # Invalid - not an array - result = WebhookService._validate_json_parameter_type("tags", "not_an_array", "array[string]") - assert result["valid"] is False - assert "must be an array, got str" in result["error"] + with pytest.raises(ValueError, match="Expected array of strings, got str"): + WebhookService._validate_json_value("tags", "not_an_array", "array[string]") # Invalid - array with non-strings - result = WebhookService._validate_json_parameter_type("tags", ["tag1", 123, "tag3"], "array[string]") - assert result["valid"] is False - assert "must be an array of strings" in result["error"] + with pytest.raises(ValueError, match="Expected array of strings, got list"): + WebhookService._validate_json_value("tags", ["tag1", 123, "tag3"], "array[string]") - def test_validate_json_parameter_type_array_number(self): - """Test JSON parameter type validation for array[number] type.""" + def test_validate_json_value_array_number(self): + """Test JSON value validation for array[number] type.""" # Valid array of numbers - result = WebhookService._validate_json_parameter_type("scores", [1, 2.5, 3, 4.7], "array[number]") - assert result["valid"] is True + result = WebhookService._validate_json_value("scores", [1, 2.5, 3, 4.7], "array[number]") + assert result == [1, 2.5, 3, 4.7] # Invalid - array with non-numbers - result = WebhookService._validate_json_parameter_type("scores", [1, "2", 3], "array[number]") - assert result["valid"] is False - assert "must be an array of numbers" in result["error"] + with pytest.raises(ValueError, match="Expected array of numbers, got list"): + WebhookService._validate_json_value("scores", [1, "2", 3], "array[number]") - def test_validate_json_parameter_type_array_bool(self): - """Test JSON parameter type validation for array[boolean] type.""" + def test_validate_json_value_array_bool(self): + """Test JSON value validation for array[boolean] type.""" # Valid array of booleans - result = WebhookService._validate_json_parameter_type("flags", [True, False, True], "array[boolean]") - assert result["valid"] is True + result = WebhookService._validate_json_value("flags", [True, False, True], "array[boolean]") + assert result == [True, False, True] # Invalid - array with non-booleans - result = WebhookService._validate_json_parameter_type("flags", [True, "false", True], "array[boolean]") - assert result["valid"] is False - assert "must be an array of booleans" in result["error"] + with pytest.raises(ValueError, match="Expected array of booleans, got list"): + WebhookService._validate_json_value("flags", [True, "false", True], "array[boolean]") - def test_validate_json_parameter_type_array_object(self): - """Test JSON parameter type validation for array[object] type.""" + def test_validate_json_value_array_object(self): + """Test JSON value validation for array[object] type.""" # Valid array of objects - result = WebhookService._validate_json_parameter_type( - "users", [{"name": "John"}, {"name": "Jane"}], "array[object]" - ) - assert result["valid"] is True + result = WebhookService._validate_json_value("users", [{"name": "John"}, {"name": "Jane"}], "array[object]") + assert result == [{"name": "John"}, {"name": "Jane"}] # Invalid - array with non-objects - result = WebhookService._validate_json_parameter_type( - "users", [{"name": "John"}, "not_object"], "array[object]" - ) - assert result["valid"] is False - assert "must be an array of objects" in result["error"] + with pytest.raises(ValueError, match="Expected array of objects, got list"): + WebhookService._validate_json_value("users", [{"name": "John"}, "not_object"], "array[object]") - def test_validate_webhook_request_json_type_validation(self): - """Test webhook validation with JSON parameter type validation.""" - # Test valid JSON types - webhook_data = { - "method": "POST", - "headers": {"Content-Type": "application/json"}, - "query_params": {}, - "body": { - "name": "John", - "age": 30, - "active": True, - "profile": {"email": "john@example.com"}, - "tags": ["developer", "python"], - "scores": [85, 92.5, 78], - "flags": [True, False], - "items": [{"id": 1}, {"id": 2}], - }, - "files": {}, - } + def test_convert_form_value_string(self): + """Test form value conversion for string type.""" + result = WebhookService._convert_form_value("test", "hello", "string") + assert result == "hello" - node_config = { - "data": { - "method": "post", - "content_type": "application/json", - "body": [ - {"name": "name", "type": "string", "required": True}, - {"name": "age", "type": "number", "required": True}, - {"name": "active", "type": "boolean", "required": True}, - {"name": "profile", "type": "object", "required": True}, - {"name": "tags", "type": "array[string]", "required": True}, - {"name": "scores", "type": "array[number]", "required": True}, - {"name": "flags", "type": "array[boolean]", "required": True}, - {"name": "items", "type": "array[object]", "required": True}, - ], + def test_convert_form_value_number(self): + """Test form value conversion for number type.""" + # Integer + result = WebhookService._convert_form_value("count", "42", "number") + assert result == 42 + + # Float + result = WebhookService._convert_form_value("price", "19.99", "number") + assert result == 19.99 + + # Invalid number + with pytest.raises(ValueError, match="Cannot convert 'not_a_number' to number"): + WebhookService._convert_form_value("count", "not_a_number", "number") + + def test_convert_form_value_boolean(self): + """Test form value conversion for boolean type.""" + # True values + assert WebhookService._convert_form_value("flag", "true", "boolean") is True + assert WebhookService._convert_form_value("flag", "1", "boolean") is True + assert WebhookService._convert_form_value("flag", "yes", "boolean") is True + + # False values + assert WebhookService._convert_form_value("flag", "false", "boolean") is False + assert WebhookService._convert_form_value("flag", "0", "boolean") is False + assert WebhookService._convert_form_value("flag", "no", "boolean") is False + + # Invalid boolean + with pytest.raises(ValueError, match="Cannot convert 'maybe' to boolean"): + WebhookService._convert_form_value("flag", "maybe", "boolean") + + def test_extract_and_validate_webhook_data_success(self): + """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 = MagicMock() + node_config = { + "data": { + "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 = WebhookService.validate_webhook_request(webhook_data, node_config) - assert result["valid"] is True + result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) - def test_validate_webhook_request_json_type_validation_invalid(self): - """Test webhook validation with invalid JSON parameter types.""" - webhook_data = { - "method": "POST", - "headers": {"Content-Type": "application/json"}, - "query_params": {}, - "body": { - "name": 123, # Should be string - "age": "thirty", # Should be number - }, - "files": {}, - } + # Check that types are correctly converted + assert result["query_params"]["count"] == 42 # Converted to int + assert result["query_params"]["enabled"] is True # Converted to bool + assert result["body"]["message"] == "hello" # Already string + assert result["body"]["age"] == 25 # Already number - node_config = { - "data": { - "method": "post", - "content_type": "application/json", - "body": [ - {"name": "name", "type": "string", "required": True}, - {"name": "age", "type": "number", "required": True}, - ], + def test_extract_and_validate_webhook_data_validation_error(self): + """Test unified data extraction with validation error.""" + app = Flask(__name__) + + with app.test_request_context( + "/webhook", + method="GET", # Wrong method + headers={"Content-Type": "application/json"}, + ): + webhook_trigger = MagicMock() + node_config = { + "data": { + "method": "post", # Expects POST + "content_type": "application/json", + } } - } - result = WebhookService.validate_webhook_request(webhook_data, node_config) - assert result["valid"] is False - assert "must be a string, got int" in result["error"] - - def test_validate_webhook_request_non_json_skip_type_validation(self): - """Test that type validation is skipped for non-JSON content types.""" - webhook_data = { - "method": "POST", - "headers": {"Content-Type": "application/x-www-form-urlencoded"}, - "query_params": {}, - "body": { - "name": 123, # Would be invalid for string if this was JSON - }, - "files": {}, - } - - node_config = { - "data": { - "method": "post", - "content_type": "application/x-www-form-urlencoded", - "body": [ - {"name": "name", "type": "string", "required": True}, - ], - } - } - - result = WebhookService.validate_webhook_request(webhook_data, node_config) - assert result["valid"] is True # Should pass because type validation is only for JSON + with pytest.raises(ValueError, match="HTTP method mismatch"): + WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)