"""Unit tests for DockerExecutor.""" import asyncio import os from pathlib import Path from unittest.mock import MagicMock, patch, AsyncMock import pytest # Patch the logger before importing the executor with patch("app.utils.logger.get_logger", return_value=MagicMock()): from app.executors.docker_executor import DockerExecutor class TestDockerExecutor: """Tests for DockerExecutor.""" @pytest.fixture def executor(self): """Create executor with mocked logger.""" with patch("app.executors.base.get_logger", return_value=MagicMock()): return DockerExecutor() @pytest.fixture def temp_stacks_root(self, tmp_path): """Create a temporary stacks root directory.""" stacks_dir = tmp_path / "opt" / "letsbe" / "stacks" stacks_dir.mkdir(parents=True) return stacks_dir @pytest.fixture def mock_settings(self, temp_stacks_root): """Mock settings with temporary paths.""" settings = MagicMock() settings.allowed_stacks_root = str(temp_stacks_root) return settings @pytest.fixture def mock_get_settings(self, mock_settings): """Patch get_settings to return mock settings.""" with patch("app.executors.docker_executor.get_settings", return_value=mock_settings): yield mock_settings @pytest.fixture def sample_compose_content(self): """Sample docker-compose.yml content.""" return """version: '3.8' services: app: image: nginx:latest ports: - "80:80" """ @pytest.fixture def stack_with_docker_compose_yml(self, temp_stacks_root, sample_compose_content): """Create a stack with docker-compose.yml.""" stack_dir = temp_stacks_root / "myapp" stack_dir.mkdir() compose_file = stack_dir / "docker-compose.yml" compose_file.write_text(sample_compose_content) return stack_dir @pytest.fixture def stack_with_compose_yml(self, temp_stacks_root, sample_compose_content): """Create a stack with compose.yml (no docker-compose.yml).""" stack_dir = temp_stacks_root / "otherapp" stack_dir.mkdir() compose_file = stack_dir / "compose.yml" compose_file.write_text(sample_compose_content) return stack_dir @pytest.fixture def stack_without_compose(self, temp_stacks_root): """Create a stack without any compose file.""" stack_dir = temp_stacks_root / "emptyapp" stack_dir.mkdir() return stack_dir # ========================================================================= # SUCCESS CASES # ========================================================================= @pytest.mark.asyncio async def test_success_with_docker_compose_yml( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test successful reload with docker-compose.yml.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (0, "Container started", "") result = await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), }) assert result.success is True assert result.data["compose_dir"] == str(stack_with_docker_compose_yml) assert result.data["compose_file"] == str(stack_with_docker_compose_yml / "docker-compose.yml") assert result.data["pull_ran"] is False assert "up" in result.data["logs"] # Verify only 'up' command was called mock_run.assert_called_once() call_args = mock_run.call_args assert call_args[0][2] == ["up", "-d", "--remove-orphans"] @pytest.mark.asyncio async def test_success_with_compose_yml_fallback( self, executor, mock_get_settings, stack_with_compose_yml ): """Test successful reload with compose.yml fallback.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (0, "Container started", "") result = await executor.execute({ "compose_dir": str(stack_with_compose_yml), }) assert result.success is True assert result.data["compose_file"] == str(stack_with_compose_yml / "compose.yml") assert result.data["pull_ran"] is False @pytest.mark.asyncio async def test_docker_compose_yml_preferred_over_compose_yml( self, executor, mock_get_settings, temp_stacks_root, sample_compose_content ): """Test that docker-compose.yml is preferred over compose.yml.""" stack_dir = temp_stacks_root / "bothfiles" stack_dir.mkdir() (stack_dir / "docker-compose.yml").write_text(sample_compose_content) (stack_dir / "compose.yml").write_text(sample_compose_content) with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (0, "", "") result = await executor.execute({ "compose_dir": str(stack_dir), }) assert result.success is True assert result.data["compose_file"] == str(stack_dir / "docker-compose.yml") # ========================================================================= # PULL PARAMETER TESTS # ========================================================================= @pytest.mark.asyncio async def test_pull_false_only_up_called( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test that pull=false only runs 'up' command.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (0, "", "") result = await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), "pull": False, }) assert result.success is True assert result.data["pull_ran"] is False assert "pull" not in result.data["logs"] assert "up" in result.data["logs"] # Only one call (up) assert mock_run.call_count == 1 call_args = mock_run.call_args assert call_args[0][2] == ["up", "-d", "--remove-orphans"] @pytest.mark.asyncio async def test_pull_true_both_commands_called( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test that pull=true runs both 'pull' and 'up' commands.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (0, "output", "") result = await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), "pull": True, }) assert result.success is True assert result.data["pull_ran"] is True assert "pull" in result.data["logs"] assert "up" in result.data["logs"] # Two calls: pull then up assert mock_run.call_count == 2 calls = mock_run.call_args_list assert calls[0][0][2] == ["pull"] assert calls[1][0][2] == ["up", "-d", "--remove-orphans"] @pytest.mark.asyncio async def test_pull_fails_stops_execution( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test that pull failure stops execution before 'up'.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (1, "", "Error pulling images") result = await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), "pull": True, }) assert result.success is False assert result.data["pull_ran"] is True assert "pull" in result.data["logs"] assert "up" not in result.data["logs"] assert "pull failed" in result.error.lower() # Only one call (pull) assert mock_run.call_count == 1 # ========================================================================= # FAILURE CASES # ========================================================================= @pytest.mark.asyncio async def test_missing_compose_file( self, executor, mock_get_settings, stack_without_compose ): """Test failure when no compose file is found.""" result = await executor.execute({ "compose_dir": str(stack_without_compose), }) assert result.success is False assert "No compose file found" in result.error assert "docker-compose.yml" in result.error assert "compose.yml" in result.error @pytest.mark.asyncio async def test_up_command_fails( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test failure when 'up' command fails.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (1, "", "Error: container crashed") result = await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), }) assert result.success is False assert "Docker up failed" in result.error assert "up" in result.data["logs"] @pytest.mark.asyncio async def test_missing_compose_dir_parameter(self, executor, mock_get_settings): """Test failure when compose_dir is missing from payload.""" with pytest.raises(ValueError, match="Missing required fields: compose_dir"): await executor.execute({}) # ========================================================================= # PATH SECURITY TESTS # ========================================================================= @pytest.mark.asyncio async def test_reject_path_outside_allowed_root( self, executor, mock_get_settings, tmp_path ): """Test rejection of compose_dir outside allowed stacks root.""" outside_dir = tmp_path / "outside" outside_dir.mkdir() (outside_dir / "docker-compose.yml").write_text("version: '3'\n") result = await executor.execute({ "compose_dir": str(outside_dir), }) assert result.success is False assert "validation failed" in result.error.lower() @pytest.mark.asyncio async def test_reject_path_traversal_attack( self, executor, mock_get_settings, temp_stacks_root ): """Test rejection of path traversal attempts.""" malicious_path = str(temp_stacks_root / ".." / ".." / "etc") result = await executor.execute({ "compose_dir": malicious_path, }) assert result.success is False assert "traversal" in result.error.lower() or "validation" in result.error.lower() @pytest.mark.asyncio async def test_reject_nonexistent_directory( self, executor, mock_get_settings, temp_stacks_root ): """Test rejection of nonexistent directory.""" result = await executor.execute({ "compose_dir": str(temp_stacks_root / "doesnotexist"), }) assert result.success is False assert "validation failed" in result.error.lower() or "does not exist" in result.error.lower() @pytest.mark.asyncio async def test_reject_file_instead_of_directory( self, executor, mock_get_settings, temp_stacks_root ): """Test rejection when compose_dir points to a file instead of directory.""" file_path = temp_stacks_root / "notadir.yml" file_path.write_text("version: '3'\n") result = await executor.execute({ "compose_dir": str(file_path), }) assert result.success is False assert "not a directory" in result.error.lower() or "validation" in result.error.lower() # ========================================================================= # TIMEOUT AND ERROR HANDLING TESTS # ========================================================================= @pytest.mark.asyncio async def test_timeout_handling( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test timeout handling.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.side_effect = asyncio.TimeoutError() result = await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), "timeout": 10, }) assert result.success is False assert "timed out" in result.error.lower() @pytest.mark.asyncio async def test_unexpected_exception_handling( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test handling of unexpected exceptions.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.side_effect = RuntimeError("Unexpected error") result = await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), }) assert result.success is False assert "Unexpected error" in result.error # ========================================================================= # OUTPUT STRUCTURE TESTS # ========================================================================= @pytest.mark.asyncio async def test_result_structure_on_success( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test that result has correct structure on success.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (0, "stdout content", "stderr content") result = await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), "pull": True, }) assert result.success is True assert "compose_dir" in result.data assert "compose_file" in result.data assert "pull_ran" in result.data assert "logs" in result.data assert isinstance(result.data["logs"], dict) assert "pull" in result.data["logs"] assert "up" in result.data["logs"] assert result.duration_ms is not None assert result.error is None @pytest.mark.asyncio async def test_logs_combine_stdout_stderr( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test that logs contain both stdout and stderr.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (0, "stdout line", "stderr line") result = await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), }) assert "stdout line" in result.data["logs"]["up"] assert "stderr line" in result.data["logs"]["up"] # ========================================================================= # INTERNAL METHOD TESTS # ========================================================================= def test_find_compose_file_docker_compose_yml( self, executor, stack_with_docker_compose_yml ): """Test _find_compose_file finds docker-compose.yml.""" result = executor._find_compose_file(stack_with_docker_compose_yml) assert result == stack_with_docker_compose_yml / "docker-compose.yml" def test_find_compose_file_compose_yml( self, executor, stack_with_compose_yml ): """Test _find_compose_file finds compose.yml.""" result = executor._find_compose_file(stack_with_compose_yml) assert result == stack_with_compose_yml / "compose.yml" def test_find_compose_file_not_found( self, executor, stack_without_compose ): """Test _find_compose_file returns None when not found.""" result = executor._find_compose_file(stack_without_compose) assert result is None def test_combine_output_both_present(self, executor): """Test _combine_output with both stdout and stderr.""" result = executor._combine_output("stdout", "stderr") assert result == "stdout\nstderr" def test_combine_output_stdout_only(self, executor): """Test _combine_output with only stdout.""" result = executor._combine_output("stdout", "") assert result == "stdout" def test_combine_output_stderr_only(self, executor): """Test _combine_output with only stderr.""" result = executor._combine_output("", "stderr") assert result == "stderr" def test_combine_output_both_empty(self, executor): """Test _combine_output with empty strings.""" result = executor._combine_output("", "") assert result == "" # ========================================================================= # TASK TYPE TEST # ========================================================================= def test_task_type(self, executor): """Test task_type property.""" assert executor.task_type == "DOCKER_RELOAD" # ========================================================================= # CUSTOM TIMEOUT TEST # ========================================================================= @pytest.mark.asyncio async def test_custom_timeout_passed_to_command( self, executor, mock_get_settings, stack_with_docker_compose_yml ): """Test that custom timeout is passed to subprocess.""" with patch.object(executor, "_run_compose_command") as mock_run: mock_run.return_value = (0, "", "") await executor.execute({ "compose_dir": str(stack_with_docker_compose_yml), "timeout": 120, }) call_args = mock_run.call_args assert call_args[0][3] == 120 # timeout argument