Files
leocrm/tests/test_phase_g_workflows.py
T

373 lines
14 KiB
Python
Raw Normal View History

"""Tests for Phase G — Workflow MVP: step handlers, wait/resume, retry, SSRF, triggers.
All tests use mocks — no real DB/LLM/Redis/HTTP needed.
"""
from __future__ import annotations
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.workflows.step_handlers import (
StepResult,
get_step_handler,
get_available_step_types,
_is_url_safe,
)
# ─── Step Handler Registry ────────────────────────────────────────────────────
class TestStepHandlerRegistry:
"""Test the step handler registry."""
def test_all_step_types_registered(self):
"""All 10 built-in step types are registered."""
types = get_available_step_types()
expected = {"agent", "calendar", "crm", "dms", "event", "http", "mail", "search", "wait", "webhook"}
assert set(types) == expected
def test_get_step_handler_returns_callable(self):
"""get_step_handler returns a callable for each registered type."""
for step_type in get_available_step_types():
handler = get_step_handler(step_type)
assert handler is not None
assert callable(handler)
def test_get_step_handler_unknown_returns_none(self):
"""get_step_handler returns None for unknown step type."""
assert get_step_handler("nonexistent") is None
# ─── Wait Step (G-WAIT) ───────────────────────────────────────────────────────
class TestWaitStep:
"""Test the wait/delay step handler."""
@pytest.mark.asyncio
async def test_wait_with_duration(self):
"""Wait step with duration_seconds sets resume_at correctly."""
instance = MagicMock()
instance.context = {}
step = {"type": "wait", "config": {"duration_seconds": 60}}
result = await get_step_handler("wait")(
MagicMock(), uuid.uuid4(), instance, step
)
assert result.advance is False
assert result.wait_until is not None
assert result.wait_reason == "wait"
# resume_at should be ~60s in the future
now = datetime.now(UTC)
delta = result.wait_until - now
assert 50 < delta.total_seconds() < 70
@pytest.mark.asyncio
async def test_wait_with_absolute_time(self):
"""Wait step with resume_at sets exact resume time."""
instance = MagicMock()
instance.context = {}
future = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
step = {"type": "wait", "config": {"resume_at": future}}
result = await get_step_handler("wait")(
MagicMock(), uuid.uuid4(), instance, step
)
assert result.advance is False
assert result.wait_until is not None
assert result.wait_reason == "wait"
@pytest.mark.asyncio
async def test_wait_without_config_aborts(self):
"""Wait step without duration_seconds or resume_at aborts."""
instance = MagicMock()
instance.context = {}
step = {"type": "wait", "config": {}}
result = await get_step_handler("wait")(
MagicMock(), uuid.uuid4(), instance, step
)
assert result.abort is True
assert "requires" in result.error
# ─── SSRF Protection (G-HTTP) ────────────────────────────────────────────────
class TestSSRFProtection:
"""Test the SSRF protection for HTTP and webhook steps."""
def test_blocks_localhost(self):
"""SSRF blocks localhost."""
assert _is_url_safe("http://localhost:8080/api") is False
assert _is_url_safe("http://127.0.0.1:8080/api") is False
def test_blocks_private_ips(self):
"""SSRF blocks private IP ranges."""
assert _is_url_safe("http://192.168.1.1/api") is False
assert _is_url_safe("http://10.0.0.1/api") is False
assert _is_url_safe("http://172.16.0.1/api") is False
def test_blocks_non_http_schemes(self):
"""SSRF blocks non-http/https schemes."""
assert _is_url_safe("ftp://example.com/file") is False
assert _is_url_safe("file:///etc/passwd") is False
assert _is_url_safe("gopher://example.com") is False
def test_allows_public_urls(self):
"""SSRF allows public HTTP/HTTPS URLs."""
assert _is_url_safe("https://api.example.com/webhook") is True
assert _is_url_safe("http://example.com/api") is True
def test_blocks_metadata_endpoint(self):
"""SSRF blocks cloud metadata endpoints."""
assert _is_url_safe("http://metadata.google.internal/computeMetadata/") is False
def test_blocks_ipv6_loopback(self):
"""SSRF blocks IPv6 loopback."""
assert _is_url_safe("http://[::1]:8080/api") is False
def test_handles_invalid_url(self):
"""SSRF handles invalid URLs gracefully."""
assert _is_url_safe("") is False
assert _is_url_safe("not-a-url") is False
# ─── HTTP Step (G-HTTP) ──────────────────────────────────────────────────────
class TestHttpStep:
"""Test the HTTP request step handler."""
@pytest.mark.asyncio
async def test_http_without_url_aborts(self):
"""HTTP step without URL aborts."""
instance = MagicMock()
instance.context = {}
step = {"type": "http", "config": {"method": "GET"}}
result = await get_step_handler("http")(
MagicMock(), uuid.uuid4(), instance, step
)
assert result.abort is True
assert "url" in result.error.lower()
@pytest.mark.asyncio
async def test_http_with_ssrf_url_aborts(self):
"""HTTP step with SSRF-blocked URL aborts."""
instance = MagicMock()
instance.context = {}
step = {"type": "http", "config": {"url": "http://127.0.0.1:8080/secret"}}
result = await get_step_handler("http")(
MagicMock(), uuid.uuid4(), instance, step
)
assert result.abort is True
assert "ssrf" in result.error.lower()
# ─── Event Step (G-EVT) ──────────────────────────────────────────────────────
class TestEventStep:
"""Test the event publishing step handler."""
@pytest.mark.asyncio
async def test_event_without_name_aborts(self):
"""Event step without event_name aborts."""
instance = MagicMock()
instance.id = uuid.uuid4()
instance.context = {}
step = {"type": "event", "config": {"payload": {"key": "value"}}}
result = await get_step_handler("event")(
MagicMock(), uuid.uuid4(), instance, step
)
assert result.abort is True
assert "event_name" in result.error
@pytest.mark.asyncio
async def test_event_publishes_successfully(self):
"""Event step publishes event to event bus."""
instance = MagicMock()
instance.id = uuid.uuid4()
instance.context = {}
step = {"type": "event", "config": {"event_name": "test.event", "payload": {"key": "value"}}}
with patch("app.core.event_bus.get_event_bus") as mock_get_bus:
mock_bus = MagicMock()
mock_bus.publish = AsyncMock()
mock_get_bus.return_value = mock_bus
result = await get_step_handler("event")(
MagicMock(), uuid.uuid4(), instance, step
)
assert result.advance is True
assert result.output["event_published"] == "test.event"
mock_bus.publish.assert_called_once()
# ─── CRM Step (G-CRM) ────────────────────────────────────────────────────────
class TestCrmStep:
"""Test the CRM action step handler."""
@pytest.mark.asyncio
async def test_crm_without_action_aborts(self):
"""CRM step without action aborts."""
instance = MagicMock()
instance.context = {}
step = {"type": "crm", "config": {"data": {"name": "Test"}}}
result = await get_step_handler("crm")(
MagicMock(), uuid.uuid4(), instance, step
)
assert result.abort is True
assert "action" in result.error
@pytest.mark.asyncio
async def test_crm_unknown_action_aborts(self):
"""CRM step with unknown action aborts."""
instance = MagicMock()
instance.context = {}
step = {"type": "crm", "config": {"action": "invalid_action"}}
result = await get_step_handler("crm")(
MagicMock(), uuid.uuid4(), instance, step
)
assert result.abort is True
assert "unknown" in result.error.lower()
# ─── StepResult ──────────────────────────────────────────────────────────────
class TestStepResult:
"""Test the StepResult class."""
def test_default_step_result_advances(self):
"""Default StepResult advances to next step."""
result = StepResult()
assert result.advance is True
assert result.next_index is None
assert result.wait_until is None
assert result.error is None
assert result.abort is False
assert result.output == {}
def test_step_result_with_wait(self):
"""StepResult with wait_until does not advance."""
wait_time = datetime.now(UTC) + timedelta(seconds=30)
result = StepResult(advance=False, wait_until=wait_time, wait_reason="wait")
assert result.advance is False
assert result.wait_until == wait_time
assert result.wait_reason == "wait"
def test_step_result_with_error(self):
"""StepResult with error but no abort is retryable."""
result = StepResult(error="Something failed")
assert result.error == "Something failed"
assert result.abort is False
def test_step_result_with_abort(self):
"""StepResult with abort stops the workflow."""
result = StepResult(error="Fatal error", abort=True)
assert result.abort is True
assert result.error == "Fatal error"
def test_step_result_with_branch(self):
"""StepResult with next_index branches to specific step."""
result = StepResult(next_index=5)
assert result.next_index == 5
assert result.advance is True # Still advances, just to specific index
# ─── Workflow Engine Resume (G-RUN) ──────────────────────────────────────────
class TestWorkflowEngineResume:
"""Test the WorkflowEngine resume functionality."""
@pytest.mark.asyncio
async def test_resume_non_waiting_instance_returns_unchanged(self):
"""Resume on a non-waiting instance returns the instance unchanged."""
from app.workflows.engine import WorkflowEngine
instance = MagicMock()
instance.status = "completed"
instance.id = uuid.uuid4()
instance.workflow_id = uuid.uuid4()
instance.current_step_index = 0
db = MagicMock()
engine = WorkflowEngine(db, uuid.uuid4())
result = await engine.resume(instance)
# Should return _instance_to_dict result, not process
assert result is not None
@pytest.mark.asyncio
async def test_find_resumable_workflows_query(self):
"""find_resumable_workflows queries for waiting instances with passed resume_at."""
from app.workflows.engine import find_resumable_workflows
from app.models.workflow import WorkflowInstance
db = MagicMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
db.execute = AsyncMock(return_value=mock_result)
await find_resumable_workflows(db, uuid.uuid4())
db.execute.assert_called_once()
# ─── Workflow Schema (G-COND) ───────────────────────────────────────────────
class TestWorkflowSchema:
"""Test the extended WorkflowStep schema."""
def test_step_schema_accepts_new_types(self):
"""WorkflowStep schema accepts all new step types."""
from app.schemas.workflow import WorkflowStep
for step_type in ["wait", "http", "mail", "calendar", "dms", "search", "agent", "crm", "event", "webhook"]:
step = WorkflowStep(name=f"Test {step_type}", type=step_type, config={})
assert step.type == step_type
def test_step_schema_rejects_unknown_type(self):
"""WorkflowStep schema rejects unknown step types."""
from app.schemas.workflow import WorkflowStep
from pydantic import ValidationError
with pytest.raises(ValidationError):
WorkflowStep(name="Bad", type="unknown_type", config={})
def test_step_schema_still_accepts_legacy_types(self):
"""WorkflowStep schema still accepts legacy step types."""
from app.schemas.workflow import WorkflowStep
for step_type in ["action", "approval", "notification", "condition"]:
step = WorkflowStep(name=f"Legacy {step_type}", type=step_type, config={})
assert step.type == step_type
# ─── Workflow Model (G-RUN) ─────────────────────────────────────────────────
class TestWorkflowModelDurableFields:
"""Test the new durable/resumable fields on WorkflowInstance."""
def test_workflow_instance_has_resume_fields(self):
"""WorkflowInstance model has all G-RUN fields."""
from app.models.workflow import WorkflowInstance
# Check that the model has the new columns
assert hasattr(WorkflowInstance, "resume_at")
assert hasattr(WorkflowInstance, "resume_reason")
assert hasattr(WorkflowInstance, "step_state")
assert hasattr(WorkflowInstance, "idempotency_key")
assert hasattr(WorkflowInstance, "lock_owner")
assert hasattr(WorkflowInstance, "lock_expires_at")
assert hasattr(WorkflowInstance, "error_message")
assert hasattr(WorkflowInstance, "retry_count")
assert hasattr(WorkflowInstance, "max_retries")