diff --git a/services/scheduler/src/executors/rest_api_executor.py b/services/scheduler/src/executors/rest_api_executor.py new file mode 100644 index 0000000..bb456fa --- /dev/null +++ b/services/scheduler/src/executors/rest_api_executor.py @@ -0,0 +1,266 @@ +""" +Generic REST API Executor + +Universal executor for calling any REST API endpoint across the system. +Supports GET, POST, PUT, DELETE with configurable payloads, headers, and authentication. + +This executor can be used to trigger any service endpoint: +- Library Desk knowledge consolidation +- Core API operations +- External webhooks +- Any HTTP-based task + +Config schema: + { + "url": "http://service:port/endpoint", + "method": "POST", # GET, POST, PUT, DELETE, PATCH + "payload": {...}, # Request body (for POST/PUT/PATCH) + "headers": {...}, # Additional headers + "auth": { + "type": "bearer", # bearer, basic, api_key + "token": "${ENV_VAR}", # Use ${VAR} for env vars + "header": "Authorization" # Optional: header name for API key + }, + "timeout": 300, # Timeout in seconds (default: 300) + "verify_ssl": true, # SSL verification (default: true) + "success_codes": [200, 201, 202], # Expected success codes + "response_path": "result.message" # JSONPath to extract from response + } + +Example configs: + +1. Library Desk Knowledge Consolidation: + { + "url": "http://library-desk:8089/consolidate/knowledge", + "method": "POST", + "payload": {"process_limit": 10, "lookback_days": 7, "dry_run": false}, + "auth": {"type": "bearer", "token": "${LIBRARY_DESK_API_KEY}"} + } + +2. Core API Container Restart: + { + "url": "http://core-api:8088/v1/infrastructure/containers/nginx/restart", + "method": "POST", + "auth": {"type": "bearer", "token": "${CORE_API_KEY}"} + } + +3. External Webhook: + { + "url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL", + "method": "POST", + "payload": {"text": "Scheduled task completed"}, + "verify_ssl": true + } +""" +import logging +import os +import re +import httpx +from typing import Any, Dict, Optional + +from src.config import Settings + +logger = logging.getLogger(__name__) + + +async def execute(config: dict, settings: Settings) -> str: + """ + Execute REST API call with configured parameters. + + Args: + config: REST API call configuration (see module docstring) + settings: Global scheduler settings + + Returns: + Response summary or extracted result + + Raises: + ValueError: On configuration error + Exception: On API call failure + """ + # Required configuration + url = config.get('url') + if not url: + raise ValueError("Missing required config: 'url'") + + method = config.get('method', 'POST').upper() + if method not in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']: + raise ValueError(f"Invalid HTTP method: {method}") + + # Optional configuration + payload = config.get('payload', {}) + headers = config.get('headers', {}) + timeout = config.get('timeout', 300) + verify_ssl = config.get('verify_ssl', True) + success_codes = config.get('success_codes', [200, 201, 202, 204]) + response_path = config.get('response_path') + + # Handle authentication + auth_config = config.get('auth', {}) + if auth_config: + auth_header = _build_auth_header(auth_config, settings) + if auth_header: + headers.update(auth_header) + + # Substitute environment variables in URL and payload + url = _substitute_env_vars(url) + payload = _substitute_env_vars_recursive(payload) + + logger.info(f"Executing REST API call: {method} {url}") + if payload: + logger.debug(f"Payload: {_redact_sensitive(payload)}") + + # Make HTTP request + try: + async with httpx.AsyncClient(timeout=timeout, verify=verify_ssl) as client: + if method == 'GET': + response = await client.get(url, headers=headers) + elif method == 'POST': + response = await client.post(url, json=payload, headers=headers) + elif method == 'PUT': + response = await client.put(url, json=payload, headers=headers) + elif method == 'DELETE': + response = await client.delete(url, headers=headers) + elif method == 'PATCH': + response = await client.patch(url, json=payload, headers=headers) + + # Check status code + if response.status_code not in success_codes: + error_msg = ( + f"API call failed with status {response.status_code}: " + f"{response.text[:500]}" + ) + logger.error(error_msg) + raise Exception(error_msg) + + # Parse response + try: + response_data = response.json() + except: + response_data = {"text": response.text} + + # Extract specific field if response_path provided + result_text = None + if response_path and isinstance(response_data, dict): + result_text = _extract_json_path(response_data, response_path) + + if not result_text: + # Build summary from response + if isinstance(response_data, dict): + # Look for common result fields + result_text = ( + response_data.get('message') or + response_data.get('result') or + response_data.get('summary') or + f"Success ({response.status_code})" + ) + else: + result_text = f"Success ({response.status_code})" + + logger.info(f"API call succeeded: {result_text}") + return str(result_text) + + except httpx.HTTPStatusError as e: + error_msg = f"HTTP {e.response.status_code}: {e.response.text[:500]}" + logger.error(error_msg) + raise Exception(error_msg) + except httpx.RequestError as e: + error_msg = f"Request failed: {str(e)}" + logger.error(error_msg) + raise Exception(error_msg) + except Exception as e: + logger.error(f"REST API call failed: {e}", exc_info=True) + raise + + +def _build_auth_header(auth_config: dict, settings: Settings) -> Optional[Dict[str, str]]: + """Build authentication header from config.""" + auth_type = auth_config.get('type', '').lower() + + if auth_type == 'bearer': + token = auth_config.get('token', '') + token = _substitute_env_vars(token) + if token: + return {"Authorization": f"Bearer {token}"} + + elif auth_type == 'basic': + username = _substitute_env_vars(auth_config.get('username', '')) + password = _substitute_env_vars(auth_config.get('password', '')) + if username and password: + import base64 + credentials = base64.b64encode(f"{username}:{password}".encode()).decode() + return {"Authorization": f"Basic {credentials}"} + + elif auth_type == 'api_key': + key = _substitute_env_vars(auth_config.get('key', '')) + header_name = auth_config.get('header', 'X-API-Key') + if key: + return {header_name: key} + + return None + + +def _substitute_env_vars(text: str) -> str: + """Substitute ${ENV_VAR} placeholders with environment variables.""" + if not isinstance(text, str): + return text + + # Find all ${VAR} patterns + pattern = r'\$\{([A-Z_][A-Z0-9_]*)\}' + matches = re.findall(pattern, text) + + for var_name in matches: + env_value = os.getenv(var_name, '') + if not env_value: + logger.warning(f"Environment variable not found: {var_name}") + text = text.replace(f"${{{var_name}}}", env_value) + + return text + + +def _substitute_env_vars_recursive(data: Any) -> Any: + """Recursively substitute environment variables in nested structures.""" + if isinstance(data, dict): + return {k: _substitute_env_vars_recursive(v) for k, v in data.items()} + elif isinstance(data, list): + return [_substitute_env_vars_recursive(item) for item in data] + elif isinstance(data, str): + return _substitute_env_vars(data) + else: + return data + + +def _extract_json_path(data: dict, path: str) -> Optional[str]: + """ + Extract value from nested dict using dot notation. + + Example: "result.message" -> data["result"]["message"] + """ + try: + keys = path.split('.') + value = data + for key in keys: + if isinstance(value, dict): + value = value.get(key) + else: + return None + return str(value) if value is not None else None + except: + return None + + +def _redact_sensitive(data: Any) -> Any: + """Redact sensitive fields from logs.""" + if isinstance(data, dict): + redacted = {} + sensitive_keys = ['password', 'token', 'api_key', 'secret', 'auth'] + for k, v in data.items(): + if any(s in k.lower() for s in sensitive_keys): + redacted[k] = '***REDACTED***' + else: + redacted[k] = _redact_sensitive(v) + return redacted + elif isinstance(data, list): + return [_redact_sensitive(item) for item in data] + else: + return data diff --git a/services/scheduler/tests/test_rest_api_executor.py b/services/scheduler/tests/test_rest_api_executor.py new file mode 100644 index 0000000..816628f --- /dev/null +++ b/services/scheduler/tests/test_rest_api_executor.py @@ -0,0 +1,631 @@ +""" +Comprehensive tests for the generic REST API executor. + +Tests cover: +- All HTTP methods (GET, POST, PUT, DELETE, PATCH) +- Authentication types (Bearer, Basic, API Key) +- Environment variable substitution +- Error handling +- Response extraction +- Sensitive data redaction + +Run with: pytest tests/test_rest_api_executor.py -v -s +""" +import pytest +import asyncio +import os +from unittest.mock import AsyncMock, patch, MagicMock +import httpx + +from src.executors import rest_api_executor +from src.config import Settings + + +@pytest.mark.executor +@pytest.mark.unit +class TestRestApiExecutor: + """Tests for rest_api_executor module.""" + + @pytest.fixture + def mock_httpx_client(self): + """Mock httpx AsyncClient.""" + return AsyncMock() + + @pytest.fixture + def base_config(self): + """Base configuration for REST API calls.""" + return { + "url": "http://test-service:8080/api/endpoint", + "method": "POST" + } + + # Basic HTTP Method Tests + + @pytest.mark.asyncio + async def test_post_request_success(self, test_settings: Settings): + """Test successful POST request.""" + config = { + "url": "http://httpbin.org/post", + "method": "POST", + "payload": {"test": "data"}, + "timeout": 10 + } + + # Mock httpx response + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "Success"} + mock_response.text = "Success" + + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + result = await rest_api_executor.execute(config, test_settings) + + assert "Success" in result + + @pytest.mark.asyncio + async def test_get_request_success(self, test_settings: Settings): + """Test successful GET request.""" + config = { + "url": "http://httpbin.org/get", + "method": "GET", + "timeout": 10 + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": "GET success"} + mock_response.text = "Success" + + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await rest_api_executor.execute(config, test_settings) + + assert result is not None + + @pytest.mark.asyncio + async def test_put_request_success(self, test_settings: Settings): + """Test successful PUT request.""" + config = { + "url": "http://httpbin.org/put", + "method": "PUT", + "payload": {"update": "data"} + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "Updated"} + mock_response.text = "Updated" + + mock_client.return_value.__aenter__.return_value.put = AsyncMock( + return_value=mock_response + ) + + result = await rest_api_executor.execute(config, test_settings) + + assert "Updated" in result + + @pytest.mark.asyncio + async def test_delete_request_success(self, test_settings: Settings): + """Test successful DELETE request.""" + config = { + "url": "http://httpbin.org/delete", + "method": "DELETE" + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 204 + mock_response.json.side_effect = Exception("No JSON") + mock_response.text = "" + + mock_client.return_value.__aenter__.return_value.delete = AsyncMock( + return_value=mock_response + ) + + result = await rest_api_executor.execute(config, test_settings) + + assert "204" in result + + @pytest.mark.asyncio + async def test_patch_request_success(self, test_settings: Settings): + """Test successful PATCH request.""" + config = { + "url": "http://httpbin.org/patch", + "method": "PATCH", + "payload": {"field": "value"} + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "Patched"} + mock_response.text = "Patched" + + mock_client.return_value.__aenter__.return_value.patch = AsyncMock( + return_value=mock_response + ) + + result = await rest_api_executor.execute(config, test_settings) + + assert "Patched" in result + + # Authentication Tests + + @pytest.mark.asyncio + async def test_bearer_auth(self, test_settings: Settings): + """Test Bearer token authentication.""" + config = { + "url": "http://test-service/api", + "method": "GET", + "auth": { + "type": "bearer", + "token": "test-token-123" + } + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "Authenticated"} + mock_response.text = "Authenticated" + + mock_get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.get = mock_get + + result = await rest_api_executor.execute(config, test_settings) + + # Verify Authorization header was set + call_args = mock_get.call_args + headers = call_args.kwargs.get('headers', {}) + assert 'Authorization' in headers + assert headers['Authorization'] == 'Bearer test-token-123' + + @pytest.mark.asyncio + async def test_basic_auth(self, test_settings: Settings): + """Test Basic authentication.""" + config = { + "url": "http://test-service/api", + "method": "GET", + "auth": { + "type": "basic", + "username": "testuser", + "password": "testpass" + } + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "Authenticated"} + mock_response.text = "Authenticated" + + mock_get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.get = mock_get + + result = await rest_api_executor.execute(config, test_settings) + + # Verify Authorization header was set + call_args = mock_get.call_args + headers = call_args.kwargs.get('headers', {}) + assert 'Authorization' in headers + assert headers['Authorization'].startswith('Basic ') + + @pytest.mark.asyncio + async def test_api_key_auth(self, test_settings: Settings): + """Test API Key authentication.""" + config = { + "url": "http://test-service/api", + "method": "GET", + "auth": { + "type": "api_key", + "key": "my-api-key-123", + "header": "X-API-Key" + } + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "Authenticated"} + mock_response.text = "Authenticated" + + mock_get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.get = mock_get + + result = await rest_api_executor.execute(config, test_settings) + + # Verify API key header was set + call_args = mock_get.call_args + headers = call_args.kwargs.get('headers', {}) + assert 'X-API-Key' in headers + assert headers['X-API-Key'] == 'my-api-key-123' + + # Environment Variable Substitution Tests + + @pytest.mark.asyncio + async def test_env_var_substitution_in_url(self, test_settings: Settings): + """Test environment variable substitution in URL.""" + os.environ['TEST_SERVICE_URL'] = 'http://my-service:8080' + + config = { + "url": "${TEST_SERVICE_URL}/api/endpoint", + "method": "GET" + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"message": "Success"} + mock_response.text = "Success" + + mock_get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.get = mock_get + + result = await rest_api_executor.execute(config, test_settings) + + # Verify URL was substituted + call_args = mock_get.call_args + url = call_args.args[0] if call_args.args else None + assert url == 'http://my-service:8080/api/endpoint' + + del os.environ['TEST_SERVICE_URL'] + + @pytest.mark.asyncio + async def test_env_var_substitution_in_auth_token(self, test_settings: Settings): + """Test environment variable substitution in auth token.""" + os.environ['API_TOKEN'] = 'secret-token-from-env' + + config = { + "url": "http://test-service/api", + "method": "GET", + "auth": { + "type": "bearer", + "token": "${API_TOKEN}" + } + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_response.text = "Success" + + mock_get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.get = mock_get + + result = await rest_api_executor.execute(config, test_settings) + + # Verify token was substituted + call_args = mock_get.call_args + headers = call_args.kwargs.get('headers', {}) + assert headers['Authorization'] == 'Bearer secret-token-from-env' + + del os.environ['API_TOKEN'] + + @pytest.mark.asyncio + async def test_env_var_substitution_in_payload(self, test_settings: Settings): + """Test environment variable substitution in payload.""" + os.environ['DATABASE_NAME'] = 'test_db' + + config = { + "url": "http://test-service/api", + "method": "POST", + "payload": { + "database": "${DATABASE_NAME}", + "action": "backup" + } + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_response.text = "Success" + + mock_post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.post = mock_post + + result = await rest_api_executor.execute(config, test_settings) + + # Verify payload was substituted + call_args = mock_post.call_args + payload = call_args.kwargs.get('json', {}) + assert payload['database'] == 'test_db' + + del os.environ['DATABASE_NAME'] + + # Response Extraction Tests + + @pytest.mark.asyncio + async def test_response_path_extraction(self, test_settings: Settings): + """Test extracting specific field from response.""" + config = { + "url": "http://test-service/api", + "method": "GET", + "response_path": "result.message" + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "result": { + "message": "Extracted message", + "other": "ignored" + } + } + mock_response.text = "Success" + + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await rest_api_executor.execute(config, test_settings) + + assert result == "Extracted message" + + @pytest.mark.asyncio + async def test_response_path_not_found(self, test_settings: Settings): + """Test response path extraction when field doesn't exist.""" + config = { + "url": "http://test-service/api", + "method": "GET", + "response_path": "result.missing" + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": {"other": "data"}} + mock_response.text = "Success" + + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await rest_api_executor.execute(config, test_settings) + + # Should fallback to default message + assert result is not None + + # Error Handling Tests + + @pytest.mark.asyncio + async def test_missing_url_raises_error(self, test_settings: Settings): + """Test that missing URL raises ValueError.""" + config = { + "method": "GET" + } + + with pytest.raises(ValueError, match="Missing required config: 'url'"): + await rest_api_executor.execute(config, test_settings) + + @pytest.mark.asyncio + async def test_invalid_http_method_raises_error(self, test_settings: Settings): + """Test that invalid HTTP method raises ValueError.""" + config = { + "url": "http://test-service/api", + "method": "INVALID" + } + + with pytest.raises(ValueError, match="Invalid HTTP method"): + await rest_api_executor.execute(config, test_settings) + + @pytest.mark.asyncio + async def test_http_error_status_code(self, test_settings: Settings): + """Test handling of HTTP error status codes.""" + config = { + "url": "http://test-service/api", + "method": "GET" + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + with pytest.raises(Exception, match="API call failed with status 500"): + await rest_api_executor.execute(config, test_settings) + + @pytest.mark.asyncio + async def test_custom_success_codes(self, test_settings: Settings): + """Test custom success codes configuration.""" + config = { + "url": "http://test-service/api", + "method": "POST", + "success_codes": [201, 202] + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"message": "Created"} + mock_response.text = "Created" + + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + result = await rest_api_executor.execute(config, test_settings) + + assert "Created" in result + + @pytest.mark.asyncio + async def test_network_error_handling(self, test_settings: Settings): + """Test handling of network errors.""" + config = { + "url": "http://unreachable-service/api", + "method": "GET", + "timeout": 1 + } + + with patch('httpx.AsyncClient') as mock_client: + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + side_effect=httpx.ConnectError("Connection refused") + ) + + with pytest.raises(Exception, match="Request failed"): + await rest_api_executor.execute(config, test_settings) + + # Sensitive Data Redaction Tests + + def test_redact_sensitive_data_in_dict(self): + """Test redaction of sensitive fields in dictionaries.""" + data = { + "username": "user", + "password": "secret123", + "api_key": "key123", + "normal_field": "visible" + } + + redacted = rest_api_executor._redact_sensitive(data) + + assert redacted["username"] == "user" + assert redacted["password"] == "***REDACTED***" + assert redacted["api_key"] == "***REDACTED***" + assert redacted["normal_field"] == "visible" + + def test_redact_sensitive_nested(self): + """Test redaction in nested structures.""" + data = { + "config": { + "database": "mydb", + "password": "secret", + "auth": { + "token": "bearer123" + } + } + } + + redacted = rest_api_executor._redact_sensitive(data) + + assert redacted["config"]["database"] == "mydb" + assert redacted["config"]["password"] == "***REDACTED***" + assert redacted["config"]["auth"]["token"] == "***REDACTED***" + + # Helper Function Tests + + def test_extract_json_path_simple(self): + """Test simple JSONPath extraction.""" + data = {"message": "test"} + result = rest_api_executor._extract_json_path(data, "message") + assert result == "test" + + def test_extract_json_path_nested(self): + """Test nested JSONPath extraction.""" + data = {"result": {"status": {"message": "success"}}} + result = rest_api_executor._extract_json_path(data, "result.status.message") + assert result == "success" + + def test_extract_json_path_not_found(self): + """Test JSONPath extraction when path doesn't exist.""" + data = {"message": "test"} + result = rest_api_executor._extract_json_path(data, "missing.path") + assert result is None + + def test_substitute_env_vars(self): + """Test environment variable substitution.""" + os.environ['TEST_VAR'] = 'test_value' + + result = rest_api_executor._substitute_env_vars("Prefix ${TEST_VAR} suffix") + assert result == "Prefix test_value suffix" + + del os.environ['TEST_VAR'] + + def test_substitute_env_vars_missing(self): + """Test substitution with missing environment variable.""" + result = rest_api_executor._substitute_env_vars("Prefix ${MISSING_VAR} suffix") + # Should replace with empty string + assert result == "Prefix suffix" + + def test_substitute_env_vars_recursive(self): + """Test recursive environment variable substitution.""" + os.environ['HOST'] = 'localhost' + os.environ['PORT'] = '8080' + + data = { + "url": "http://${HOST}:${PORT}/api", + "nested": { + "key": "${HOST}" + } + } + + result = rest_api_executor._substitute_env_vars_recursive(data) + + assert result["url"] == "http://localhost:8080/api" + assert result["nested"]["key"] == "localhost" + + del os.environ['HOST'] + del os.environ['PORT'] + + # Configuration Options Tests + + @pytest.mark.asyncio + async def test_custom_headers(self, test_settings: Settings): + """Test adding custom headers.""" + config = { + "url": "http://test-service/api", + "method": "GET", + "headers": { + "X-Custom-Header": "custom-value", + "User-Agent": "Test-Agent" + } + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_response.text = "Success" + + mock_get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value.get = mock_get + + result = await rest_api_executor.execute(config, test_settings) + + # Verify custom headers were set + call_args = mock_get.call_args + headers = call_args.kwargs.get('headers', {}) + assert headers['X-Custom-Header'] == 'custom-value' + assert headers['User-Agent'] == 'Test-Agent' + + @pytest.mark.asyncio + async def test_ssl_verification_disabled(self, test_settings: Settings): + """Test disabling SSL verification.""" + config = { + "url": "https://test-service/api", + "method": "GET", + "verify_ssl": False + } + + with patch('httpx.AsyncClient') as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_response.text = "Success" + + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await rest_api_executor.execute(config, test_settings) + + # Verify SSL verification was disabled + client_call = mock_client.call_args + assert client_call.kwargs.get('verify') is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"])