413 lines
17 KiB
Python
413 lines
17 KiB
Python
"""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
|