letsbe-sysadmin/tests/executors/test_docker_executor.py

468 lines
18 KiB
Python

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