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