"""Unit tests for PlaywrightExecutor. These tests focus on validation logic without launching browsers. Browser-based integration tests are skipped by default (SKIP_BROWSER_TESTS=true). """ import os import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest # Mock playwright module before any imports that might use it sys.modules["playwright"] = MagicMock() sys.modules["playwright.async_api"] = MagicMock() # Patch the logger before importing the executor with patch("app.utils.logger.get_logger", return_value=MagicMock()): from app.utils.validation import is_domain_allowed, validate_allowed_domains, ValidationError class TestDomainValidation: """Test domain allowlist validation functions.""" # ==================== is_domain_allowed Tests ==================== def test_exact_domain_match(self): """Test exact domain matching.""" assert is_domain_allowed("https://cloud.example.com/path", ["cloud.example.com"]) is True assert is_domain_allowed("https://cloud.example.com", ["cloud.example.com"]) is True assert is_domain_allowed("http://cloud.example.com", ["cloud.example.com"]) is True def test_exact_domain_no_match(self): """Test exact domain non-matching.""" assert is_domain_allowed("https://evil.com/path", ["cloud.example.com"]) is False assert is_domain_allowed("https://sub.cloud.example.com", ["cloud.example.com"]) is False def test_wildcard_subdomain_match(self): """Test wildcard subdomain matching.""" assert is_domain_allowed("https://sub.example.com", ["*.example.com"]) is True assert is_domain_allowed("https://deep.sub.example.com", ["*.example.com"]) is True assert is_domain_allowed("https://example.com", ["*.example.com"]) is True def test_wildcard_subdomain_no_match(self): """Test wildcard subdomain non-matching.""" assert is_domain_allowed("https://evil.com", ["*.example.com"]) is False assert is_domain_allowed("https://example.org", ["*.example.com"]) is False def test_domain_with_port(self): """Test domain matching with port specification.""" assert is_domain_allowed("https://cloud.example.com:8443/path", ["cloud.example.com:8443"]) is True assert is_domain_allowed("https://cloud.example.com:8443", ["cloud.example.com:8443"]) is True # Wrong port should not match assert is_domain_allowed("https://cloud.example.com:9000", ["cloud.example.com:8443"]) is False # No port in URL should not match port-specific pattern assert is_domain_allowed("https://cloud.example.com", ["cloud.example.com:8443"]) is False def test_multiple_allowed_domains(self): """Test with multiple allowed domains.""" allowed = ["cloud.example.com", "mail.example.com", "*.internal.com"] assert is_domain_allowed("https://cloud.example.com", allowed) is True assert is_domain_allowed("https://mail.example.com", allowed) is True assert is_domain_allowed("https://app.internal.com", allowed) is True assert is_domain_allowed("https://evil.com", allowed) is False def test_empty_inputs(self): """Test with empty inputs.""" assert is_domain_allowed("", ["example.com"]) is False assert is_domain_allowed("https://example.com", []) is False assert is_domain_allowed("", []) is False def test_case_insensitive(self): """Test case-insensitive matching.""" assert is_domain_allowed("https://Cloud.Example.COM", ["cloud.example.com"]) is True assert is_domain_allowed("https://cloud.example.com", ["Cloud.Example.COM"]) is True # ==================== validate_allowed_domains Tests ==================== def test_validate_valid_domains(self): """Test validation of valid domain patterns.""" result = validate_allowed_domains(["example.com", "cloud.example.com"]) assert result == ["example.com", "cloud.example.com"] def test_validate_wildcard_domains(self): """Test validation of wildcard domain patterns.""" result = validate_allowed_domains(["*.example.com", "*.internal.org"]) assert result == ["*.example.com", "*.internal.org"] def test_validate_with_ports(self): """Test validation of domains with ports.""" result = validate_allowed_domains(["example.com:8080", "cloud.example.com:8443"]) assert result == ["example.com:8080", "cloud.example.com:8443"] def test_validate_empty_list_raises(self): """Test that empty list raises ValidationError.""" with pytest.raises(ValidationError, match="cannot be empty"): validate_allowed_domains([]) def test_validate_protocol_raises(self): """Test that domains with protocol raise ValidationError.""" with pytest.raises(ValidationError, match="should not include protocol"): validate_allowed_domains(["https://example.com"]) def test_validate_invalid_wildcard_raises(self): """Test that invalid wildcards raise ValidationError.""" with pytest.raises(ValidationError, match="Wildcards must be at the start"): validate_allowed_domains(["example.*.com"]) with pytest.raises(ValidationError, match="Wildcards must be at the start"): validate_allowed_domains(["*"]) def test_validate_normalizes_case(self): """Test that validation normalizes to lowercase.""" result = validate_allowed_domains(["Example.COM", "CLOUD.Example.com"]) assert result == ["example.com", "cloud.example.com"] class TestPlaywrightExecutor: """Test suite for PlaywrightExecutor.""" @pytest.fixture def executor(self): """Create executor instance with mocked logger.""" with patch("app.executors.base.get_logger", return_value=MagicMock()): from app.executors.playwright_executor import PlaywrightExecutor return PlaywrightExecutor() @pytest.fixture def mock_settings(self, tmp_path): """Mock settings with temporary paths.""" settings = MagicMock() settings.playwright_artifacts_dir = str(tmp_path / "playwright-artifacts") settings.playwright_default_timeout_ms = 60000 settings.playwright_navigation_timeout_ms = 120000 return settings # ==================== Validation Error Tests ==================== @pytest.mark.asyncio async def test_missing_scenario_field(self, executor, mock_settings): """Test that missing scenario field returns error.""" with patch("app.executors.playwright_executor.get_settings", return_value=mock_settings): result = await executor.execute({ "inputs": {"base_url": "https://example.com"}, "options": {"allowed_domains": ["example.com"]} }) assert result.success is False assert "Missing required fields: scenario" in result.error @pytest.mark.asyncio async def test_missing_inputs_field(self, executor, mock_settings): """Test that missing inputs field returns error.""" with patch("app.executors.playwright_executor.get_settings", return_value=mock_settings): result = await executor.execute({ "scenario": "test_scenario", "options": {"allowed_domains": ["example.com"]} }) assert result.success is False assert "Missing required fields: inputs" in result.error @pytest.mark.asyncio async def test_missing_allowed_domains(self, executor, mock_settings): """Test that missing allowed_domains returns security error.""" with patch("app.executors.playwright_executor.get_settings", return_value=mock_settings): result = await executor.execute({ "scenario": "test_scenario", "inputs": {"base_url": "https://example.com"}, "options": {} }) assert result.success is False assert "allowed_domains" in result.error assert "required" in result.error.lower() @pytest.mark.asyncio async def test_missing_options_means_no_domains(self, executor, mock_settings): """Test that missing options dict means no allowed_domains.""" with patch("app.executors.playwright_executor.get_settings", return_value=mock_settings): result = await executor.execute({ "scenario": "test_scenario", "inputs": {"base_url": "https://example.com"}, }) assert result.success is False assert "allowed_domains" in result.error @pytest.mark.asyncio async def test_invalid_allowed_domains_format(self, executor, mock_settings): """Test that invalid domain patterns return error.""" with patch("app.executors.playwright_executor.get_settings", return_value=mock_settings): result = await executor.execute({ "scenario": "test_scenario", "inputs": {"base_url": "https://example.com"}, "options": {"allowed_domains": ["https://example.com"]} # Protocol not allowed }) assert result.success is False assert "Invalid allowed_domains" in result.error @pytest.mark.asyncio async def test_unknown_scenario(self, executor, mock_settings): """Test that unknown scenario returns error with available list.""" with patch("app.executors.playwright_executor.get_settings", return_value=mock_settings): result = await executor.execute({ "scenario": "nonexistent_scenario", "inputs": {"base_url": "https://example.com"}, "options": {"allowed_domains": ["example.com"]} }) assert result.success is False assert "Unknown scenario" in result.error assert "nonexistent_scenario" in result.error assert "available_scenarios" in result.data # ==================== Task Type Tests ==================== def test_task_type_is_playwright(self, executor): """Test that executor reports correct task type.""" assert executor.task_type == "PLAYWRIGHT" class TestScenarioRegistry: """Test scenario registration and lookup.""" def test_register_and_get_scenario(self): """Test registering and retrieving a scenario.""" from app.playwright_scenarios import get_scenario, get_scenario_names, _SCENARIO_REGISTRY from app.playwright_scenarios import register_scenario, BaseScenario, ScenarioResult # Clear registry for clean test original_registry = _SCENARIO_REGISTRY.copy() _SCENARIO_REGISTRY.clear() try: @register_scenario class TestScenario(BaseScenario): @property def name(self) -> str: return "test_scenario" @property def required_inputs(self) -> list[str]: return ["base_url"] async def execute(self, page, inputs, options) -> ScenarioResult: return ScenarioResult(success=True, data={}) # Should find the registered scenario scenario = get_scenario("test_scenario") assert scenario is not None assert scenario.name == "test_scenario" # Should be in the list names = get_scenario_names() assert "test_scenario" in names finally: # Restore original registry _SCENARIO_REGISTRY.clear() _SCENARIO_REGISTRY.update(original_registry) def test_get_unknown_scenario_returns_none(self): """Test that unknown scenario lookup returns None.""" from app.playwright_scenarios import get_scenario scenario = get_scenario("definitely_does_not_exist_xyz123") assert scenario is None class TestScenarioOptions: """Test ScenarioOptions dataclass.""" def test_default_values(self): """Test default option values.""" from app.playwright_scenarios import ScenarioOptions options = ScenarioOptions() assert options.timeout_ms == 60000 assert options.screenshot_on_failure is True assert options.screenshot_on_success is False assert options.save_trace is False assert options.allowed_domains == [] assert options.artifacts_dir is None def test_custom_values(self): """Test custom option values.""" from app.playwright_scenarios import ScenarioOptions options = ScenarioOptions( timeout_ms=30000, screenshot_on_failure=False, screenshot_on_success=True, save_trace=True, allowed_domains=["example.com"], artifacts_dir=Path("/tmp/artifacts"), ) assert options.timeout_ms == 30000 assert options.screenshot_on_failure is False assert options.screenshot_on_success is True assert options.save_trace is True assert options.allowed_domains == ["example.com"] assert options.artifacts_dir == Path("/tmp/artifacts") def test_string_artifacts_dir_converted(self): """Test that string artifacts_dir is converted to Path.""" from app.playwright_scenarios import ScenarioOptions options = ScenarioOptions(artifacts_dir="/tmp/artifacts") assert isinstance(options.artifacts_dir, Path) # Path separators differ by OS, just check it's a valid Path assert options.artifacts_dir == Path("/tmp/artifacts") class TestScenarioResult: """Test ScenarioResult dataclass.""" def test_success_result(self): """Test successful result creation.""" from app.playwright_scenarios import ScenarioResult result = ScenarioResult( success=True, data={"setup": "complete"}, screenshots=["/tmp/success.png"], ) assert result.success is True assert result.data == {"setup": "complete"} assert result.screenshots == ["/tmp/success.png"] assert result.error is None def test_failure_result(self): """Test failure result creation.""" from app.playwright_scenarios import ScenarioResult result = ScenarioResult( success=False, data={}, error="Element not found", ) assert result.success is False assert result.error == "Element not found" class TestBaseScenario: """Test BaseScenario ABC.""" def test_validate_inputs_missing(self): """Test input validation returns missing keys.""" from app.playwright_scenarios import BaseScenario, ScenarioResult class TestScenario(BaseScenario): @property def name(self) -> str: return "test" @property def required_inputs(self) -> list[str]: return ["base_url", "username", "password"] async def execute(self, page, inputs, options) -> ScenarioResult: return ScenarioResult(success=True, data={}) scenario = TestScenario() # Missing all inputs missing = scenario.validate_inputs({}) assert "base_url" in missing assert "username" in missing assert "password" in missing # Missing some inputs missing = scenario.validate_inputs({"base_url": "https://example.com"}) assert "base_url" not in missing assert "username" in missing assert "password" in missing # All inputs present missing = scenario.validate_inputs({ "base_url": "https://example.com", "username": "admin", "password": "secret", }) assert missing == [] def test_default_optional_inputs(self): """Test default optional inputs is empty.""" from app.playwright_scenarios import BaseScenario, ScenarioResult class TestScenario(BaseScenario): @property def name(self) -> str: return "test" @property def required_inputs(self) -> list[str]: return ["base_url"] async def execute(self, page, inputs, options) -> ScenarioResult: return ScenarioResult(success=True, data={}) scenario = TestScenario() assert scenario.optional_inputs == [] def test_default_description(self): """Test default description uses name.""" from app.playwright_scenarios import BaseScenario, ScenarioResult class TestScenario(BaseScenario): @property def name(self) -> str: return "my_test_scenario" @property def required_inputs(self) -> list[str]: return [] async def execute(self, page, inputs, options) -> ScenarioResult: return ScenarioResult(success=True, data={}) scenario = TestScenario() assert "my_test_scenario" in scenario.description # Skip browser tests by default SKIP_BROWSER_TESTS = os.environ.get("SKIP_BROWSER_TESTS", "true").lower() == "true" @pytest.mark.skipif(SKIP_BROWSER_TESTS, reason="Browser tests skipped (set SKIP_BROWSER_TESTS=false to run)") class TestPlaywrightExecutorIntegration: """Integration tests that require a real browser. These tests are skipped by default. Set SKIP_BROWSER_TESTS=false to run. """ @pytest.fixture def executor(self): """Create executor instance.""" with patch("app.executors.base.get_logger", return_value=MagicMock()): from app.executors.playwright_executor import PlaywrightExecutor return PlaywrightExecutor() @pytest.mark.asyncio async def test_domain_blocking_in_browser(self, executor, tmp_path): """Test that blocked domains are actually blocked in browser.""" # This would require a mock HTTP server and real browser # Implementation deferred to manual testing pass