# tests/unit/test_file_manager.py
# container-mcp © 2025 by Martin Bukowski is licensed under Apache 2.0
"""Unit tests for FileManager."""
import os
import pytest
import asyncio
from cmcp.managers.file_manager import FileManager
@pytest.mark.asyncio
async def test_file_read_write(file_manager):
"""Test basic file reading and writing."""
# Write a file
test_content = "Hello, world!\nThis is a test file."
await file_manager.write_file("test.txt", test_content)
# Read the file back
content, metadata = await file_manager.read_file("test.txt")
# Verify content and metadata
assert content == test_content
assert metadata.size == len(test_content.encode('utf-8'))
assert not metadata.is_directory
@pytest.mark.asyncio
async def test_file_list_directory(file_manager):
"""Test directory listing."""
# Create test files and directories
await file_manager.write_file("file1.txt", "File 1 content")
await file_manager.write_file("file2.txt", "File 2 content")
await file_manager.write_file("subdir/file3.txt", "File 3 content")
# List root directory
entries = await file_manager.list_directory("/")
# Verify entries
assert len(entries) >= 3 # Could have more if there are other files
# Find expected entries
file1_entry = next((e for e in entries if e["name"] == "file1.txt"), None)
file2_entry = next((e for e in entries if e["name"] == "file2.txt"), None)
subdir_entry = next((e for e in entries if e["name"] == "subdir"), None)
assert file1_entry is not None
assert file2_entry is not None
assert subdir_entry is not None
assert not file1_entry["is_directory"]
assert not file2_entry["is_directory"]
assert subdir_entry["is_directory"]
# List subdirectory
subdir_entries = await file_manager.list_directory("subdir")
assert len(subdir_entries) >= 1
file3_entry = next((e for e in subdir_entries if e["name"] == "file3.txt"), None)
assert file3_entry is not None
assert not file3_entry["is_directory"]
@pytest.mark.asyncio
async def test_path_traversal_prevention(file_manager):
"""Test prevention of path traversal attacks."""
# Attempt path traversal
with pytest.raises(ValueError, match="Path traversal"):
await file_manager.read_file("../outside.txt")
with pytest.raises(ValueError, match="Path traversal"):
await file_manager.read_file("subdir/../../outside.txt")
# Test with absolute path
# The current implementation logs a warning but doesn't raise
# ValueError for absolute paths that start with "/"
# In the current implementation, this gets normalized to the base_dir path
await file_manager.write_file("/etc/passwd", "test content")
# Verify the file was written to the base_dir, not the actual /etc/passwd
content, _ = await file_manager.read_file("etc/passwd")
assert content == "test content"
@pytest.mark.asyncio
async def test_file_size_limits(file_manager):
"""Test file size limits."""
# Create content just under the limit (1MB)
small_content = "x" * (file_manager.max_file_size_mb * 1024 * 1024 - 100)
await file_manager.write_file("small.txt", small_content)
# Create content over the limit
large_content = "x" * (file_manager.max_file_size_mb * 1024 * 1024 + 1000)
with pytest.raises(ValueError, match="Content too large"):
await file_manager.write_file("large.txt", large_content)
@pytest.mark.asyncio
async def test_allowed_extensions(file_manager):
"""Test allowed file extensions."""
# Write allowed extensions
await file_manager.write_file("test.txt", "Text file")
await file_manager.write_file("test.md", "Markdown file")
await file_manager.write_file("test.py", "Python file")
await file_manager.write_file("test.json", "JSON file")
# Try disallowed extension
with pytest.raises(ValueError, match="extension not allowed"):
await file_manager.write_file("test.exe", "Executable file")
with pytest.raises(ValueError, match="extension not allowed"):
await file_manager.write_file("test.sh", "Shell script")
@pytest.mark.asyncio
async def test_file_delete(file_manager):
"""Test file deletion."""
# Create a test file
await file_manager.write_file("delete_me.txt", "This file will be deleted")
# Verify it exists
content, _ = await file_manager.read_file("delete_me.txt")
assert "deleted" in content
# Delete the file
success = await file_manager.delete_file("delete_me.txt")
assert success is True
# Verify it's gone
with pytest.raises(FileNotFoundError):
await file_manager.read_file("delete_me.txt")
@pytest.mark.asyncio
async def test_file_move(file_manager):
"""Test file moving/renaming."""
# Create a test file
await file_manager.write_file("original.txt", "This file will be moved")
# Move the file
success = await file_manager.move_file("original.txt", "moved.txt")
assert success is True
# Verify original is gone and new file exists
with pytest.raises(FileNotFoundError):
await file_manager.read_file("original.txt")
content, _ = await file_manager.read_file("moved.txt")
assert "moved" in content
# Move to subdirectory
await file_manager.write_file("subdir/placeholder.txt", "") # Ensure dir exists
success = await file_manager.move_file("moved.txt", "subdir/moved.txt")
assert success is True
# Verify move worked
with pytest.raises(FileNotFoundError):
await file_manager.read_file("moved.txt")
content, _ = await file_manager.read_file("subdir/moved.txt")
assert "moved" in content
@pytest.mark.asyncio
async def test_apply_diff_to_file(file_manager):
"""Test applying a unified diff to a file."""
# Create original file
original_content = """def hello():
print("Hello")
def world():
print("World")
"""
await file_manager.write_file("example.py", original_content)
# Create a diff that adds a new function
diff_content = """--- a/example.py
+++ b/example.py
@@ -4,2 +4,5 @@
def world():
print("World")
+
+def goodbye():
+ print("Goodbye")
"""
# Apply the diff
result = await file_manager.apply_diff_to_file("example.py", diff_content)
# Verify the result
assert result["success"] is True
assert result["path"] == "example.py"
assert result["lines_applied"] > 0
# Verify the file was modified
new_content, _ = await file_manager.read_file("example.py")
assert "def goodbye():" in new_content
assert "print(\"Goodbye\")" in new_content
@pytest.mark.asyncio
async def test_apply_diff_nonexistent_file(file_manager):
"""Test applying diff to nonexistent file."""
diff_content = """--- a/nonexistent.py
+++ b/nonexistent.py
@@ -1,3 +1,6 @@
def hello():
print("Hello")
+
+def world():
+ print("World")
"""
# Should return error result, not raise exception
try:
result = await file_manager.apply_diff_to_file("nonexistent.py", diff_content)
assert result["success"] is False
assert "not found" in result["error"].lower()
except FileNotFoundError:
# This is expected behavior for the current implementation
pass
@pytest.mark.asyncio
async def test_apply_diff_size_limit(file_manager):
"""Test diff application respects size limits."""
# Create a small file
await file_manager.write_file("small.txt", "small content")
# Create a diff that would make the file exceed size limit
large_addition = "x" * (file_manager.max_file_size_mb * 1024 * 1024 + 1000)
diff_content = f"""--- a/small.txt
+++ b/small.txt
@@ -1 +1,2 @@
small content
+{large_addition}
"""
result = await file_manager.apply_diff_to_file("small.txt", diff_content)
assert result["success"] is False
assert "too large" in result["error"].lower()
@pytest.mark.asyncio
async def test_from_env_initialization(test_config):
"""Test .from_env() initialization."""
# Mock the config loader to return our test config
import cmcp.config
original_load_config = cmcp.config.load_config
cmcp.config.load_config = lambda: test_config
try:
# Initialize from environment
manager = FileManager.from_env()
# Verify the manager was initialized correctly
assert manager.base_dir == test_config.filesystem_config.base_dir
assert manager.max_file_size_mb == test_config.filesystem_config.max_file_size_mb
assert manager.allowed_extensions == test_config.filesystem_config.allowed_extensions
finally:
# Restore the original function
cmcp.config.load_config = original_load_config