letsbe-sysadmin/tests/executors/test_env_update_executor.py

583 lines
24 KiB
Python
Raw Normal View History

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