"""Comprehensive Test Suite for Action Tools - Following Proven MCP Tool Test Pattern.
import logging
logging.basicConfig(level=logging.DEBUG)
This test suite validates the Action Tools functionality using the systematic
testing approach that achieved 100% success rate across 6 tool suites.
Test Coverage:
- Action addition to macros with parameter validation
- Action type discovery and listing with filtering
- Security validation and XML injection prevention
- Property-based testing for robust input validation
- Integration testing with mocked dependencies
- Error handling for all failure scenarios
Testing Strategy:
- Property-based testing with Hypothesis for comprehensive input coverage
- Mock-based testing for external dependencies (KM client, registries)
- Security validation for input sanitization
- Integration testing scenarios with realistic data
- Performance and timeout testing
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastmcp import Context
from hypothesis import HealthCheck, assume, given, settings
from hypothesis import strategies as st
from hypothesis.strategies import composite
# Import action system components
from src.actions import ActionBuilder, ActionCategory, ActionRegistry, ActionType
from src.core.errors import (
PermissionDeniedError,
)
# Import core types and errors
# Import the tools we're testing
from src.server.tools.action_tools import (
_add_action_to_km_macro,
_truncate_xml_for_preview,
km_add_action,
km_list_action_types,
)
if TYPE_CHECKING:
from collections.abc import Callable
# Test fixtures following proven pattern
@pytest.fixture
def mock_context() -> Mock:
"""Create mock FastMCP context following successful pattern."""
context = Mock(spec=Context)
context.info = AsyncMock()
context.warn = AsyncMock()
context.error = AsyncMock()
context.report_progress = AsyncMock()
context.read_resource = AsyncMock()
context.sample = AsyncMock()
return context
@pytest.fixture
def mock_km_client() -> Mock:
"""Create mock KM client with standard interface."""
client = Mock()
client.check_connection = Mock()
client.execute_macro = Mock()
client.get_macro_info = Mock()
client.modify_macro = Mock()
return client
@pytest.fixture
def mock_action_registry() -> bool:
"""Create mock action registry with sample actions."""
registry = Mock(spec=ActionRegistry)
# Mock action type definitions
text_action = Mock(spec=ActionType)
text_action.identifier = "Type a String"
text_action.category = ActionCategory.TEXT
text_action.required_params = ["text"]
text_action.optional_params = ["by_typing"]
text_action.description = "Type text with optional typing simulation"
pause_action = Mock(spec=ActionType)
pause_action.identifier = "Pause"
pause_action.category = ActionCategory.SYSTEM
pause_action.required_params = ["duration"]
pause_action.optional_params = []
pause_action.description = "Pause execution for specified duration"
app_action = Mock(spec=ActionType)
app_action.identifier = "Activate a Specific Application"
app_action.category = ActionCategory.APPLICATION
app_action.required_params = ["application"]
app_action.optional_params = ["bring_all_windows"]
app_action.description = "Activate application with optional window control"
registry.get_action_type.return_value = text_action
registry.list_action_names.return_value = [
"Type a String",
"Pause",
"Activate a Specific Application",
"Set Variable to Text",
"If Then Else",
"Play Sound",
]
registry.get_action_count.return_value = 80
registry.validate_action_parameters.return_value = {
"valid": True,
"missing_required": [],
"unknown_params": [],
}
registry.list_all_actions.return_value = [text_action, pause_action, app_action]
registry.get_actions_by_category.return_value = [text_action]
registry.get_category_counts.return_value = {
ActionCategory.TEXT: 15,
ActionCategory.APPLICATION: 12,
ActionCategory.SYSTEM: 10,
}
return registry
@pytest.fixture
def mock_action_builder() -> Mock:
"""Create mock action builder with XML generation."""
builder = Mock(spec=ActionBuilder)
builder.add_action = Mock()
builder.get_action_count.return_value = 1
builder.actions = [Mock()]
builder.build_xml.return_value = {
"success": True,
"xml": "<action><identifier>Type a String</identifier><parameters><text>Hello World</text></parameters></action>",
}
return builder
@pytest.fixture
def sample_action_configs() -> Mock:
"""Sample action configurations for testing."""
return {
"text_action": {"text": "Hello World", "by_typing": True},
"pause_action": {"duration": 2.5},
"app_action": {"application": "Safari", "bring_all_windows": False},
"variable_action": {"variable": "TestVar", "text": "TestValue"},
}
# Hypothesis strategies for property-based testing
@composite
def valid_macro_identifiers(draw: Callable[..., Any]) -> Mock:
"""Generate valid macro identifiers."""
# Either UUID format or readable name
if draw(st.booleans()):
return str(uuid.uuid4())
return draw(
st.text(
min_size=1,
max_size=50,
alphabet=st.characters(
whitelist_categories=("Lu", "Ll", "Nd", "Pc", "Pd", "Zs"),
max_codepoint=128,
),
),
)
@composite
def valid_action_types(draw: Callable[..., Any]) -> Mock:
"""Generate valid action type identifiers."""
return draw(
st.sampled_from(
[
"Type a String",
"Pause",
"Activate a Specific Application",
"Set Variable to Text",
"If Then Else",
"Play Sound",
"Move or Click Mouse",
"Type Keystroke",
],
),
)
@composite
def valid_action_configs(draw: Callable[..., Any]) -> dict[str, Any]:
"""Generate valid action configurations."""
action_type = draw(valid_action_types())
if action_type == "Type a String":
return {
"text": draw(st.text(min_size=1, max_size=1000)),
"by_typing": draw(st.booleans()),
}
if action_type == "Pause":
return {"duration": draw(st.floats(min_value=0.1, max_value=60.0))}
if action_type == "Activate a Specific Application":
return {
"application": draw(
st.sampled_from(["Safari", "Chrome", "TextEdit", "Finder"]),
),
"bring_all_windows": draw(st.booleans()),
}
return draw(
st.dictionaries(
st.text(min_size=1, max_size=20),
st.one_of(
st.text(max_size=100),
st.booleans(),
st.floats(min_value=0, max_value=1000),
),
),
)
class TestKMAddAction:
"""Test suite for km_add_action tool using proven patterns."""
@pytest.mark.asyncio
async def test_add_action_success_basic(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
sample_action_configs: dict[str, Any] | Any,
) -> None:
"""Test successful action addition with basic configuration."""
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
return_value=True,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config=sample_action_configs["text_action"],
ctx=mock_context,
)
assert result["success"] is True
assert result["data"]["action_added"]["action_type"] == "Type a String"
assert result["data"]["action_added"]["macro_id"] == "test_macro"
assert result["data"]["action_added"]["enabled"] is True
assert result["data"]["action_added"]["abort_on_failure"] is False
assert result["data"]["validation"]["xml_validated"] is True
assert result["data"]["validation"]["parameters_validated"] is True
assert result["data"]["integration"]["km_client_status"] == "connected"
assert "timestamp" in result["metadata"]
assert "correlation_id" in result["metadata"]
@pytest.mark.asyncio
async def test_add_action_success_with_position(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
sample_action_configs: dict[str, Any] | Any,
) -> None:
"""Test successful action addition with specific position."""
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
return_value=True,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Pause",
action_config=sample_action_configs["pause_action"],
position=2,
timeout=30,
enabled=False,
abort_on_failure=True,
ctx=mock_context,
)
assert result["success"] is True
assert result["data"]["action_added"]["position"] == 2
assert result["data"]["action_added"]["timeout"] == 30
assert result["data"]["action_added"]["enabled"] is False
assert result["data"]["action_added"]["abort_on_failure"] is True
# Verify mock calls
mock_action_builder.add_action.assert_called_once()
call_args = mock_action_builder.add_action.call_args
assert call_args[1]["position"] == 2
assert call_args[1]["enabled"] is False
assert call_args[1]["abort_on_failure"] is True
@pytest.mark.asyncio
async def test_add_action_validation_error_invalid_action_type(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test action addition with invalid action type."""
mock_action_registry.get_action_type.return_value = None
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Invalid Action Type",
action_config={"text": "test"},
ctx=mock_context,
)
assert result["success"] is False
# ValidationError now works correctly and returns VALIDATION_ERROR
assert result["error"]["code"] == "VALIDATION_ERROR"
assert "Validation failed for field 'action_type'" in result["error"]["message"]
assert "Invalid Action Type" in result["error"]["message"]
assert "recovery_suggestion" in result["error"]
@pytest.mark.asyncio
async def test_add_action_validation_error_invalid_parameters(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test action addition with invalid parameters."""
mock_action_registry.validate_action_parameters.return_value = {
"valid": False,
"missing_required": ["text"],
"unknown_params": ["invalid_param"],
}
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config={"invalid_param": "test"},
ctx=mock_context,
)
assert result["success"] is False
# The actual implementation catches ValidationError creation error and returns VALIDATION_ERROR
assert result["error"]["code"] == "VALIDATION_ERROR"
assert (
"Validation failed for field 'action_config'" in result["error"]["message"]
)
@pytest.mark.asyncio
async def test_add_action_xml_generation_error(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test action addition with XML generation failure."""
mock_action_builder.build_xml.return_value = {
"success": False,
"error": "XML generation failed: Invalid characters in parameters",
}
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config={"text": "test"},
ctx=mock_context,
)
assert result["success"] is False
# The actual implementation catches ValidationError creation error and returns ACTION_ADDITION_ERROR
assert result["error"]["code"] == "VALIDATION_ERROR"
assert (
"Validation failed for field 'xml_generation'" in result["error"]["message"]
)
@pytest.mark.asyncio
async def test_add_action_builder_configuration_error(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test action addition with builder configuration failure."""
mock_action_builder.add_action.side_effect = Exception(
"Configuration error: Invalid timeout value",
)
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config={"text": "test"},
ctx=mock_context,
)
assert result["success"] is False
# The actual implementation catches ValidationError creation error and returns VALIDATION_ERROR
assert result["error"]["code"] == "VALIDATION_ERROR"
assert (
"Validation failed for field 'action_configuration'"
in result["error"]["message"]
)
@pytest.mark.asyncio
async def test_add_action_permission_denied(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test action addition with permission denied."""
# Create a proper PermissionDeniedError with required parameters
permission_error = PermissionDeniedError(["macro_modification"], [])
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
side_effect=permission_error,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config={"text": "test"},
ctx=mock_context,
)
assert result["success"] is False
assert result["error"]["code"] == "PERMISSION_DENIED"
assert "Insufficient permissions" in result["error"]["message"]
assert result["error"]["details"]["required_permissions"] == [
"macro_modification",
]
assert "recovery_suggestion" in result["error"]
@pytest.mark.asyncio
async def test_add_action_generic_error(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test action addition with generic error."""
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
side_effect=Exception("Unexpected error"),
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config={"text": "test"},
ctx=mock_context,
)
assert result["success"] is False
assert result["error"]["code"] == "ACTION_ADDITION_ERROR"
assert "Failed to add action to macro" in result["error"]["message"]
assert result["error"]["details"]["error_type"] == "Exception"
assert "recovery_suggestion" in result["error"]
@pytest.mark.asyncio
@given(
macro_id=valid_macro_identifiers(),
action_type=valid_action_types(),
action_config=valid_action_configs(),
)
@settings(
max_examples=20,
deadline=5000,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
async def test_add_action_property_based_success(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
macro_id: str,
action_type: str,
action_config: dict[str, Any],
) -> None:
"""Property-based test for successful action addition."""
assume(len(macro_id) > 0)
assume(len(action_config) > 0)
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
return_value=True,
),
):
result = await km_add_action(
macro_id=macro_id,
action_type=action_type,
action_config=action_config,
ctx=mock_context,
)
assert result["success"] is True
assert result["data"]["action_added"]["action_type"] == action_type
assert result["data"]["action_added"]["macro_id"] == macro_id
assert "correlation_id" in result["metadata"]
@pytest.mark.asyncio
@given(
position=st.integers(min_value=0, max_value=1000),
timeout=st.integers(min_value=1, max_value=3600),
enabled=st.booleans(),
abort_on_failure=st.booleans(),
)
@settings(
max_examples=15,
deadline=5000,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
async def test_add_action_property_based_options(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
position: float,
timeout: float,
enabled: bool,
abort_on_failure: Any,
) -> None:
"""Property-based test for action addition options."""
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
return_value=True,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config={"text": "test"},
position=position,
timeout=timeout,
enabled=enabled,
abort_on_failure=abort_on_failure,
ctx=mock_context,
)
assert result["success"] is True
assert result["data"]["action_added"]["position"] == position
assert result["data"]["action_added"]["timeout"] == timeout
assert result["data"]["action_added"]["enabled"] == enabled
assert result["data"]["action_added"]["abort_on_failure"] == abort_on_failure
class TestKMListActionTypes:
"""Test suite for km_list_action_types tool using proven patterns."""
@pytest.mark.asyncio
async def test_list_action_types_success_no_filters(
self,
mock_context: Any,
mock_action_registry: Any,
) -> None:
"""Test successful action type listing without filters."""
with patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
):
result = await km_list_action_types(ctx=mock_context)
assert result["success"] is True
assert len(result["data"]["actions"]) == 3
assert result["data"]["summary"]["total_available"] == 80
assert result["data"]["summary"]["total_found"] == 3
assert result["data"]["summary"]["returned"] == 3
assert result["data"]["summary"]["filtered_by_category"] is None
assert result["data"]["summary"]["filtered_by_search"] is None
assert result["data"]["summary"]["limit_applied"] == 50
# Verify action structure
action = result["data"]["actions"][0]
assert "identifier" in action
assert "category" in action
assert "description" in action
assert "required_parameters" in action
assert "optional_parameters" in action
assert "parameter_count" in action
# Verify category statistics
assert "categories" in result["data"]
assert result["data"]["categories"]["text"] == 15
assert result["data"]["categories"]["application"] == 12
assert result["data"]["categories"]["system"] == 10
@pytest.mark.asyncio
async def test_list_action_types_success_category_filter(
self,
mock_context: Any,
mock_action_registry: Any,
) -> None:
"""Test successful action type listing with category filter."""
with patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
):
result = await km_list_action_types(category="text", ctx=mock_context)
assert result["success"] is True
assert result["data"]["summary"]["filtered_by_category"] == "text"
mock_action_registry.get_actions_by_category.assert_called_once()
# Verify ActionCategory enum was used
call_args = mock_action_registry.get_actions_by_category.call_args[0]
assert call_args[0] == ActionCategory.TEXT
@pytest.mark.asyncio
async def test_list_action_types_success_search_filter(
self,
mock_context: Any,
mock_action_registry: Any,
) -> None:
"""Test successful action type listing with search filter."""
# Create actions with identifiers that match search
text_action = Mock(spec=ActionType)
text_action.identifier = "Type a String"
text_action.category = ActionCategory.TEXT
text_action.description = "Type text with simulation"
text_action.required_params = ["text"]
text_action.optional_params = ["by_typing"]
mock_action_registry.list_all_actions.return_value = [text_action]
with patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
):
result = await km_list_action_types(search="type", ctx=mock_context)
assert result["success"] is True
assert result["data"]["summary"]["filtered_by_search"] == "type"
assert len(result["data"]["actions"]) == 1
assert result["data"]["actions"][0]["identifier"] == "Type a String"
@pytest.mark.asyncio
async def test_list_action_types_success_limit_applied(
self,
mock_context: Any,
mock_action_registry: Any,
) -> None:
"""Test successful action type listing with limit."""
with patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
):
result = await km_list_action_types(limit=2, ctx=mock_context)
assert result["success"] is True
assert result["data"]["summary"]["limit_applied"] == 2
assert len(result["data"]["actions"]) == 2
assert result["data"]["summary"]["total_found"] == 3
assert result["data"]["summary"]["returned"] == 2
@pytest.mark.asyncio
async def test_list_action_types_invalid_category(
self,
mock_context: Any,
mock_action_registry: Any,
) -> None:
"""Test action type listing with invalid category."""
with patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
):
result = await km_list_action_types(
category="invalid_category",
ctx=mock_context,
)
assert result["success"] is False
assert result["error"]["code"] == "ACTION_LIST_ERROR"
assert "Failed to list action types" in result["error"]["message"]
assert result["error"]["details"]["category"] == "invalid_category"
@pytest.mark.asyncio
async def test_list_action_types_registry_error(
self,
mock_context: Any,
mock_action_registry: Any,
) -> None:
"""Test action type listing with registry error."""
mock_action_registry.list_all_actions.side_effect = Exception("Registry error")
with patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
):
result = await km_list_action_types(ctx=mock_context)
assert result["success"] is False
assert result["error"]["code"] == "ACTION_LIST_ERROR"
assert "Failed to list action types" in result["error"]["message"]
assert "Registry error" in result["error"]["message"]
@pytest.mark.asyncio
@given(
category=st.one_of(
st.none(),
st.sampled_from(["text", "application", "system", "control", "variable"]),
),
search=st.one_of(st.none(), st.text(min_size=1, max_size=50)),
limit=st.integers(min_value=1, max_value=200),
)
@settings(
max_examples=15,
deadline=5000,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
async def test_list_action_types_property_based(
self,
mock_context: Any,
mock_action_registry: Any,
category: str,
search: Any,
limit: int,
) -> None:
"""Property-based test for action type listing."""
with patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
):
result = await km_list_action_types(
category=category,
search=search,
limit=limit,
ctx=mock_context,
)
assert result["success"] is True
assert result["data"]["summary"]["filtered_by_category"] == category
assert result["data"]["summary"]["filtered_by_search"] == search
assert result["data"]["summary"]["limit_applied"] == limit
assert len(result["data"]["actions"]) <= limit
assert "correlation_id" in result["metadata"]
class TestActionToolsIntegration:
"""Integration tests for action tools with realistic scenarios."""
@pytest.mark.asyncio
async def test_complete_action_workflow(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test complete workflow: list actions, then add action."""
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
return_value=True,
),
):
# First, list available actions
list_result = await km_list_action_types(ctx=mock_context)
assert list_result["success"] is True
# Get an action type from the list
action_type = list_result["data"]["actions"][0]["identifier"]
# Add that action to a macro
add_result = await km_add_action(
macro_id="test_macro",
action_type=action_type,
action_config={"text": "Integration test"},
ctx=mock_context,
)
assert add_result["success"] is True
assert add_result["data"]["action_added"]["action_type"] == action_type
@pytest.mark.asyncio
async def test_multiple_actions_same_macro(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test adding multiple actions to the same macro."""
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
return_value=True,
),
):
macro_id = "multi_action_macro"
# Add first action
result1 = await km_add_action(
macro_id=macro_id,
action_type="Type a String",
action_config={"text": "First action"},
position=0,
ctx=mock_context,
)
assert result1["success"] is True
# Add second action
result2 = await km_add_action(
macro_id=macro_id,
action_type="Pause",
action_config={"duration": 1.0},
position=1,
ctx=mock_context,
)
assert result2["success"] is True
# Add third action
result3 = await km_add_action(
macro_id=macro_id,
action_type="Type a String",
action_config={"text": "Third action"},
position=2,
ctx=mock_context,
)
assert result3["success"] is True
class TestActionToolsSecurityValidation:
"""Security validation tests for action tools."""
@pytest.mark.asyncio
async def test_add_action_xml_injection_prevention(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test XML injection prevention in action configuration."""
malicious_config = {
"text": "Hello <script>alert('XSS')</script> World",
"by_typing": True,
}
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
return_value=True,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config=malicious_config,
ctx=mock_context,
)
# Should succeed because XML generation handles escaping
assert result["success"] is True
# Verify XML was generated (security handled by ActionBuilder)
mock_action_builder.build_xml.assert_called_once()
@pytest.mark.asyncio
@given(
malicious_text=st.text(min_size=1, max_size=50).filter(
lambda x: any(char in x for char in "<>&\"'"),
),
)
@settings(
max_examples=10,
deadline=5000,
suppress_health_check=[
HealthCheck.function_scoped_fixture,
HealthCheck.filter_too_much,
],
)
async def test_add_action_security_property_based(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
malicious_text: Any,
) -> None:
"""Property-based test for security validation."""
assume(len(malicious_text) > 0)
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
return_value=True,
),
):
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config={"text": malicious_text},
ctx=mock_context,
)
# Should either succeed with proper escaping or fail with validation error
assert "success" in result
if result["success"]:
assert result["data"]["validation"]["security_passed"] is True
else:
assert result["error"]["code"] in [
"VALIDATION_ERROR",
"ACTION_ADDITION_ERROR",
]
class TestActionToolsHelperFunctions:
"""Test helper functions used by action tools."""
def test_truncate_xml_for_preview_short_xml(self) -> None:
"""Test XML truncation for short XML strings."""
short_xml = "<action><identifier>Test</identifier></action>"
result = _truncate_xml_for_preview(short_xml)
assert result == short_xml
def test_truncate_xml_for_preview_long_xml(self) -> None:
"""Test XML truncation for long XML strings."""
long_xml = "<action>" + "x" * 600 + "</action>"
result = _truncate_xml_for_preview(long_xml)
assert len(result) <= 503 # 500 + "..."
assert result.endswith("...")
def test_truncate_xml_for_preview_custom_length(self) -> None:
"""Test XML truncation with custom length."""
xml = "x" * 100
result = _truncate_xml_for_preview(xml, max_length=50)
assert len(result) <= 53 # 50 + "..."
assert result.endswith("...")
def test_add_action_to_km_macro_mock_implementation(self) -> None:
"""Test the mock implementation of KM macro integration."""
mock_client = Mock()
result = _add_action_to_km_macro(
mock_client,
"test_macro",
"<action><identifier>Test</identifier></action>",
0,
)
assert result is True
class TestActionToolsPerformanceAndTimeouts:
"""Performance and timeout testing for action tools."""
@pytest.mark.asyncio
async def test_add_action_performance_measurement(
self,
mock_context: Any,
mock_action_registry: Any,
mock_action_builder: Any,
) -> None:
"""Test that performance metrics are captured."""
with (
patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
),
patch(
"src.server.tools.action_tools.ActionBuilder",
return_value=mock_action_builder,
),
patch("src.server.tools.action_tools.get_km_client"),
patch(
"src.server.tools.action_tools._add_action_to_km_macro",
return_value=True,
),
):
start_time = datetime.now(UTC)
result = await km_add_action(
macro_id="test_macro",
action_type="Type a String",
action_config={"text": "test"},
ctx=mock_context,
)
end_time = datetime.now(UTC)
assert result["success"] is True
assert "execution_time_seconds" in result["metadata"]
assert result["metadata"]["execution_time_seconds"] > 0
assert (
result["metadata"]["execution_time_seconds"]
< (end_time - start_time).total_seconds() + 1
)
@pytest.mark.asyncio
async def test_list_action_types_performance_measurement(
self,
mock_context: Any,
mock_action_registry: Any,
) -> None:
"""Test that performance metrics are captured for listing."""
with patch(
"src.server.tools.action_tools.ActionRegistry",
return_value=mock_action_registry,
):
result = await km_list_action_types(ctx=mock_context)
assert result["success"] is True
assert "timestamp" in result["metadata"]
assert "correlation_id" in result["metadata"]
# Verify timestamp is recent
timestamp = datetime.fromisoformat(
result["metadata"]["timestamp"].replace("Z", "+00:00"),
)
assert (datetime.now(UTC) - timestamp).total_seconds() < 5
# Run the tests
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])