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