"""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")