Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
letsbe-sysadmin-agent/tests/__init__.py
Normal file
1
letsbe-sysadmin-agent/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test suite for LetsBe SysAdmin Agent."""
|
||||
55
letsbe-sysadmin-agent/tests/conftest.py
Normal file
55
letsbe-sysadmin-agent/tests/conftest.py
Normal 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
|
||||
1
letsbe-sysadmin-agent/tests/executors/__init__.py
Normal file
1
letsbe-sysadmin-agent/tests/executors/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for executor modules."""
|
||||
495
letsbe-sysadmin-agent/tests/executors/test_composite_executor.py
Normal file
495
letsbe-sysadmin-agent/tests/executors/test_composite_executor.py
Normal 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
|
||||
467
letsbe-sysadmin-agent/tests/executors/test_docker_executor.py
Normal file
467
letsbe-sysadmin-agent/tests/executors/test_docker_executor.py
Normal 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
|
||||
@@ -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 == {}
|
||||
@@ -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 == ""
|
||||
253
letsbe-sysadmin-agent/tests/executors/test_file_executor.py
Normal file
253
letsbe-sysadmin-agent/tests/executors/test_file_executor.py
Normal 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
|
||||
@@ -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
|
||||
524
letsbe-sysadmin-agent/tests/executors/test_nextcloud_executor.py
Normal file
524
letsbe-sysadmin-agent/tests/executors/test_nextcloud_executor.py
Normal 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)
|
||||
@@ -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
|
||||
81
letsbe-sysadmin-agent/tests/integration_docker_test.py
Normal file
81
letsbe-sysadmin-agent/tests/integration_docker_test.py
Normal 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)
|
||||
Reference in New Issue
Block a user