Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1 @@
"""Test suite for LetsBe SysAdmin Agent."""

View File

@@ -0,0 +1,55 @@
"""Pytest configuration and shared fixtures."""
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def temp_env_root(tmp_path):
"""Create a temporary directory to act as /opt/letsbe/env."""
env_dir = tmp_path / "opt" / "letsbe" / "env"
env_dir.mkdir(parents=True)
return env_dir
@pytest.fixture
def mock_settings(temp_env_root):
"""Mock settings with temporary paths."""
settings = MagicMock()
settings.allowed_env_root = str(temp_env_root)
settings.allowed_file_root = str(temp_env_root.parent / "data")
settings.allowed_stacks_root = str(temp_env_root.parent / "stacks")
settings.max_file_size = 10 * 1024 * 1024
return settings
@pytest.fixture
def mock_get_settings(mock_settings):
"""Patch get_settings to return mock settings."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
yield mock_settings
@pytest.fixture
def sample_env_content():
"""Sample ENV file content for testing."""
return """# Database configuration
DATABASE_URL=postgres://localhost/mydb
API_KEY=secret123
# Feature flags
DEBUG=true
LOG_LEVEL=info
"""
@pytest.fixture
def existing_env_file(temp_env_root, sample_env_content):
"""Create an existing ENV file for testing updates."""
env_file = temp_env_root / "app.env"
env_file.write_text(sample_env_content)
return env_file

View File

@@ -0,0 +1 @@
"""Tests for executor modules."""

View File

@@ -0,0 +1,495 @@
"""Unit tests for CompositeExecutor."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from app.executors.base import ExecutionResult
# Patch the logger before importing the executor
with patch("app.utils.logger.get_logger", return_value=MagicMock()):
from app.executors.composite_executor import CompositeExecutor
class TestCompositeExecutor:
"""Tests for CompositeExecutor."""
@pytest.fixture
def executor(self):
"""Create executor with mocked logger."""
with patch("app.executors.base.get_logger", return_value=MagicMock()):
return CompositeExecutor()
def _create_mock_executor(self, success: bool, data: dict, error: str | None = None):
"""Create a mock executor that returns specified result."""
mock_executor = MagicMock()
mock_executor.execute = AsyncMock(return_value=ExecutionResult(
success=success,
data=data,
error=error,
))
return mock_executor
# =========================================================================
# HAPPY PATH TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_two_steps_both_succeed(self, executor):
"""Test successful execution of two steps."""
mock_env_executor = self._create_mock_executor(
success=True,
data={"updated_keys": ["API_KEY"], "removed_keys": [], "path": "/opt/letsbe/env/app.env"},
)
mock_docker_executor = self._create_mock_executor(
success=True,
data={"compose_dir": "/opt/letsbe/stacks/myapp", "pull_ran": True, "logs": {}},
)
def mock_get_executor(task_type: str):
if task_type == "ENV_UPDATE":
return mock_env_executor
elif task_type == "DOCKER_RELOAD":
return mock_docker_executor
raise ValueError(f"Unknown task type: {task_type}")
with patch("app.executors.get_executor", side_effect=mock_get_executor):
result = await executor.execute({
"steps": [
{"type": "ENV_UPDATE", "payload": {"path": "/opt/letsbe/env/app.env", "updates": {"API_KEY": "secret"}}},
{"type": "DOCKER_RELOAD", "payload": {"compose_dir": "/opt/letsbe/stacks/myapp", "pull": True}},
]
})
assert result.success is True
assert result.error is None
assert len(result.data["steps"]) == 2
# Verify first step
assert result.data["steps"][0]["index"] == 0
assert result.data["steps"][0]["type"] == "ENV_UPDATE"
assert result.data["steps"][0]["status"] == "completed"
assert result.data["steps"][0]["result"]["updated_keys"] == ["API_KEY"]
# Verify second step
assert result.data["steps"][1]["index"] == 1
assert result.data["steps"][1]["type"] == "DOCKER_RELOAD"
assert result.data["steps"][1]["status"] == "completed"
assert result.data["steps"][1]["result"]["compose_dir"] == "/opt/letsbe/stacks/myapp"
@pytest.mark.asyncio
async def test_single_step_succeeds(self, executor):
"""Test successful execution of single step."""
mock_executor = self._create_mock_executor(
success=True,
data={"written": True, "path": "/opt/letsbe/env/test.env", "size": 100},
)
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "FILE_WRITE", "payload": {"path": "/opt/letsbe/env/test.env", "content": "KEY=value"}},
]
})
assert result.success is True
assert len(result.data["steps"]) == 1
assert result.data["steps"][0]["status"] == "completed"
@pytest.mark.asyncio
async def test_three_steps_all_succeed(self, executor):
"""Test successful execution of three steps."""
mock_executor = self._create_mock_executor(success=True, data={"success": True})
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "FILE_WRITE", "payload": {}},
{"type": "ENV_UPDATE", "payload": {}},
{"type": "DOCKER_RELOAD", "payload": {}},
]
})
assert result.success is True
assert len(result.data["steps"]) == 3
assert all(s["status"] == "completed" for s in result.data["steps"])
# =========================================================================
# FAILURE HANDLING TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_first_step_fails_stops_execution(self, executor):
"""Test that first step failure stops execution."""
mock_executor = self._create_mock_executor(
success=False,
data={"partial": "data"},
error="Validation failed: invalid key format",
)
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "ENV_UPDATE", "payload": {}},
{"type": "DOCKER_RELOAD", "payload": {}}, # Should NOT be called
]
})
assert result.success is False
assert "Step 0 (ENV_UPDATE) failed" in result.error
assert "invalid key format" in result.error
assert len(result.data["steps"]) == 1 # Only first step
assert result.data["steps"][0]["status"] == "failed"
assert result.data["steps"][0]["error"] == "Validation failed: invalid key format"
@pytest.mark.asyncio
async def test_second_step_fails_preserves_first_result(self, executor):
"""Test that second step failure preserves first step result."""
mock_env_executor = self._create_mock_executor(
success=True,
data={"updated_keys": ["KEY1"]},
)
mock_docker_executor = self._create_mock_executor(
success=False,
data={},
error="No compose file found",
)
call_count = [0]
def mock_get_executor(task_type: str):
call_count[0] += 1
if task_type == "ENV_UPDATE":
return mock_env_executor
elif task_type == "DOCKER_RELOAD":
return mock_docker_executor
raise ValueError(f"Unknown task type: {task_type}")
with patch("app.executors.get_executor", side_effect=mock_get_executor):
result = await executor.execute({
"steps": [
{"type": "ENV_UPDATE", "payload": {}},
{"type": "DOCKER_RELOAD", "payload": {}},
]
})
assert result.success is False
assert "Step 1 (DOCKER_RELOAD) failed" in result.error
assert len(result.data["steps"]) == 2
# First step completed
assert result.data["steps"][0]["index"] == 0
assert result.data["steps"][0]["status"] == "completed"
assert result.data["steps"][0]["result"]["updated_keys"] == ["KEY1"]
# Second step failed
assert result.data["steps"][1]["index"] == 1
assert result.data["steps"][1]["status"] == "failed"
assert result.data["steps"][1]["error"] == "No compose file found"
@pytest.mark.asyncio
async def test_executor_raises_exception(self, executor):
"""Test handling of executor that raises exception."""
mock_executor = MagicMock()
mock_executor.execute = AsyncMock(side_effect=RuntimeError("Unexpected database error"))
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "ENV_UPDATE", "payload": {}},
]
})
assert result.success is False
assert "Step 0 (ENV_UPDATE) failed" in result.error
assert "Unexpected database error" in result.error
assert len(result.data["steps"]) == 1
assert result.data["steps"][0]["status"] == "failed"
assert "Unexpected database error" in result.data["steps"][0]["error"]
# =========================================================================
# VALIDATION TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_empty_steps_validation_error(self, executor):
"""Test that empty steps list fails validation."""
result = await executor.execute({"steps": []})
assert result.success is False
assert "cannot be empty" in result.error
assert result.data["steps"] == []
@pytest.mark.asyncio
async def test_missing_steps_field(self, executor):
"""Test that missing steps field raises ValueError."""
with pytest.raises(ValueError, match="Missing required fields: steps"):
await executor.execute({})
@pytest.mark.asyncio
async def test_steps_not_a_list(self, executor):
"""Test that non-list steps fails validation."""
result = await executor.execute({"steps": "not a list"})
assert result.success is False
assert "must be a list" in result.error
@pytest.mark.asyncio
async def test_step_missing_type_field(self, executor):
"""Test that step without type field fails."""
result = await executor.execute({
"steps": [
{"payload": {"key": "value"}}
]
})
assert result.success is False
assert "Step 0 missing 'type' field" in result.error
@pytest.mark.asyncio
async def test_step_not_a_dict(self, executor):
"""Test that non-dict step fails validation."""
result = await executor.execute({
"steps": ["not a dict"]
})
assert result.success is False
assert "Step 0 is not a valid step definition" in result.error
@pytest.mark.asyncio
async def test_unknown_step_type_fails(self, executor):
"""Test that unknown step type fails with clear error."""
with patch("app.executors.get_executor") as mock_get:
mock_get.side_effect = ValueError("Unknown task type: INVALID_TYPE. Available: ['ECHO', 'SHELL']")
result = await executor.execute({
"steps": [
{"type": "INVALID_TYPE", "payload": {}}
]
})
assert result.success is False
assert "Unknown task type" in result.error
assert "INVALID_TYPE" in result.error
# =========================================================================
# RESULT STRUCTURE TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_result_has_correct_structure(self, executor):
"""Test that result has all required fields."""
mock_executor = self._create_mock_executor(
success=True,
data={"key": "value"},
)
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "ECHO", "payload": {"message": "test"}},
]
})
assert result.success is True
assert "steps" in result.data
assert isinstance(result.data["steps"], list)
step = result.data["steps"][0]
assert "index" in step
assert "type" in step
assert "status" in step
assert "result" in step
assert step["index"] == 0
assert step["type"] == "ECHO"
assert step["status"] == "completed"
@pytest.mark.asyncio
async def test_error_field_present_on_failure(self, executor):
"""Test that error field is present in step result on failure."""
mock_executor = self._create_mock_executor(
success=False,
data={},
error="Something went wrong",
)
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "SHELL", "payload": {}},
]
})
assert result.success is False
assert "error" in result.data["steps"][0]
assert result.data["steps"][0]["error"] == "Something went wrong"
@pytest.mark.asyncio
async def test_error_field_absent_on_success(self, executor):
"""Test that error field is not present in step result on success."""
mock_executor = self._create_mock_executor(
success=True,
data={"result": "ok"},
error=None,
)
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "ECHO", "payload": {}},
]
})
assert result.success is True
assert "error" not in result.data["steps"][0]
@pytest.mark.asyncio
async def test_propagates_underlying_executor_results(self, executor):
"""Test that underlying executor data is propagated correctly."""
specific_data = {
"updated_keys": ["DB_HOST", "DB_PORT"],
"removed_keys": ["OLD_KEY"],
"path": "/opt/letsbe/env/database.env",
"custom_field": "custom_value",
}
mock_executor = self._create_mock_executor(
success=True,
data=specific_data,
)
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "ENV_UPDATE", "payload": {}},
]
})
assert result.success is True
assert result.data["steps"][0]["result"] == specific_data
@pytest.mark.asyncio
async def test_duration_ms_populated(self, executor):
"""Test that duration_ms is populated."""
mock_executor = self._create_mock_executor(success=True, data={})
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "ECHO", "payload": {}},
]
})
assert result.duration_ms is not None
assert result.duration_ms >= 0
# =========================================================================
# PAYLOAD HANDLING TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_step_payload_defaults_to_empty_dict(self, executor):
"""Test that missing payload in step defaults to empty dict."""
mock_executor = MagicMock()
mock_executor.execute = AsyncMock(return_value=ExecutionResult(success=True, data={}))
with patch("app.executors.get_executor", return_value=mock_executor):
result = await executor.execute({
"steps": [
{"type": "ECHO"} # No payload field
]
})
assert result.success is True
# Verify execute was called with empty dict
mock_executor.execute.assert_called_once_with({})
@pytest.mark.asyncio
async def test_step_payload_passed_correctly(self, executor):
"""Test that step payload is passed to executor correctly."""
mock_executor = MagicMock()
mock_executor.execute = AsyncMock(return_value=ExecutionResult(success=True, data={}))
expected_payload = {"path": "/opt/letsbe/env/app.env", "updates": {"KEY": "value"}}
with patch("app.executors.get_executor", return_value=mock_executor):
await executor.execute({
"steps": [
{"type": "ENV_UPDATE", "payload": expected_payload}
]
})
mock_executor.execute.assert_called_once_with(expected_payload)
# =========================================================================
# TASK TYPE TEST
# =========================================================================
def test_task_type(self, executor):
"""Test task_type property."""
assert executor.task_type == "COMPOSITE"
# =========================================================================
# EXECUTION ORDER TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_steps_executed_in_order(self, executor):
"""Test that steps are executed in sequential order."""
execution_order = []
def create_tracking_executor(name: str):
mock = MagicMock()
async def track_execute(payload):
execution_order.append(name)
return ExecutionResult(success=True, data={"name": name})
mock.execute = track_execute
return mock
def mock_get_executor(task_type: str):
return create_tracking_executor(task_type)
with patch("app.executors.get_executor", side_effect=mock_get_executor):
result = await executor.execute({
"steps": [
{"type": "STEP_A", "payload": {}},
{"type": "STEP_B", "payload": {}},
{"type": "STEP_C", "payload": {}},
]
})
assert result.success is True
assert execution_order == ["STEP_A", "STEP_B", "STEP_C"]
@pytest.mark.asyncio
async def test_failure_stops_subsequent_steps(self, executor):
"""Test that failure at step N prevents steps N+1 and beyond from running."""
execution_order = []
def create_tracking_executor(name: str, should_fail: bool = False):
mock = MagicMock()
async def track_execute(payload):
execution_order.append(name)
return ExecutionResult(
success=not should_fail,
data={},
error="Failed" if should_fail else None,
)
mock.execute = track_execute
return mock
def mock_get_executor(task_type: str):
if task_type == "STEP_B":
return create_tracking_executor(task_type, should_fail=True)
return create_tracking_executor(task_type)
with patch("app.executors.get_executor", side_effect=mock_get_executor):
result = await executor.execute({
"steps": [
{"type": "STEP_A", "payload": {}},
{"type": "STEP_B", "payload": {}}, # This fails
{"type": "STEP_C", "payload": {}}, # Should NOT run
]
})
assert result.success is False
assert execution_order == ["STEP_A", "STEP_B"] # STEP_C not executed

View File

@@ -0,0 +1,467 @@
"""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

View File

@@ -0,0 +1,403 @@
"""Unit tests for EnvInspectExecutor."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Patch the logger before importing the executor
with patch("app.utils.logger.get_logger", return_value=MagicMock()):
from app.executors.env_inspect_executor import EnvInspectExecutor
class TestEnvInspectExecutor:
"""Test suite for EnvInspectExecutor."""
@pytest.fixture
def executor(self):
"""Create executor instance with mocked logger."""
with patch("app.executors.base.get_logger", return_value=MagicMock()):
return EnvInspectExecutor()
@pytest.fixture
def temp_env_root(self, tmp_path):
"""Create a temporary directory to act as /opt/letsbe/env."""
env_dir = tmp_path / "opt" / "letsbe" / "env"
env_dir.mkdir(parents=True)
return env_dir
@pytest.fixture
def mock_settings(self, temp_env_root):
"""Mock settings with temporary env root."""
settings = MagicMock()
settings.allowed_env_root = str(temp_env_root)
return settings
# ==================== Basic Inspection Tests ====================
@pytest.mark.asyncio
async def test_inspect_all_keys(self, executor, temp_env_root, mock_settings):
"""Test reading all keys when no filter is provided."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY1=value1\nKEY2=value2\nKEY3=value3\n")
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["path"] == str(env_path)
assert result.data["keys"] == {
"KEY1": "value1",
"KEY2": "value2",
"KEY3": "value3",
}
@pytest.mark.asyncio
async def test_inspect_selected_keys(self, executor, temp_env_root, mock_settings):
"""Test reading only selected keys."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY1=value1\nKEY2=value2\nKEY3=value3\n")
result = await executor.execute({
"path": str(env_path),
"keys": ["KEY1", "KEY3"],
})
assert result.success is True
assert result.data["keys"] == {
"KEY1": "value1",
"KEY3": "value3",
}
@pytest.mark.asyncio
async def test_inspect_selected_keys_ignores_unknown(self, executor, temp_env_root, mock_settings):
"""Test that unknown keys in filter are silently ignored."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY1=value1\nKEY2=value2\n")
result = await executor.execute({
"path": str(env_path),
"keys": ["KEY1", "NONEXISTENT", "ALSO_MISSING"],
})
assert result.success is True
assert result.data["keys"] == {"KEY1": "value1"}
@pytest.mark.asyncio
async def test_inspect_empty_keys_filter(self, executor, temp_env_root, mock_settings):
"""Test with empty keys filter returns nothing."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY1=value1\n")
result = await executor.execute({
"path": str(env_path),
"keys": [],
})
assert result.success is True
assert result.data["keys"] == {}
@pytest.mark.asyncio
async def test_inspect_with_keys_null(self, executor, temp_env_root, mock_settings):
"""Test that keys=null returns all keys (same as omitting keys)."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY1=value1\nKEY2=value2\n")
result = await executor.execute({
"path": str(env_path),
"keys": None,
})
assert result.success is True
assert result.data["keys"] == {"KEY1": "value1", "KEY2": "value2"}
# ==================== File Not Found Tests ====================
@pytest.mark.asyncio
async def test_missing_file(self, executor, temp_env_root, mock_settings):
"""Test error when file does not exist."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "nonexistent.env"
result = await executor.execute({
"path": str(env_path),
})
assert result.success is False
assert "does not exist" in result.error or "Path validation failed" in result.error
# ==================== Path Validation Tests ====================
@pytest.mark.asyncio
async def test_path_traversal_rejected(self, executor, temp_env_root, mock_settings):
"""Test rejection of directory traversal attempts."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": str(temp_env_root / ".." / ".." / "etc" / "passwd"),
})
assert result.success is False
assert "Path validation failed" in result.error or "traversal" in result.error.lower()
@pytest.mark.asyncio
async def test_path_outside_allowed_root(self, executor, temp_env_root, mock_settings):
"""Test rejection of paths outside allowed root."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": "/etc/passwd",
})
assert result.success is False
assert "Path validation failed" in result.error
@pytest.mark.asyncio
async def test_path_in_allowed_root_nested(self, executor, temp_env_root, mock_settings):
"""Test acceptance of valid nested path within allowed root."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
nested_dir = temp_env_root / "subdir"
nested_dir.mkdir()
env_path = nested_dir / "app.env"
env_path.write_text("KEY=value\n")
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["keys"] == {"KEY": "value"}
# ==================== Payload Validation Tests ====================
@pytest.mark.asyncio
async def test_missing_path_payload(self, executor):
"""Test rejection of payload without path."""
with pytest.raises(ValueError, match="Missing required field: path"):
await executor.execute({})
@pytest.mark.asyncio
async def test_missing_path_payload_with_keys(self, executor):
"""Test rejection of payload with keys but no path."""
with pytest.raises(ValueError, match="Missing required field: path"):
await executor.execute({
"keys": ["KEY1"],
})
@pytest.mark.asyncio
async def test_reject_invalid_keys_type(self, executor, temp_env_root, mock_settings):
"""Test rejection when keys is not a list or null."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY=value\n")
result = await executor.execute({
"path": str(env_path),
"keys": {"not": "a_list"},
})
assert result.success is False
assert "'keys' must be a list" in result.error
@pytest.mark.asyncio
async def test_reject_keys_as_string(self, executor, temp_env_root, mock_settings):
"""Test rejection when keys is a string instead of list."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY=value\n")
result = await executor.execute({
"path": str(env_path),
"keys": "KEY",
})
assert result.success is False
assert "'keys' must be a list" in result.error
# ==================== Task Type Tests ====================
def test_task_type_property(self, executor):
"""Test that task_type returns ENV_INSPECT."""
assert executor.task_type == "ENV_INSPECT"
# ==================== Registry Integration Tests ====================
def test_registry_integration(self):
"""Test that ENV_INSPECT is registered in executor registry."""
from app.executors import get_executor
executor = get_executor("ENV_INSPECT")
assert executor is not None
assert executor.task_type == "ENV_INSPECT"
# ==================== Empty File Tests ====================
@pytest.mark.asyncio
async def test_empty_file(self, executor, temp_env_root, mock_settings):
"""Test reading an empty ENV file returns empty keys dict."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "empty.env"
env_path.write_text("")
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["keys"] == {}
@pytest.mark.asyncio
async def test_file_with_only_comments(self, executor, temp_env_root, mock_settings):
"""Test reading file with only comments returns empty keys dict."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "comments.env"
env_path.write_text("# This is a comment\n# Another comment\n")
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["keys"] == {}
# ==================== Parsing Tests ====================
@pytest.mark.asyncio
async def test_parses_quoted_values_double(self, executor, temp_env_root, mock_settings):
"""Test parsing of double-quoted values."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text('KEY="value with spaces"\n')
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["keys"]["KEY"] == "value with spaces"
@pytest.mark.asyncio
async def test_parses_quoted_values_single(self, executor, temp_env_root, mock_settings):
"""Test parsing of single-quoted values."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY='single quoted'\n")
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["keys"]["KEY"] == "single quoted"
@pytest.mark.asyncio
async def test_parses_value_with_equals(self, executor, temp_env_root, mock_settings):
"""Test parsing values containing equals signs."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("URL=postgres://user:pass@host/db?opt=val\n")
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["keys"]["URL"] == "postgres://user:pass@host/db?opt=val"
@pytest.mark.asyncio
async def test_ignores_comment_lines(self, executor, temp_env_root, mock_settings):
"""Test that comment lines are ignored."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("# Comment\nKEY=value\n# Another comment\nKEY2=value2\n")
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["keys"] == {"KEY": "value", "KEY2": "value2"}
@pytest.mark.asyncio
async def test_ignores_empty_lines(self, executor, temp_env_root, mock_settings):
"""Test that empty lines are ignored."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY1=value1\n\n\nKEY2=value2\n")
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["keys"] == {"KEY1": "value1", "KEY2": "value2"}
@pytest.mark.asyncio
async def test_handles_whitespace_around_key_value(self, executor, temp_env_root, mock_settings):
"""Test handling of whitespace around keys and values."""
with patch("app.executors.env_inspect_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text(" KEY1 = value1 \n")
result = await executor.execute({
"path": str(env_path),
})
assert result.success is True
assert result.data["keys"]["KEY1"] == "value1"
class TestEnvInspectExecutorInternal:
"""Tests for internal methods of EnvInspectExecutor."""
@pytest.fixture
def executor(self):
"""Create executor instance with mocked logger."""
with patch("app.executors.base.get_logger", return_value=MagicMock()):
return EnvInspectExecutor()
def test_parse_env_file_basic(self, executor):
"""Test basic ENV file parsing."""
content = "KEY1=value1\nKEY2=value2\n"
result = executor._parse_env_file(content)
assert result == {"KEY1": "value1", "KEY2": "value2"}
def test_parse_env_file_with_comments(self, executor):
"""Test parsing ignores comments."""
content = "# Comment\nKEY=value\n# Another comment\n"
result = executor._parse_env_file(content)
assert result == {"KEY": "value"}
def test_parse_env_file_with_empty_lines(self, executor):
"""Test parsing ignores empty lines."""
content = "KEY1=value1\n\n\nKEY2=value2\n"
result = executor._parse_env_file(content)
assert result == {"KEY1": "value1", "KEY2": "value2"}
def test_parse_env_file_with_quotes(self, executor):
"""Test parsing handles quoted values."""
content = 'KEY1="quoted value"\nKEY2=\'single quoted\'\n'
result = executor._parse_env_file(content)
assert result == {"KEY1": "quoted value", "KEY2": "single quoted"}
def test_parse_env_file_with_equals_in_value(self, executor):
"""Test parsing handles equals signs in values."""
content = "URL=postgres://user:pass@host/db?opt=val\n"
result = executor._parse_env_file(content)
assert result == {"URL": "postgres://user:pass@host/db?opt=val"}
def test_parse_env_file_empty(self, executor):
"""Test parsing empty content."""
content = ""
result = executor._parse_env_file(content)
assert result == {}
def test_parse_env_file_only_comments(self, executor):
"""Test parsing content with only comments."""
content = "# Comment 1\n# Comment 2\n"
result = executor._parse_env_file(content)
assert result == {}

View File

@@ -0,0 +1,582 @@
"""Unit tests for EnvUpdateExecutor."""
import os
import stat
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Patch the logger before importing the executor
with patch("app.utils.logger.get_logger", return_value=MagicMock()):
from app.executors.env_update_executor import EnvUpdateExecutor
class TestEnvUpdateExecutor:
"""Test suite for EnvUpdateExecutor."""
@pytest.fixture
def executor(self):
"""Create executor instance with mocked logger."""
with patch("app.executors.base.get_logger", return_value=MagicMock()):
return EnvUpdateExecutor()
@pytest.fixture
def temp_env_root(self, tmp_path):
"""Create a temporary directory to act as /opt/letsbe/env."""
env_dir = tmp_path / "opt" / "letsbe" / "env"
env_dir.mkdir(parents=True)
return env_dir
@pytest.fixture
def mock_settings(self, temp_env_root):
"""Mock settings with temporary env root."""
settings = MagicMock()
settings.allowed_env_root = str(temp_env_root)
return settings
# ==================== New File Creation Tests ====================
@pytest.mark.asyncio
async def test_create_new_env_file(self, executor, temp_env_root, mock_settings):
"""Test creating a new ENV file when it doesn't exist."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "newapp.env"
result = await executor.execute({
"path": str(env_path),
"updates": {
"DATABASE_URL": "postgres://localhost/mydb",
"API_KEY": "secret123",
},
})
assert result.success is True
assert set(result.data["updated_keys"]) == {"DATABASE_URL", "API_KEY"}
assert result.data["removed_keys"] == []
assert result.data["path"] == str(env_path)
# Verify file was created
assert env_path.exists()
content = env_path.read_text()
assert "API_KEY=secret123" in content
assert "DATABASE_URL=postgres://localhost/mydb" in content
@pytest.mark.asyncio
async def test_create_env_file_in_nested_directory(self, executor, temp_env_root, mock_settings):
"""Test creating ENV file in a nested directory that doesn't exist."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "subdir" / "app.env"
result = await executor.execute({
"path": str(env_path),
"updates": {"KEY": "value"},
})
assert result.success is True
assert env_path.exists()
# ==================== Update Existing Keys Tests ====================
@pytest.mark.asyncio
async def test_update_existing_keys(self, executor, temp_env_root, mock_settings):
"""Test updating existing keys in an ENV file."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("EXISTING_KEY=old_value\nANOTHER_KEY=keep_this\n")
result = await executor.execute({
"path": str(env_path),
"updates": {"EXISTING_KEY": "new_value"},
})
assert result.success is True
assert "EXISTING_KEY" in result.data["updated_keys"]
content = env_path.read_text()
assert "EXISTING_KEY=new_value" in content
assert "ANOTHER_KEY=keep_this" in content
@pytest.mark.asyncio
async def test_add_new_keys_to_existing_file(self, executor, temp_env_root, mock_settings):
"""Test adding new keys to an existing ENV file."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("EXISTING_KEY=value\n")
result = await executor.execute({
"path": str(env_path),
"updates": {"NEW_KEY": "new_value"},
})
assert result.success is True
content = env_path.read_text()
assert "EXISTING_KEY=value" in content
assert "NEW_KEY=new_value" in content
@pytest.mark.asyncio
async def test_preserves_key_values(self, executor, temp_env_root, mock_settings):
"""Test that existing key values are preserved when not updated."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY1=value1\nKEY2=value2\n")
result = await executor.execute({
"path": str(env_path),
"updates": {"KEY1": "updated"},
})
assert result.success is True
content = env_path.read_text()
assert "KEY1=updated" in content
assert "KEY2=value2" in content
# ==================== Remove Keys Tests ====================
@pytest.mark.asyncio
async def test_remove_existing_keys(self, executor, temp_env_root, mock_settings):
"""Test removing existing keys from an ENV file."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEEP_KEY=value\nREMOVE_KEY=to_remove\nANOTHER_KEEP=keep\n")
result = await executor.execute({
"path": str(env_path),
"remove_keys": ["REMOVE_KEY"],
})
assert result.success is True
assert result.data["removed_keys"] == ["REMOVE_KEY"]
assert result.data["updated_keys"] == []
content = env_path.read_text()
assert "REMOVE_KEY" not in content
assert "KEEP_KEY=value" in content
assert "ANOTHER_KEEP=keep" in content
@pytest.mark.asyncio
async def test_remove_nonexistent_key(self, executor, temp_env_root, mock_settings):
"""Test removing a key that doesn't exist (should succeed but not report as removed)."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("EXISTING_KEY=value\n")
result = await executor.execute({
"path": str(env_path),
"remove_keys": ["NONEXISTENT_KEY"],
})
assert result.success is True
assert result.data["removed_keys"] == [] # Not reported as removed since it didn't exist
@pytest.mark.asyncio
async def test_update_and_remove_together(self, executor, temp_env_root, mock_settings):
"""Test updating and removing keys in the same operation."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("KEY1=old\nKEY2=remove_me\nKEY3=keep\n")
result = await executor.execute({
"path": str(env_path),
"updates": {"KEY1": "new", "NEW_KEY": "added"},
"remove_keys": ["KEY2"],
})
assert result.success is True
assert "KEY1" in result.data["updated_keys"]
assert "NEW_KEY" in result.data["updated_keys"]
assert result.data["removed_keys"] == ["KEY2"]
content = env_path.read_text()
assert "KEY1=new" in content
assert "NEW_KEY=added" in content
assert "KEY3=keep" in content
assert "KEY2" not in content
@pytest.mark.asyncio
async def test_remove_multiple_keys(self, executor, temp_env_root, mock_settings):
"""Test removing multiple keys at once."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("A=1\nB=2\nC=3\nD=4\n")
result = await executor.execute({
"path": str(env_path),
"remove_keys": ["A", "C"],
})
assert result.success is True
assert set(result.data["removed_keys"]) == {"A", "C"}
content = env_path.read_text()
assert "A=" not in content
assert "C=" not in content
assert "B=2" in content
assert "D=4" in content
# ==================== Invalid Key Name Tests ====================
@pytest.mark.asyncio
async def test_reject_invalid_update_key_lowercase(self, executor, temp_env_root, mock_settings):
"""Test rejection of lowercase keys in updates."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
result = await executor.execute({
"path": str(env_path),
"updates": {"invalid_key": "value"},
})
assert result.success is False
assert "Invalid ENV key format" in result.error
@pytest.mark.asyncio
async def test_reject_invalid_update_key_starts_with_number(self, executor, temp_env_root, mock_settings):
"""Test rejection of keys starting with a number."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
result = await executor.execute({
"path": str(env_path),
"updates": {"1INVALID": "value"},
})
assert result.success is False
assert "Invalid ENV key format" in result.error
@pytest.mark.asyncio
async def test_reject_invalid_update_key_special_chars(self, executor, temp_env_root, mock_settings):
"""Test rejection of keys with special characters."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
result = await executor.execute({
"path": str(env_path),
"updates": {"INVALID-KEY": "value"},
})
assert result.success is False
assert "Invalid ENV key format" in result.error
@pytest.mark.asyncio
async def test_reject_invalid_remove_key(self, executor, temp_env_root, mock_settings):
"""Test rejection of invalid keys in remove_keys."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("VALID_KEY=value\n")
result = await executor.execute({
"path": str(env_path),
"remove_keys": ["invalid_lowercase"],
})
assert result.success is False
assert "Invalid ENV key format" in result.error
@pytest.mark.asyncio
async def test_accept_valid_key_formats(self, executor, temp_env_root, mock_settings):
"""Test acceptance of various valid key formats."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
valid_keys = {
"A": "1",
"AB": "2",
"A1": "3",
"A_B": "4",
"ABC123_XYZ": "5",
"DATABASE_URL": "postgres://localhost/db",
}
result = await executor.execute({
"path": str(env_path),
"updates": valid_keys,
})
assert result.success is True
assert set(result.data["updated_keys"]) == set(valid_keys.keys())
# ==================== Path Validation Tests ====================
@pytest.mark.asyncio
async def test_reject_path_outside_allowed_root(self, executor, temp_env_root, mock_settings):
"""Test rejection of paths outside /opt/letsbe/env."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
# Try to write to parent directory
result = await executor.execute({
"path": "/etc/passwd",
"updates": {"HACK": "attempt"},
})
assert result.success is False
assert "Path validation failed" in result.error
@pytest.mark.asyncio
async def test_reject_path_traversal_attack(self, executor, temp_env_root, mock_settings):
"""Test rejection of directory traversal attempts."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": str(temp_env_root / ".." / ".." / "etc" / "passwd"),
"updates": {"HACK": "attempt"},
})
assert result.success is False
assert "Path validation failed" in result.error or "traversal" in result.error.lower()
@pytest.mark.asyncio
async def test_accept_valid_path_in_allowed_root(self, executor, temp_env_root, mock_settings):
"""Test acceptance of valid paths within allowed root."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "valid" / "path" / "app.env"
result = await executor.execute({
"path": str(env_path),
"updates": {"VALID": "path"},
})
assert result.success is True
# ==================== Payload Validation Tests ====================
@pytest.mark.asyncio
async def test_reject_missing_path(self, executor):
"""Test rejection of payload without path."""
with pytest.raises(ValueError, match="Missing required field: path"):
await executor.execute({
"updates": {"KEY": "value"},
})
@pytest.mark.asyncio
async def test_reject_empty_operations(self, executor, temp_env_root, mock_settings):
"""Test rejection when neither updates nor remove_keys provided."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": str(temp_env_root / "app.env"),
})
assert result.success is False
assert "At least one of 'updates' or 'remove_keys'" in result.error
@pytest.mark.asyncio
async def test_reject_invalid_updates_type(self, executor, temp_env_root, mock_settings):
"""Test rejection when updates is not a dict."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": str(temp_env_root / "app.env"),
"updates": ["not", "a", "dict"],
})
assert result.success is False
assert "'updates' must be a dictionary" in result.error
@pytest.mark.asyncio
async def test_reject_invalid_remove_keys_type(self, executor, temp_env_root, mock_settings):
"""Test rejection when remove_keys is not a list."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": str(temp_env_root / "app.env"),
"remove_keys": {"not": "a_list"},
})
assert result.success is False
assert "'remove_keys' must be a list" in result.error
# ==================== File Permission Tests ====================
@pytest.mark.asyncio
@pytest.mark.skipif(os.name == "nt", reason="chmod not fully supported on Windows")
async def test_file_permissions_640(self, executor, temp_env_root, mock_settings):
"""Test that created files have 640 permissions."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "secure.env"
result = await executor.execute({
"path": str(env_path),
"updates": {"SECRET": "value"},
})
assert result.success is True
# Check file permissions
file_stat = env_path.stat()
# 0o640 = owner rw, group r, others none
expected_mode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP
actual_mode = stat.S_IMODE(file_stat.st_mode)
assert actual_mode == expected_mode, f"Expected {oct(expected_mode)}, got {oct(actual_mode)}"
# ==================== ENV File Parsing Tests ====================
@pytest.mark.asyncio
async def test_parse_quoted_values(self, executor, temp_env_root, mock_settings):
"""Test parsing of quoted values in ENV files."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text('QUOTED="value with spaces"\nSINGLE=\'single quoted\'\n')
result = await executor.execute({
"path": str(env_path),
"updates": {"NEW": "added"},
})
assert result.success is True
content = env_path.read_text()
# Values should be preserved (without extra quotes in the parsed form)
assert "NEW=added" in content
@pytest.mark.asyncio
async def test_handle_values_with_equals(self, executor, temp_env_root, mock_settings):
"""Test handling of values containing equals signs."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
result = await executor.execute({
"path": str(env_path),
"updates": {"URL": "postgres://user:pass@host/db?opt=val"},
})
assert result.success is True
content = env_path.read_text()
# Values with = should be quoted
assert 'URL="postgres://user:pass@host/db?opt=val"' in content
@pytest.mark.asyncio
async def test_keys_sorted_in_output(self, executor, temp_env_root, mock_settings):
"""Test that keys are sorted alphabetically in output."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
result = await executor.execute({
"path": str(env_path),
"updates": {
"ZEBRA": "last",
"APPLE": "first",
"MANGO": "middle",
},
})
assert result.success is True
content = env_path.read_text()
lines = [l for l in content.splitlines() if l]
keys = [l.split("=")[0] for l in lines]
assert keys == sorted(keys)
# ==================== Edge Cases ====================
@pytest.mark.asyncio
async def test_empty_env_file(self, executor, temp_env_root, mock_settings):
"""Test handling of empty existing ENV file."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "empty.env"
env_path.write_text("")
result = await executor.execute({
"path": str(env_path),
"updates": {"NEW_KEY": "value"},
})
assert result.success is True
content = env_path.read_text()
assert "NEW_KEY=value" in content
@pytest.mark.asyncio
async def test_remove_all_keys(self, executor, temp_env_root, mock_settings):
"""Test removing all keys results in empty file."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
env_path.write_text("ONLY_KEY=value\n")
result = await executor.execute({
"path": str(env_path),
"remove_keys": ["ONLY_KEY"],
})
assert result.success is True
content = env_path.read_text()
assert content == ""
@pytest.mark.asyncio
async def test_value_with_newline(self, executor, temp_env_root, mock_settings):
"""Test handling values with newlines (should be quoted)."""
with patch("app.executors.env_update_executor.get_settings", return_value=mock_settings):
env_path = temp_env_root / "app.env"
result = await executor.execute({
"path": str(env_path),
"updates": {"MULTILINE": "line1\nline2"},
})
assert result.success is True
content = env_path.read_text()
assert 'MULTILINE="line1\nline2"' in content
class TestEnvUpdateExecutorInternal:
"""Tests for internal methods of EnvUpdateExecutor."""
@pytest.fixture
def executor(self):
"""Create executor instance with mocked logger."""
with patch("app.executors.base.get_logger", return_value=MagicMock()):
return EnvUpdateExecutor()
def test_parse_env_file_basic(self, executor):
"""Test basic ENV file parsing."""
content = "KEY1=value1\nKEY2=value2\n"
result = executor._parse_env_file(content)
assert result == {"KEY1": "value1", "KEY2": "value2"}
def test_parse_env_file_with_comments(self, executor):
"""Test parsing ignores comments."""
content = "# Comment\nKEY=value\n# Another comment\n"
result = executor._parse_env_file(content)
assert result == {"KEY": "value"}
def test_parse_env_file_with_empty_lines(self, executor):
"""Test parsing ignores empty lines."""
content = "KEY1=value1\n\n\nKEY2=value2\n"
result = executor._parse_env_file(content)
assert result == {"KEY1": "value1", "KEY2": "value2"}
def test_parse_env_file_with_quotes(self, executor):
"""Test parsing handles quoted values."""
content = 'KEY1="quoted value"\nKEY2=\'single quoted\'\n'
result = executor._parse_env_file(content)
assert result == {"KEY1": "quoted value", "KEY2": "single quoted"}
def test_parse_env_file_with_equals_in_value(self, executor):
"""Test parsing handles equals signs in values."""
content = "URL=postgres://user:pass@host/db?opt=val\n"
result = executor._parse_env_file(content)
assert result == {"URL": "postgres://user:pass@host/db?opt=val"}
def test_serialize_env_basic(self, executor):
"""Test basic ENV serialization."""
env_dict = {"KEY1": "value1", "KEY2": "value2"}
result = executor._serialize_env(env_dict)
assert "KEY1=value1" in result
assert "KEY2=value2" in result
def test_serialize_env_sorted(self, executor):
"""Test serialization produces sorted output."""
env_dict = {"ZEBRA": "z", "APPLE": "a"}
result = executor._serialize_env(env_dict)
lines = result.strip().split("\n")
assert lines[0].startswith("APPLE=")
assert lines[1].startswith("ZEBRA=")
def test_serialize_env_quotes_special_values(self, executor):
"""Test serialization quotes values with special characters."""
env_dict = {
"SPACES": "has spaces",
"EQUALS": "has=equals",
"NEWLINE": "has\nnewline",
}
result = executor._serialize_env(env_dict)
assert 'SPACES="has spaces"' in result
assert 'EQUALS="has=equals"' in result
assert 'NEWLINE="has\nnewline"' in result
def test_serialize_env_empty_dict(self, executor):
"""Test serialization of empty dict."""
result = executor._serialize_env({})
assert result == ""

View File

@@ -0,0 +1,253 @@
"""Unit tests for FileExecutor (FILE_WRITE)."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Patch the logger before importing the executor
with patch("app.utils.logger.get_logger", return_value=MagicMock()):
from app.executors.file_executor import FileExecutor
class TestFileExecutor:
"""Test suite for FileExecutor."""
@pytest.fixture
def executor(self):
"""Create executor instance with mocked logger."""
with patch("app.executors.base.get_logger", return_value=MagicMock()):
return FileExecutor()
@pytest.fixture
def temp_file_root(self, tmp_path):
"""Create a temporary directory to act as /opt/letsbe."""
file_dir = tmp_path / "opt" / "letsbe"
file_dir.mkdir(parents=True)
return file_dir
@pytest.fixture
def mock_settings(self, temp_file_root):
"""Mock settings with temporary file root."""
settings = MagicMock()
settings.allowed_file_root = str(temp_file_root)
settings.allowed_env_root = str(temp_file_root / "env")
settings.max_file_size = 1_048_576 # 1MB
return settings
# ==================== Happy Path Tests ====================
@pytest.mark.asyncio
async def test_write_file_happy_path(self, executor, temp_file_root, mock_settings):
"""Test writing a simple file."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
content = "Hello, World!"
result = await executor.execute({
"path": str(file_path),
"content": content,
})
assert result.success is True
assert result.data["written"] is True
assert result.data["path"] == str(file_path)
assert result.data["size"] == len(content.encode("utf-8"))
assert file_path.read_text() == content
@pytest.mark.asyncio
async def test_write_file_in_config_directory(self, executor, temp_file_root, mock_settings):
"""Test writing file in /opt/letsbe/config subdirectory.
This verifies that config paths are valid under allowed_file_root.
"""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
config_dir = temp_file_root / "config" / "app"
config_dir.mkdir(parents=True)
file_path = config_dir / "settings.json"
content = '{"debug": false}'
result = await executor.execute({
"path": str(file_path),
"content": content,
})
assert result.success is True
assert result.data["written"] is True
assert file_path.read_text() == content
@pytest.mark.asyncio
async def test_write_file_nested_config_directory(self, executor, temp_file_root, mock_settings):
"""Test writing file in nested config subdirectory."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
config_dir = temp_file_root / "config" / "nginx" / "sites-available"
config_dir.mkdir(parents=True)
file_path = config_dir / "default.conf"
content = "server { listen 80; }"
result = await executor.execute({
"path": str(file_path),
"content": content,
})
assert result.success is True
assert file_path.read_text() == content
@pytest.mark.asyncio
async def test_write_creates_parent_directories(self, executor, temp_file_root, mock_settings):
"""Test that parent directories are created automatically."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "new" / "nested" / "dir" / "file.txt"
content = "content"
result = await executor.execute({
"path": str(file_path),
"content": content,
})
assert result.success is True
assert file_path.exists()
assert file_path.read_text() == content
# ==================== Write Mode Tests ====================
@pytest.mark.asyncio
@pytest.mark.skipif(
__import__("os").name == "nt",
reason="os.rename on Windows fails when target exists; executor uses os.rename instead of os.replace"
)
async def test_write_mode_overwrites(self, executor, temp_file_root, mock_settings):
"""Test that write mode overwrites existing content."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("old content")
result = await executor.execute({
"path": str(file_path),
"content": "new content",
"mode": "write",
})
assert result.success is True
assert file_path.read_text() == "new content"
@pytest.mark.asyncio
async def test_append_mode(self, executor, temp_file_root, mock_settings):
"""Test append mode adds to existing content."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("line1\n")
result = await executor.execute({
"path": str(file_path),
"content": "line2\n",
"mode": "append",
})
assert result.success is True
assert file_path.read_text() == "line1\nline2\n"
@pytest.mark.asyncio
async def test_invalid_mode_rejected(self, executor, temp_file_root, mock_settings):
"""Test rejection of invalid mode."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": str(temp_file_root / "test.txt"),
"content": "content",
"mode": "invalid",
})
assert result.success is False
assert "Invalid mode" in result.error
# ==================== Path Validation Tests ====================
@pytest.mark.asyncio
async def test_path_outside_allowed_root_rejected(self, executor, temp_file_root, mock_settings):
"""Test rejection of paths outside allowed root."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": "/etc/passwd",
"content": "hack",
})
assert result.success is False
assert "Validation failed" in result.error
@pytest.mark.asyncio
async def test_path_traversal_rejected(self, executor, temp_file_root, mock_settings):
"""Test rejection of directory traversal attempts."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": str(temp_file_root / ".." / ".." / "etc" / "passwd"),
"content": "hack",
})
assert result.success is False
assert "Validation failed" in result.error or "traversal" in result.error.lower()
# ==================== Payload Validation Tests ====================
@pytest.mark.asyncio
async def test_missing_path_payload(self, executor):
"""Test rejection of payload without path."""
with pytest.raises(ValueError, match="Missing required fields: path"):
await executor.execute({
"content": "content",
})
@pytest.mark.asyncio
async def test_missing_content_payload(self, executor):
"""Test rejection of payload without content."""
with pytest.raises(ValueError, match="Missing required fields: content"):
await executor.execute({
"path": "/some/path",
})
# ==================== Task Type and Registry Tests ====================
def test_task_type_property(self, executor):
"""Test that task_type returns FILE_WRITE."""
assert executor.task_type == "FILE_WRITE"
def test_registry_integration(self):
"""Test that FILE_WRITE is registered in executor registry."""
from app.executors import get_executor
executor = get_executor("FILE_WRITE")
assert executor is not None
assert executor.task_type == "FILE_WRITE"
# ==================== Duration Tests ====================
@pytest.mark.asyncio
async def test_duration_ms_populated(self, executor, temp_file_root, mock_settings):
"""Test that duration_ms is populated in result."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
result = await executor.execute({
"path": str(file_path),
"content": "content",
})
assert result.success is True
assert result.duration_ms is not None
assert result.duration_ms >= 0
# ==================== UTF-8 Tests ====================
@pytest.mark.asyncio
async def test_utf8_content(self, executor, temp_file_root, mock_settings):
"""Test writing UTF-8 encoded content."""
with patch("app.executors.file_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "utf8.txt"
content = "Hello 世界! こんにちは 🎉"
result = await executor.execute({
"path": str(file_path),
"content": content,
})
assert result.success is True
assert file_path.read_text(encoding="utf-8") == content

View File

@@ -0,0 +1,412 @@
"""Unit tests for FileInspectExecutor."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Patch the logger before importing the executor
with patch("app.utils.logger.get_logger", return_value=MagicMock()):
from app.executors.file_inspect_executor import FileInspectExecutor
class TestFileInspectExecutor:
"""Test suite for FileInspectExecutor."""
@pytest.fixture
def executor(self):
"""Create executor instance with mocked logger."""
with patch("app.executors.base.get_logger", return_value=MagicMock()):
return FileInspectExecutor()
@pytest.fixture
def temp_file_root(self, tmp_path):
"""Create a temporary directory to act as /opt/letsbe."""
file_dir = tmp_path / "opt" / "letsbe"
file_dir.mkdir(parents=True)
return file_dir
@pytest.fixture
def mock_settings(self, temp_file_root):
"""Mock settings with temporary file root."""
settings = MagicMock()
settings.allowed_file_root = str(temp_file_root)
return settings
# ==================== Happy Path Tests ====================
@pytest.mark.asyncio
async def test_inspect_file_happy_path(self, executor, temp_file_root, mock_settings):
"""Test reading a small file with default max_bytes."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
content = "Hello, World!\nThis is a test file."
# Write in binary mode to avoid platform line ending conversion
file_path.write_bytes(content.encode("utf-8"))
result = await executor.execute({
"path": str(file_path),
})
assert result.success is True
assert result.data["path"] == str(file_path)
assert result.data["content"] == content
assert result.data["bytes_read"] == len(content.encode("utf-8"))
assert result.data["truncated"] is False
@pytest.mark.asyncio
async def test_inspect_file_with_custom_max_bytes(self, executor, temp_file_root, mock_settings):
"""Test reading with custom max_bytes value."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
content = "Short content"
file_path.write_text(content)
result = await executor.execute({
"path": str(file_path),
"max_bytes": 8192,
})
assert result.success is True
assert result.data["content"] == content
assert result.data["truncated"] is False
@pytest.mark.asyncio
async def test_inspect_file_nested_directory(self, executor, temp_file_root, mock_settings):
"""Test reading file in nested directory."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
nested_dir = temp_file_root / "subdir" / "nested"
nested_dir.mkdir(parents=True)
file_path = nested_dir / "config.txt"
content = "nested file content"
file_path.write_text(content)
result = await executor.execute({
"path": str(file_path),
})
assert result.success is True
assert result.data["content"] == content
@pytest.mark.asyncio
async def test_inspect_file_in_config_directory(self, executor, temp_file_root, mock_settings):
"""Test reading file in /opt/letsbe/config subdirectory.
This verifies that config paths are valid under allowed_file_root.
"""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
config_dir = temp_file_root / "config" / "nginx"
config_dir.mkdir(parents=True)
file_path = config_dir / "nginx.conf"
content = "server { listen 80; }"
file_path.write_text(content)
result = await executor.execute({
"path": str(file_path),
})
assert result.success is True
assert result.data["content"] == content
assert result.data["path"] == str(file_path)
# ==================== Truncation Tests ====================
@pytest.mark.asyncio
async def test_inspect_file_with_truncation(self, executor, temp_file_root, mock_settings):
"""Test truncation when file is larger than max_bytes."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "large.txt"
content = "A" * 100 # 100 bytes
file_path.write_text(content)
result = await executor.execute({
"path": str(file_path),
"max_bytes": 10,
})
assert result.success is True
assert result.data["truncated"] is True
assert result.data["bytes_read"] == 10
assert result.data["content"] == "A" * 10
@pytest.mark.asyncio
async def test_inspect_file_exact_size_no_truncation(self, executor, temp_file_root, mock_settings):
"""Test no truncation when file is exactly max_bytes."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "exact.txt"
content = "X" * 50
file_path.write_text(content)
result = await executor.execute({
"path": str(file_path),
"max_bytes": 50,
})
assert result.success is True
assert result.data["truncated"] is False
assert result.data["bytes_read"] == 50
assert result.data["content"] == content
@pytest.mark.asyncio
async def test_inspect_file_one_byte_over_truncates(self, executor, temp_file_root, mock_settings):
"""Test truncation when file is one byte over max_bytes."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "over.txt"
content = "Y" * 51
file_path.write_text(content)
result = await executor.execute({
"path": str(file_path),
"max_bytes": 50,
})
assert result.success is True
assert result.data["truncated"] is True
assert result.data["bytes_read"] == 50
# ==================== File Not Found Tests ====================
@pytest.mark.asyncio
async def test_file_does_not_exist(self, executor, temp_file_root, mock_settings):
"""Test error when file does not exist."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "nonexistent.txt"
result = await executor.execute({
"path": str(file_path),
})
assert result.success is False
assert "does not exist" in result.error or "Path validation failed" in result.error
# ==================== Path Validation Tests ====================
@pytest.mark.asyncio
async def test_path_outside_allowed_root_rejected(self, executor, temp_file_root, mock_settings):
"""Test rejection of paths outside allowed root."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": "/etc/passwd",
})
assert result.success is False
assert "Path validation failed" in result.error
@pytest.mark.asyncio
async def test_path_traversal_rejected(self, executor, temp_file_root, mock_settings):
"""Test rejection of directory traversal attempts."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
result = await executor.execute({
"path": str(temp_file_root / ".." / ".." / "etc" / "passwd"),
})
assert result.success is False
assert "Path validation failed" in result.error or "traversal" in result.error.lower()
# ==================== max_bytes Validation Tests ====================
@pytest.mark.asyncio
async def test_invalid_max_bytes_type_string(self, executor, temp_file_root, mock_settings):
"""Test rejection when max_bytes is a non-numeric string."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("content")
result = await executor.execute({
"path": str(file_path),
"max_bytes": "not-a-number",
})
assert result.success is False
assert "Invalid max_bytes" in result.error
@pytest.mark.asyncio
async def test_invalid_max_bytes_type_none(self, executor, temp_file_root, mock_settings):
"""Test that None max_bytes uses default (success case)."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("content")
# Note: None should trigger default, not error
# But explicitly passing None might be different from omitting
result = await executor.execute({
"path": str(file_path),
"max_bytes": None,
})
# None cannot be converted to int, so this should fail
assert result.success is False
assert "Invalid max_bytes" in result.error
@pytest.mark.asyncio
async def test_max_bytes_zero_rejected(self, executor, temp_file_root, mock_settings):
"""Test rejection when max_bytes is zero."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("content")
result = await executor.execute({
"path": str(file_path),
"max_bytes": 0,
})
assert result.success is False
assert "max_bytes must be between 1 and" in result.error
@pytest.mark.asyncio
async def test_max_bytes_negative_rejected(self, executor, temp_file_root, mock_settings):
"""Test rejection when max_bytes is negative."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("content")
result = await executor.execute({
"path": str(file_path),
"max_bytes": -100,
})
assert result.success is False
assert "max_bytes must be between 1 and" in result.error
@pytest.mark.asyncio
async def test_max_bytes_over_limit_rejected(self, executor, temp_file_root, mock_settings):
"""Test rejection when max_bytes exceeds 1MB."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("content")
result = await executor.execute({
"path": str(file_path),
"max_bytes": 2_000_000, # 2MB
})
assert result.success is False
assert "max_bytes must be between 1 and" in result.error
@pytest.mark.asyncio
async def test_max_bytes_at_limit_accepted(self, executor, temp_file_root, mock_settings):
"""Test acceptance of max_bytes at exactly 1MB."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("small content")
result = await executor.execute({
"path": str(file_path),
"max_bytes": 1_048_576, # Exactly 1MB
})
assert result.success is True
@pytest.mark.asyncio
async def test_max_bytes_as_string_number_accepted(self, executor, temp_file_root, mock_settings):
"""Test acceptance of max_bytes as numeric string."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("content")
result = await executor.execute({
"path": str(file_path),
"max_bytes": "4096", # String that can be converted to int
})
assert result.success is True
# ==================== Payload Validation Tests ====================
@pytest.mark.asyncio
async def test_missing_path_payload(self, executor):
"""Test rejection of payload without path."""
with pytest.raises(ValueError, match="Missing required field: path"):
await executor.execute({})
@pytest.mark.asyncio
async def test_missing_path_with_max_bytes(self, executor):
"""Test rejection of payload with max_bytes but no path."""
with pytest.raises(ValueError, match="Missing required field: path"):
await executor.execute({
"max_bytes": 4096,
})
# ==================== Task Type and Registry Tests ====================
def test_task_type_property(self, executor):
"""Test that task_type returns FILE_INSPECT."""
assert executor.task_type == "FILE_INSPECT"
def test_registry_integration(self):
"""Test that FILE_INSPECT is registered in executor registry."""
from app.executors import get_executor
executor = get_executor("FILE_INSPECT")
assert executor is not None
assert executor.task_type == "FILE_INSPECT"
# ==================== Empty File Tests ====================
@pytest.mark.asyncio
async def test_empty_file(self, executor, temp_file_root, mock_settings):
"""Test reading an empty file."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "empty.txt"
file_path.write_text("")
result = await executor.execute({
"path": str(file_path),
})
assert result.success is True
assert result.data["content"] == ""
assert result.data["bytes_read"] == 0
assert result.data["truncated"] is False
# ==================== Binary/UTF-8 Tests ====================
@pytest.mark.asyncio
async def test_utf8_content(self, executor, temp_file_root, mock_settings):
"""Test reading UTF-8 encoded content."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "utf8.txt"
content = "Hello 世界! こんにちは 🎉"
file_path.write_text(content, encoding="utf-8")
result = await executor.execute({
"path": str(file_path),
})
assert result.success is True
assert result.data["content"] == content
@pytest.mark.asyncio
async def test_binary_with_replacement(self, executor, temp_file_root, mock_settings):
"""Test that invalid UTF-8 bytes are replaced."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "binary.bin"
# Write bytes that are not valid UTF-8
file_path.write_bytes(b"Hello\xff\xfeWorld")
result = await executor.execute({
"path": str(file_path),
})
assert result.success is True
# Invalid bytes should be replaced with replacement character
assert "Hello" in result.data["content"]
assert "World" in result.data["content"]
assert "\ufffd" in result.data["content"] # Replacement character
# ==================== Duration Tests ====================
@pytest.mark.asyncio
async def test_duration_ms_populated(self, executor, temp_file_root, mock_settings):
"""Test that duration_ms is populated in result."""
with patch("app.executors.file_inspect_executor.get_settings", return_value=mock_settings):
file_path = temp_file_root / "test.txt"
file_path.write_text("content")
result = await executor.execute({
"path": str(file_path),
})
assert result.success is True
assert result.duration_ms is not None
assert result.duration_ms >= 0

View File

@@ -0,0 +1,524 @@
"""Unit tests for NextcloudSetDomainExecutor."""
import asyncio
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Patch the logger before importing the executor
with patch("app.utils.logger.get_logger", return_value=MagicMock()):
from app.executors.nextcloud_executor import NextcloudSetDomainExecutor
class TestNextcloudSetDomainExecutor:
"""Tests for NextcloudSetDomainExecutor."""
@pytest.fixture
def executor(self):
"""Create executor with mocked logger."""
with patch("app.executors.base.get_logger", return_value=MagicMock()):
return NextcloudSetDomainExecutor()
@pytest.fixture
def temp_nextcloud_stack(self, tmp_path):
"""Create a temporary Nextcloud stack directory with compose file."""
stack_dir = tmp_path / "opt" / "letsbe" / "stacks" / "nextcloud"
stack_dir.mkdir(parents=True)
compose_file = stack_dir / "docker-compose.yml"
compose_file.write_text("""version: '3.8'
services:
app:
image: nextcloud:latest
""")
return stack_dir
@pytest.fixture
def executor_with_temp_stack(self, executor, temp_nextcloud_stack):
"""Configure executor to use temporary stack directory."""
executor.NEXTCLOUD_STACK_DIR = str(temp_nextcloud_stack)
return executor
# =========================================================================
# TASK TYPE TEST
# =========================================================================
def test_task_type(self, executor):
"""Test that task_type property returns correct value."""
assert executor.task_type == "NEXTCLOUD_SET_DOMAIN"
# =========================================================================
# URL PARSING TESTS
# =========================================================================
def test_parse_public_url_with_https(self, executor):
"""Test URL parsing with explicit https scheme."""
scheme, host, normalized_url = executor._parse_public_url("https://cloud.example.com")
assert scheme == "https"
assert host == "cloud.example.com"
assert normalized_url == "https://cloud.example.com"
def test_parse_public_url_with_http(self, executor):
"""Test URL parsing with http scheme."""
scheme, host, normalized_url = executor._parse_public_url("http://cloud.example.com")
assert scheme == "http"
assert host == "cloud.example.com"
assert normalized_url == "http://cloud.example.com"
def test_parse_public_url_with_port(self, executor):
"""Test URL parsing with port number."""
scheme, host, normalized_url = executor._parse_public_url("https://cloud.example.com:8443")
assert scheme == "https"
assert host == "cloud.example.com:8443"
assert normalized_url == "https://cloud.example.com:8443"
def test_parse_public_url_without_scheme_defaults_to_https(self, executor):
"""Test that URLs without scheme default to https."""
scheme, host, normalized_url = executor._parse_public_url("cloud.example.com")
assert scheme == "https"
assert host == "cloud.example.com"
assert normalized_url == "https://cloud.example.com"
def test_parse_public_url_trailing_slash_stripped(self, executor):
"""Test that trailing slash is stripped from URL."""
scheme, host, normalized_url = executor._parse_public_url("https://cloud.example.com/")
assert scheme == "https"
assert host == "cloud.example.com"
assert normalized_url == "https://cloud.example.com"
def test_parse_public_url_with_path(self, executor):
"""Test URL parsing with path (trailing slash stripped)."""
scheme, host, normalized_url = executor._parse_public_url("https://cloud.example.com/nextcloud/")
assert scheme == "https"
assert host == "cloud.example.com"
assert normalized_url == "https://cloud.example.com/nextcloud"
def test_parse_public_url_empty_raises_error(self, executor):
"""Test that empty URL raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
executor._parse_public_url("")
def test_parse_public_url_whitespace_only_raises_error(self, executor):
"""Test that whitespace-only URL raises ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
executor._parse_public_url(" ")
def test_parse_public_url_invalid_no_host_raises_error(self, executor):
"""Test that URL with no host raises ValueError."""
with pytest.raises(ValueError, match="no host found"):
executor._parse_public_url("https://")
# =========================================================================
# SUCCESS CASES
# =========================================================================
@pytest.mark.asyncio
async def test_success_all_commands(self, executor_with_temp_stack):
"""Test successful domain configuration with all three occ commands."""
with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run:
mock_run.return_value = (0, "System config value overwritehost set to cloud.example.com", "")
result = await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is True
assert result.data["public_url"] == "https://cloud.example.com"
assert result.data["host"] == "cloud.example.com"
assert result.data["scheme"] == "https"
assert result.data["commands_executed"] == 3
assert "overwritehost" in result.data["logs"]
assert "overwriteprotocol" in result.data["logs"]
assert "overwrite.cli.url" in result.data["logs"]
assert result.error is None
assert result.duration_ms is not None
# Verify all three commands were called
assert mock_run.call_count == 3
@pytest.mark.asyncio
async def test_success_with_http_scheme(self, executor_with_temp_stack):
"""Test successful configuration with http scheme."""
with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run:
mock_run.return_value = (0, "success", "")
result = await executor_with_temp_stack.execute({
"public_url": "http://cloud.example.com"
})
assert result.success is True
assert result.data["scheme"] == "http"
assert result.data["public_url"] == "http://cloud.example.com"
@pytest.mark.asyncio
async def test_success_url_without_scheme(self, executor_with_temp_stack):
"""Test successful configuration when URL lacks scheme (defaults to https)."""
with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run:
mock_run.return_value = (0, "success", "")
result = await executor_with_temp_stack.execute({
"public_url": "cloud.example.com"
})
assert result.success is True
assert result.data["scheme"] == "https"
assert result.data["host"] == "cloud.example.com"
assert result.data["public_url"] == "https://cloud.example.com"
# =========================================================================
# COMMAND ARGUMENTS TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_command_arguments_correct(self, executor_with_temp_stack):
"""Test that occ commands receive correct arguments."""
calls = []
async def capture_calls(compose_file, occ_args, timeout):
calls.append(occ_args)
return (0, "success", "")
with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=capture_calls):
await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert len(calls) == 3
assert calls[0] == ["config:system:set", "overwritehost", "--value=cloud.example.com"]
assert calls[1] == ["config:system:set", "overwriteprotocol", "--value=https"]
assert calls[2] == ["config:system:set", "overwrite.cli.url", "--value=https://cloud.example.com"]
@pytest.mark.asyncio
async def test_command_arguments_with_port(self, executor_with_temp_stack):
"""Test that host with port is passed correctly to occ command."""
calls = []
async def capture_calls(compose_file, occ_args, timeout):
calls.append(occ_args)
return (0, "success", "")
with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=capture_calls):
await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com:8443"
})
assert calls[0] == ["config:system:set", "overwritehost", "--value=cloud.example.com:8443"]
# =========================================================================
# PAYLOAD VALIDATION TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_missing_public_url_raises_error(self, executor_with_temp_stack):
"""Test that missing public_url raises ValueError."""
with pytest.raises(ValueError, match="Missing required fields"):
await executor_with_temp_stack.execute({})
@pytest.mark.asyncio
async def test_invalid_url_returns_failure(self, executor_with_temp_stack):
"""Test that invalid URL returns failure result."""
result = await executor_with_temp_stack.execute({
"public_url": ""
})
assert result.success is False
assert "cannot be empty" in result.error
# =========================================================================
# COMPOSE FILE TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_compose_file_not_found(self, executor, tmp_path):
"""Test failure when compose file doesn't exist."""
nonexistent_dir = tmp_path / "nonexistent"
nonexistent_dir.mkdir()
executor.NEXTCLOUD_STACK_DIR = str(nonexistent_dir)
result = await executor.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is False
assert "compose file not found" in result.error.lower()
assert "docker-compose.yml" in result.error
@pytest.mark.asyncio
async def test_compose_yml_fallback(self, executor, tmp_path):
"""Test that compose.yml is used as fallback."""
stack_dir = tmp_path / "nextcloud"
stack_dir.mkdir()
(stack_dir / "compose.yml").write_text("version: '3'\n")
executor.NEXTCLOUD_STACK_DIR = str(stack_dir)
with patch.object(executor, "_run_occ_command") as mock_run:
mock_run.return_value = (0, "success", "")
result = await executor.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is True
# =========================================================================
# COMMAND FAILURE TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_first_command_fails(self, executor_with_temp_stack):
"""Test that failure on first occ command returns partial results."""
with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run:
mock_run.return_value = (1, "", "Error: Unable to write config")
result = await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is False
assert result.data["commands_executed"] == 1
assert result.data["failed_command"] == "overwritehost"
assert result.data["failed_args"] == ["config:system:set", "overwritehost", "--value=cloud.example.com"]
assert "overwritehost" in result.data["logs"]
assert "occ overwritehost failed with exit code 1" in result.error
@pytest.mark.asyncio
async def test_second_command_fails(self, executor_with_temp_stack):
"""Test that failure on second occ command returns partial results."""
call_count = 0
async def mock_occ(compose_file, occ_args, timeout):
nonlocal call_count
call_count += 1
if call_count == 1:
return (0, "success", "") # First succeeds
return (1, "", "Error setting overwriteprotocol") # Second fails
with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=mock_occ):
result = await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is False
assert result.data["commands_executed"] == 2
assert result.data["failed_command"] == "overwriteprotocol"
assert "overwritehost" in result.data["logs"]
assert "overwriteprotocol" in result.data["logs"]
assert "overwrite.cli.url" not in result.data["logs"]
@pytest.mark.asyncio
async def test_third_command_fails(self, executor_with_temp_stack):
"""Test that failure on third occ command returns partial results."""
call_count = 0
async def mock_occ(compose_file, occ_args, timeout):
nonlocal call_count
call_count += 1
if call_count <= 2:
return (0, "success", "") # First two succeed
return (1, "", "Error setting overwrite.cli.url") # Third fails
with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=mock_occ):
result = await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is False
assert result.data["commands_executed"] == 3
assert result.data["failed_command"] == "overwrite.cli.url"
assert "overwritehost" in result.data["logs"]
assert "overwriteprotocol" in result.data["logs"]
assert "overwrite.cli.url" in result.data["logs"]
# =========================================================================
# TIMEOUT HANDLING TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_timeout_handling(self, executor_with_temp_stack):
"""Test that asyncio.TimeoutError is caught and returns failure."""
with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run:
mock_run.side_effect = asyncio.TimeoutError()
result = await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is False
assert "timed out" in result.error.lower()
assert result.data["commands_executed"] == 0
assert result.duration_ms is not None
@pytest.mark.asyncio
async def test_timeout_after_partial_success(self, executor_with_temp_stack):
"""Test timeout after some commands succeeded."""
call_count = 0
async def mock_occ(compose_file, occ_args, timeout):
nonlocal call_count
call_count += 1
if call_count == 1:
return (0, "success", "")
raise asyncio.TimeoutError()
with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=mock_occ):
result = await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is False
assert "timed out" in result.error.lower()
assert result.data["commands_executed"] == 1
assert "overwritehost" in result.data["logs"]
# =========================================================================
# EXCEPTION HANDLING TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_unexpected_exception_handling(self, executor_with_temp_stack):
"""Test that unexpected exceptions are caught and returned."""
with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run:
mock_run.side_effect = RuntimeError("Unexpected docker error")
result = await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is False
assert "Unexpected docker error" in result.error
assert result.duration_ms is not None
# =========================================================================
# RESULT STRUCTURE TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_result_structure_on_success(self, executor_with_temp_stack):
"""Test that successful result contains all expected keys."""
with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run:
mock_run.return_value = (0, "success", "")
result = await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is True
assert "public_url" in result.data
assert "host" in result.data
assert "scheme" in result.data
assert "commands_executed" in result.data
assert "logs" in result.data
assert result.error is None
assert result.duration_ms is not None
@pytest.mark.asyncio
async def test_result_structure_on_failure(self, executor_with_temp_stack):
"""Test that failure result contains all expected keys including failed_command."""
with patch.object(executor_with_temp_stack, "_run_occ_command") as mock_run:
mock_run.return_value = (1, "", "error")
result = await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com"
})
assert result.success is False
assert "public_url" in result.data
assert "host" in result.data
assert "scheme" in result.data
assert "commands_executed" in result.data
assert "failed_command" in result.data
assert "failed_args" in result.data
assert "logs" in result.data
assert result.error is not None
assert result.duration_ms is not None
# =========================================================================
# OUTPUT COMBINATION TESTS
# =========================================================================
def test_combine_output_both(self, executor):
"""Test combining stdout and stderr."""
result = executor._combine_output("stdout content", "stderr content")
assert "stdout content" in result
assert "stderr content" in result
def test_combine_output_stdout_only(self, executor):
"""Test combining with only stdout."""
result = executor._combine_output("stdout content", "")
assert result == "stdout content"
def test_combine_output_stderr_only(self, executor):
"""Test combining with only stderr."""
result = executor._combine_output("", "stderr content")
assert result == "stderr content"
def test_combine_output_empty(self, executor):
"""Test combining empty outputs."""
result = executor._combine_output("", "")
assert result == ""
# =========================================================================
# FIND COMPOSE FILE TESTS
# =========================================================================
def test_find_compose_file_docker_compose_yml(self, executor, tmp_path):
"""Test finding docker-compose.yml."""
(tmp_path / "docker-compose.yml").write_text("version: '3'\n")
result = executor._find_compose_file(tmp_path)
assert result == tmp_path / "docker-compose.yml"
def test_find_compose_file_compose_yml(self, executor, tmp_path):
"""Test finding compose.yml as fallback."""
(tmp_path / "compose.yml").write_text("version: '3'\n")
result = executor._find_compose_file(tmp_path)
assert result == tmp_path / "compose.yml"
def test_find_compose_file_prefers_docker_compose_yml(self, executor, tmp_path):
"""Test that docker-compose.yml is preferred over compose.yml."""
(tmp_path / "docker-compose.yml").write_text("version: '3'\n")
(tmp_path / "compose.yml").write_text("version: '3'\n")
result = executor._find_compose_file(tmp_path)
assert result == tmp_path / "docker-compose.yml"
def test_find_compose_file_not_found(self, executor, tmp_path):
"""Test returning None when no compose file found."""
result = executor._find_compose_file(tmp_path)
assert result is None
# =========================================================================
# CUSTOM TIMEOUT TESTS
# =========================================================================
@pytest.mark.asyncio
async def test_custom_timeout_passed(self, executor_with_temp_stack):
"""Test that custom timeout from payload is passed to _run_occ_command."""
received_timeouts = []
async def capture_timeout(compose_file, occ_args, timeout):
received_timeouts.append(timeout)
return (0, "success", "")
with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=capture_timeout):
await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com",
"timeout": 120,
})
assert all(t == 120 for t in received_timeouts)
@pytest.mark.asyncio
async def test_default_timeout_used(self, executor_with_temp_stack):
"""Test that default timeout is used when not specified in payload."""
received_timeouts = []
async def capture_timeout(compose_file, occ_args, timeout):
received_timeouts.append(timeout)
return (0, "success", "")
with patch.object(executor_with_temp_stack, "_run_occ_command", side_effect=capture_timeout):
await executor_with_temp_stack.execute({
"public_url": "https://cloud.example.com",
})
assert all(t == executor_with_temp_stack.DEFAULT_COMMAND_TIMEOUT for t in received_timeouts)

View File

@@ -0,0 +1,450 @@
"""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

View File

@@ -0,0 +1,81 @@
"""Integration test for DockerExecutor with real Docker."""
import asyncio
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
def main():
# Create a real temp directory structure
with tempfile.TemporaryDirectory() as tmp:
stacks_root = Path(tmp) / "stacks"
stack_dir = stacks_root / "test-app"
stack_dir.mkdir(parents=True)
# Create a minimal compose file
compose_content = """services:
test:
image: alpine:latest
command: echo 'Hello from integration test'
"""
compose_file = stack_dir / "docker-compose.yml"
compose_file.write_text(compose_content)
print(f"Created stack at: {stack_dir}")
print(f"Compose file: {compose_file}")
# Import executor with mocked logger
with patch("app.executors.base.get_logger", return_value=MagicMock()):
from app.executors.docker_executor import DockerExecutor
executor = DockerExecutor()
# Mock settings to use our temp directory
mock_settings = MagicMock()
mock_settings.allowed_stacks_root = str(stacks_root)
async def run_test():
with patch("app.executors.docker_executor.get_settings", return_value=mock_settings):
# Test 1: Without pull
print("\n=== Test 1: pull=False ===")
result = await executor.execute({
"compose_dir": str(stack_dir),
"pull": False,
"timeout": 60,
})
print(f"Success: {result.success}")
print(f"compose_file: {result.data.get('compose_file')}")
print(f"pull_ran: {result.data.get('pull_ran')}")
if result.error:
print(f"Error: {result.error}")
up_logs = result.data.get("logs", {}).get("up", "")
print(f"Logs (up): {up_logs[:300] if up_logs else 'empty'}")
# Test 2: With pull
print("\n=== Test 2: pull=True ===")
result2 = await executor.execute({
"compose_dir": str(stack_dir),
"pull": True,
"timeout": 60,
})
print(f"Success: {result2.success}")
print(f"pull_ran: {result2.data.get('pull_ran')}")
pull_logs = result2.data.get("logs", {}).get("pull", "")
print(f"Logs (pull): {pull_logs[:300] if pull_logs else 'empty'}")
return result.success and result2.success
success = asyncio.run(run_test())
print(f"\n{'=' * 50}")
print(f"INTEGRATION TEST: {'PASSED' if success else 'FAILED'}")
print(f"{'=' * 50}")
return success
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)