initial commit after one day coding agent session
This commit is contained in:
commit
372af75b90
88 changed files with 22694 additions and 0 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
346
tests/builder/test_environment_builder.py
Normal file
346
tests/builder/test_environment_builder.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""Tests for environment builder orchestration."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from mcp_forge.builder.environment_builder import (
|
||||
EnvironmentBuilder,
|
||||
BuildRateLimiter,
|
||||
)
|
||||
from mcp_forge.builder.package_validator import SecurityError
|
||||
from mcp_forge.builder.image_builder import BuildResult
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder_config():
|
||||
"""Mock environment builder configuration."""
|
||||
config = Mock(spec=EnvironmentBuilderConfig)
|
||||
config.build_timeout = 600
|
||||
config.max_build_timeout = 1800
|
||||
config.max_image_size = "2g"
|
||||
config.max_packages = 50
|
||||
config.uv_cache_path = Path("/tmp/uv-cache")
|
||||
config.build_rate_limit = {"requests": 5, "period": 3600}
|
||||
config.max_concurrent_builds = 3
|
||||
config.base_images = {"python:3.11-slim": True}
|
||||
config.templates = {
|
||||
"data-science": {
|
||||
"packages": ["numpy", "pandas", "matplotlib"],
|
||||
"description": "Data science environment"
|
||||
}
|
||||
}
|
||||
# Add package_validation config for PackageValidator
|
||||
config.package_validation = Path("/tmp/package-validation.yaml")
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_podman_client():
|
||||
"""Mock Podman client."""
|
||||
return Mock(spec=PodmanClient)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_audit_logger():
|
||||
"""Mock audit logger."""
|
||||
return Mock(spec=AuditLogger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder(builder_config, mock_podman_client, mock_audit_logger):
|
||||
"""Environment builder instance."""
|
||||
# Mock the sub-components during initialization
|
||||
with patch('mcp_forge.builder.environment_builder.PackageValidator'), \
|
||||
patch('mcp_forge.builder.environment_builder.UVInstaller'), \
|
||||
patch('mcp_forge.builder.environment_builder.ImageBuilder'):
|
||||
|
||||
builder = EnvironmentBuilder(
|
||||
config=builder_config,
|
||||
podman_client=mock_podman_client,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
# Replace with mocks for tests
|
||||
builder.package_validator = Mock()
|
||||
builder.uv_installer = Mock()
|
||||
builder.image_builder = Mock()
|
||||
|
||||
return builder
|
||||
|
||||
|
||||
def test_build_custom_environment_success(builder, tmp_path):
|
||||
"""Test successful custom environment build."""
|
||||
# Mock all sub-components
|
||||
builder.package_validator.validate_packages = Mock()
|
||||
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
||||
|
||||
mock_result = BuildResult(
|
||||
success=True,
|
||||
image_name="mcp-forge/custom:test-env",
|
||||
image_id="sha256:abc123",
|
||||
build_time=45.2,
|
||||
size_bytes=500_000_000,
|
||||
cache_hit=False,
|
||||
installed_packages=["numpy==1.24.0"]
|
||||
)
|
||||
builder.image_builder.build_image = Mock(return_value=mock_result)
|
||||
|
||||
result = builder.build_custom_environment(
|
||||
name="test-env",
|
||||
packages=["numpy>=1.24.0"],
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.image_name == "mcp-forge/custom:test-env"
|
||||
builder.package_validator.validate_packages.assert_called_once()
|
||||
builder.image_builder.build_image.assert_called_once()
|
||||
|
||||
|
||||
def test_build_validates_package_count(builder):
|
||||
"""Test that build validates package count against maximum."""
|
||||
builder.config.max_packages = 10
|
||||
|
||||
packages = [f"package{i}" for i in range(20)]
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds maximum"):
|
||||
builder.build_custom_environment(
|
||||
name="test-env",
|
||||
packages=packages,
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
|
||||
def test_build_validates_packages_with_validator(builder):
|
||||
"""Test that build uses package validator."""
|
||||
builder.package_validator.validate_packages = Mock(
|
||||
side_effect=SecurityError("Blocked package")
|
||||
)
|
||||
|
||||
with pytest.raises(SecurityError, match="Blocked package"):
|
||||
builder.build_custom_environment(
|
||||
name="test-env",
|
||||
packages=["forbidden-package"],
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
|
||||
def test_build_enforces_rate_limit(builder):
|
||||
"""Test that build enforces rate limiting."""
|
||||
builder.package_validator.validate_packages = Mock()
|
||||
|
||||
# Exhaust rate limit
|
||||
for _ in range(5):
|
||||
builder.rate_limiter.check_rate_limit("user123")
|
||||
|
||||
# Next request should fail
|
||||
with pytest.raises(RuntimeError, match="Rate limit exceeded"):
|
||||
builder.build_custom_environment(
|
||||
name="test-env",
|
||||
packages=["numpy"],
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
|
||||
def test_build_enforces_concurrent_builds_limit(builder, tmp_path):
|
||||
"""Test that build enforces concurrent builds limit."""
|
||||
builder.config.max_concurrent_builds = 2
|
||||
builder.package_validator.validate_packages = Mock()
|
||||
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
||||
|
||||
# Simulate 2 active builds
|
||||
builder.active_builds.add("build1")
|
||||
builder.active_builds.add("build2")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Maximum concurrent builds"):
|
||||
builder.build_custom_environment(
|
||||
name="test-env",
|
||||
packages=["numpy"],
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
|
||||
def test_build_cleans_up_build_context(builder, tmp_path):
|
||||
"""Test that build cleans up build context even on failure."""
|
||||
builder.package_validator.validate_packages = Mock()
|
||||
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
||||
builder.image_builder.build_image = Mock(side_effect=RuntimeError("Build failed"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="Build failed"):
|
||||
builder.build_custom_environment(
|
||||
name="test-env",
|
||||
packages=["numpy"],
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
# Build context should have been registered for cleanup
|
||||
# (actual cleanup would happen in finally block)
|
||||
|
||||
|
||||
def test_build_validates_environment_name(builder):
|
||||
"""Test that build validates environment name format."""
|
||||
with pytest.raises(ValueError, match="must contain only alphanumeric"):
|
||||
builder.build_custom_environment(
|
||||
name="test env!", # Invalid: spaces and special chars
|
||||
packages=["numpy"],
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
|
||||
def test_build_from_template_expands_packages(builder, tmp_path):
|
||||
"""Test building from template expands package list."""
|
||||
builder.package_validator.validate_packages = Mock()
|
||||
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
||||
|
||||
mock_result = BuildResult(
|
||||
success=True,
|
||||
image_name="mcp-forge/custom:data-sci",
|
||||
image_id="sha256:abc123",
|
||||
build_time=45.2,
|
||||
size_bytes=500_000_000,
|
||||
cache_hit=False,
|
||||
installed_packages=["numpy==1.24.0", "pandas==2.0.0", "matplotlib==3.7.0"]
|
||||
)
|
||||
builder.image_builder.build_image = Mock(return_value=mock_result)
|
||||
|
||||
result = builder.build_from_template(
|
||||
template_name="data-science",
|
||||
name="data-sci",
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
# Should have called validator with template packages
|
||||
builder.package_validator.validate_packages.assert_called_once()
|
||||
call_args = builder.package_validator.validate_packages.call_args[0][0]
|
||||
assert "numpy" in call_args
|
||||
assert "pandas" in call_args
|
||||
assert "matplotlib" in call_args
|
||||
|
||||
|
||||
def test_build_from_template_with_additional_packages(builder, tmp_path):
|
||||
"""Test building from template with additional packages."""
|
||||
builder.package_validator.validate_packages = Mock()
|
||||
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
||||
|
||||
mock_result = BuildResult(
|
||||
success=True,
|
||||
image_name="mcp-forge/custom:data-sci",
|
||||
image_id="sha256:abc123",
|
||||
build_time=45.2,
|
||||
size_bytes=500_000_000,
|
||||
cache_hit=False,
|
||||
installed_packages=["numpy==1.24.0", "pandas==2.0.0", "matplotlib==3.7.0", "scipy==1.10.0"]
|
||||
)
|
||||
builder.image_builder.build_image = Mock(return_value=mock_result)
|
||||
|
||||
result = builder.build_from_template(
|
||||
template_name="data-science",
|
||||
additional_packages=["scipy"],
|
||||
name="data-sci",
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
call_args = builder.package_validator.validate_packages.call_args[0][0]
|
||||
assert "scipy" in call_args
|
||||
|
||||
|
||||
def test_build_from_template_validates_template_exists(builder):
|
||||
"""Test that template build validates template exists."""
|
||||
with pytest.raises(ValueError, match="Template.*not found"):
|
||||
builder.build_from_template(
|
||||
template_name="nonexistent",
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
|
||||
def test_list_templates_returns_config_templates(builder):
|
||||
"""Test that list_templates returns configured templates."""
|
||||
templates = builder.list_templates()
|
||||
|
||||
assert "data-science" in templates
|
||||
assert templates["data-science"]["packages"] == ["numpy", "pandas", "matplotlib"]
|
||||
|
||||
|
||||
def test_rate_limiter_allows_within_limit():
|
||||
"""Test that rate limiter allows requests within limit."""
|
||||
limiter = BuildRateLimiter(max_requests=5, period_seconds=3600)
|
||||
|
||||
# Should allow 5 requests
|
||||
for _ in range(5):
|
||||
limiter.check_rate_limit("user123")
|
||||
|
||||
# 6th request should fail
|
||||
with pytest.raises(RuntimeError, match="Rate limit exceeded"):
|
||||
limiter.check_rate_limit("user123")
|
||||
|
||||
|
||||
def test_rate_limiter_cleans_up_old_requests():
|
||||
"""Test that rate limiter cleans up old requests."""
|
||||
limiter = BuildRateLimiter(max_requests=5, period_seconds=1)
|
||||
|
||||
# Make 5 requests
|
||||
for _ in range(5):
|
||||
limiter.check_rate_limit("user123")
|
||||
|
||||
# Wait for period to expire
|
||||
import time
|
||||
time.sleep(1.1)
|
||||
|
||||
# Should allow new request after period
|
||||
limiter.check_rate_limit("user123")
|
||||
|
||||
|
||||
def test_rate_limiter_tracks_per_user():
|
||||
"""Test that rate limiter tracks requests per user."""
|
||||
limiter = BuildRateLimiter(max_requests=2, period_seconds=3600)
|
||||
|
||||
# User1 makes 2 requests
|
||||
limiter.check_rate_limit("user1")
|
||||
limiter.check_rate_limit("user1")
|
||||
|
||||
# User2 should still be allowed
|
||||
limiter.check_rate_limit("user2")
|
||||
limiter.check_rate_limit("user2")
|
||||
|
||||
# Both users now at limit
|
||||
with pytest.raises(RuntimeError):
|
||||
limiter.check_rate_limit("user1")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
limiter.check_rate_limit("user2")
|
||||
|
||||
|
||||
def test_build_registers_and_unregisters_active_builds(builder, tmp_path):
|
||||
"""Test that builds are registered and unregistered correctly."""
|
||||
builder.package_validator.validate_packages = Mock()
|
||||
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
||||
|
||||
mock_result = BuildResult(
|
||||
success=True,
|
||||
image_name="mcp-forge/custom:test-env",
|
||||
image_id="sha256:abc123",
|
||||
build_time=45.2,
|
||||
size_bytes=500_000_000,
|
||||
cache_hit=False,
|
||||
installed_packages=["numpy==1.24.0"]
|
||||
)
|
||||
builder.image_builder.build_image = Mock(return_value=mock_result)
|
||||
|
||||
# Before build
|
||||
assert "test-env" not in builder.active_builds
|
||||
|
||||
builder.build_custom_environment(
|
||||
name="test-env",
|
||||
packages=["numpy"],
|
||||
user_id="user123"
|
||||
)
|
||||
|
||||
# After build
|
||||
assert "test-env" not in builder.active_builds
|
||||
355
tests/builder/test_image_builder.py
Normal file
355
tests/builder/test_image_builder.py
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
"""Tests for Image Builder module."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from mcp_forge.builder.image_builder import ImageBuilder, BuildResult
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_podman_client():
|
||||
"""Mock PodmanClient."""
|
||||
mock = Mock(spec=PodmanClient)
|
||||
# Configure nested mocks for images and containers
|
||||
mock.images = Mock()
|
||||
mock.containers = Mock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder_config():
|
||||
"""Mock EnvironmentBuilderConfig."""
|
||||
config = Mock(spec=EnvironmentBuilderConfig)
|
||||
config.enabled = True
|
||||
config.uv_cache_path = Path("/tmp/uv_cache")
|
||||
config.max_packages_per_build = 50
|
||||
config.build_timeout = 600
|
||||
config.max_build_timeout = 1800
|
||||
config.max_image_size = "2g"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_audit_logger():
|
||||
"""Mock AuditLogger."""
|
||||
return Mock(spec=AuditLogger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder(mock_podman_client, builder_config, mock_audit_logger):
|
||||
"""ImageBuilder instance."""
|
||||
return ImageBuilder(mock_podman_client, builder_config, mock_audit_logger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_context(tmp_path):
|
||||
"""Create temporary build context."""
|
||||
context = tmp_path / "build_context"
|
||||
context.mkdir()
|
||||
|
||||
# Create Containerfile
|
||||
containerfile = context / "Containerfile"
|
||||
containerfile.write_text("FROM python:3.11-slim\n")
|
||||
|
||||
# Create requirements.txt
|
||||
requirements = context / "requirements.txt"
|
||||
requirements.write_text("numpy>=1.24.0\npandas\n")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def test_build_image_success(builder, build_context, mock_podman_client):
|
||||
"""Test successful image build."""
|
||||
# Mock successful build
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 500_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image # Mock images.get() call
|
||||
|
||||
# Mock pip list output
|
||||
mock_container = Mock()
|
||||
mock_container.exec_run.return_value = (
|
||||
0,
|
||||
b'[{"name": "numpy", "version": "1.24.0"}, {"name": "pandas", "version": "2.0.0"}]'
|
||||
)
|
||||
mock_podman_client.containers.run.return_value = mock_container
|
||||
|
||||
packages = ["numpy>=1.24.0", "pandas"]
|
||||
result = builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=packages
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.image_name == "mcp-forge/custom:test-env"
|
||||
assert result.image_id == "sha256:abc123"
|
||||
assert result.size_bytes == 500_000_000
|
||||
assert len(result.installed_packages) == 2
|
||||
|
||||
|
||||
def test_build_image_with_custom_timeout(builder, build_context, mock_podman_client):
|
||||
"""Test build with custom timeout."""
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 100_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
|
||||
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[],
|
||||
timeout=1200
|
||||
)
|
||||
|
||||
# Verify timeout was passed to build
|
||||
call_kwargs = mock_podman_client.images.build.call_args[1]
|
||||
assert call_kwargs["timeout"] == 1200
|
||||
|
||||
|
||||
def test_build_image_validates_timeout_against_max(builder, build_context, builder_config):
|
||||
"""Test that build validates timeout against maximum."""
|
||||
builder_config.max_build_timeout = 1800
|
||||
|
||||
with pytest.raises(ValueError, match="Timeout 3600 exceeds maximum"):
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[],
|
||||
timeout=3600
|
||||
)
|
||||
|
||||
|
||||
def test_build_image_uses_default_timeout(builder, build_context, builder_config, mock_podman_client):
|
||||
"""Test that build uses config default timeout when not specified."""
|
||||
builder_config.build_timeout = 600
|
||||
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 100_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
|
||||
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
|
||||
call_kwargs = mock_podman_client.images.build.call_args[1]
|
||||
assert call_kwargs["timeout"] == 600
|
||||
|
||||
|
||||
def test_generate_tag_valid_name(builder):
|
||||
"""Test tag generation with valid name."""
|
||||
tag = builder.generate_tag("my-env-123")
|
||||
assert tag == "mcp-forge/custom:my-env-123"
|
||||
|
||||
|
||||
def test_generate_tag_invalid_name_raises_error(builder):
|
||||
"""Test that invalid names raise ValueError."""
|
||||
with pytest.raises(ValueError, match="alphanumeric"):
|
||||
builder.generate_tag("my env")
|
||||
|
||||
with pytest.raises(ValueError, match="alphanumeric"):
|
||||
builder.generate_tag("my_env!")
|
||||
|
||||
|
||||
def test_validate_image_size_within_limit(builder, mock_podman_client, builder_config):
|
||||
"""Test image size validation passes when within limit."""
|
||||
builder_config.max_image_size = "2g"
|
||||
|
||||
mock_image = Mock()
|
||||
mock_image.attrs = {"Size": 1_000_000_000} # 1GB
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
|
||||
size = builder.validate_image_size("sha256:abc123")
|
||||
|
||||
assert size == 1_000_000_000
|
||||
|
||||
|
||||
def test_validate_image_size_exceeds_limit(builder, mock_podman_client, builder_config):
|
||||
"""Test image size validation fails when exceeds limit."""
|
||||
builder_config.max_image_size = "1g"
|
||||
|
||||
mock_image = Mock()
|
||||
mock_image.attrs = {"Size": 2_000_000_000} # 2GB
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds maximum"):
|
||||
builder.validate_image_size("sha256:abc123")
|
||||
|
||||
|
||||
def test_extract_installed_packages(builder, mock_podman_client):
|
||||
"""Test extracting installed packages from image."""
|
||||
mock_container = Mock()
|
||||
mock_container.exec_run.return_value = (
|
||||
0,
|
||||
b'[{"name": "numpy", "version": "1.24.0"}, {"name": "pandas", "version": "2.0.0"}]'
|
||||
)
|
||||
mock_podman_client.containers.run.return_value = mock_container
|
||||
|
||||
packages = builder.extract_installed_packages("sha256:abc123")
|
||||
|
||||
assert len(packages) == 2
|
||||
assert "numpy==1.24.0" in packages
|
||||
assert "pandas==2.0.0" in packages
|
||||
|
||||
|
||||
def test_extract_installed_packages_handles_error(builder, mock_podman_client):
|
||||
"""Test that package extraction handles errors gracefully."""
|
||||
mock_container = Mock()
|
||||
mock_container.exec_run.return_value = (1, b"Error")
|
||||
mock_podman_client.containers.run.return_value = mock_container
|
||||
|
||||
packages = builder.extract_installed_packages("sha256:abc123")
|
||||
|
||||
assert packages == []
|
||||
|
||||
|
||||
def test_calculate_cache_hash(builder):
|
||||
"""Test cache hash calculation."""
|
||||
packages = ["numpy>=1.24.0", "pandas==2.0.0", "requests"]
|
||||
|
||||
hash1 = builder.calculate_cache_hash(packages)
|
||||
hash2 = builder.calculate_cache_hash(packages)
|
||||
|
||||
# Same packages should produce same hash
|
||||
assert hash1 == hash2
|
||||
|
||||
# Different packages should produce different hash
|
||||
different_packages = ["numpy>=1.24.0", "scipy"]
|
||||
hash3 = builder.calculate_cache_hash(different_packages)
|
||||
assert hash1 != hash3
|
||||
|
||||
|
||||
def test_calculate_cache_hash_order_independent(builder):
|
||||
"""Test that cache hash is order-independent."""
|
||||
packages1 = ["numpy", "pandas", "scipy"]
|
||||
packages2 = ["scipy", "numpy", "pandas"]
|
||||
|
||||
hash1 = builder.calculate_cache_hash(packages1)
|
||||
hash2 = builder.calculate_cache_hash(packages2)
|
||||
|
||||
# Order shouldn't matter
|
||||
assert hash1 == hash2
|
||||
|
||||
|
||||
def test_build_image_logs_audit_event(builder, build_context, mock_podman_client, mock_audit_logger):
|
||||
"""Test that build logs audit event."""
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 100_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
|
||||
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=["numpy"]
|
||||
)
|
||||
|
||||
# Verify audit log was called
|
||||
assert mock_audit_logger.log.called
|
||||
|
||||
|
||||
def test_build_image_returns_build_time(builder, build_context, mock_podman_client):
|
||||
"""Test that build result includes build time."""
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 100_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
|
||||
|
||||
result = builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
|
||||
assert result.build_time > 0
|
||||
|
||||
|
||||
def test_build_image_handles_build_failure(builder, build_context, mock_podman_client):
|
||||
"""Test that build handles Podman build failures."""
|
||||
mock_podman_client.images.build.side_effect = Exception("Build failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Build failed"):
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
|
||||
|
||||
def test_build_result_to_dict(builder):
|
||||
"""Test BuildResult serialization."""
|
||||
result = BuildResult(
|
||||
success=True,
|
||||
image_name="mcp-forge/custom:test",
|
||||
image_id="sha256:abc123",
|
||||
build_time=10.5,
|
||||
size_bytes=500_000_000,
|
||||
cache_hit=False,
|
||||
installed_packages=["numpy==1.24.0", "pandas==2.0.0"]
|
||||
)
|
||||
|
||||
result_dict = result.to_dict()
|
||||
|
||||
assert result_dict["success"] is True
|
||||
assert result_dict["image_name"] == "mcp-forge/custom:test"
|
||||
assert result_dict["image_id"] == "sha256:abc123"
|
||||
assert result_dict["build_time"] == 10.5
|
||||
assert result_dict["size_bytes"] == 500_000_000
|
||||
assert result_dict["cache_hit"] is False
|
||||
assert len(result_dict["installed_packages"]) == 2
|
||||
|
||||
|
||||
def test_build_image_validates_build_context_exists(builder, tmp_path):
|
||||
"""Test that build validates build context exists."""
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
|
||||
with pytest.raises(ValueError, match="Build context does not exist"):
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=nonexistent,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
|
||||
|
||||
def test_build_image_validates_containerfile_exists(builder, tmp_path):
|
||||
"""Test that build validates Containerfile exists."""
|
||||
context = tmp_path / "context"
|
||||
context.mkdir()
|
||||
# No Containerfile created
|
||||
|
||||
with pytest.raises(ValueError, match="Containerfile not found"):
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
271
tests/builder/test_package_validator.py
Normal file
271
tests/builder/test_package_validator.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""Tests for Package Validator module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from mcp_forge.builder.package_validator import (
|
||||
PackageValidator,
|
||||
ApprovalRequiredError,
|
||||
SecurityError
|
||||
)
|
||||
from mcp_forge.config.schema import PackageValidationConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def allowlist_file(tmp_path):
|
||||
"""Create temporary allowlist file."""
|
||||
allowlist = tmp_path / "allowlist.txt"
|
||||
allowlist.write_text("""
|
||||
# Standard data science packages
|
||||
numpy
|
||||
pandas
|
||||
scipy
|
||||
scikit-learn
|
||||
matplotlib
|
||||
|
||||
# Web and API
|
||||
requests
|
||||
httpx
|
||||
aiohttp
|
||||
|
||||
# Utilities
|
||||
pyyaml
|
||||
python-dateutil
|
||||
""".strip())
|
||||
return allowlist
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blocklist_file(tmp_path):
|
||||
"""Create temporary blocklist file."""
|
||||
blocklist = tmp_path / "blocklist.txt"
|
||||
blocklist.write_text("""
|
||||
# Security concerns
|
||||
os-crypto
|
||||
subprocess-wrapper
|
||||
shell-exec
|
||||
|
||||
# Known malicious
|
||||
malicious-package
|
||||
evil-lib
|
||||
""".strip())
|
||||
return blocklist
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validation_config(allowlist_file, blocklist_file, tmp_path):
|
||||
"""Mock PackageValidationConfig."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = True
|
||||
config.allowlist_path = allowlist_file
|
||||
config.blocklist_path = blocklist_file
|
||||
config.require_approval_patterns = [
|
||||
"^torch.*", # PyTorch packages
|
||||
"^tensorflow.*", # TensorFlow packages
|
||||
".*-gpu$", # GPU variants
|
||||
]
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validator(validation_config):
|
||||
"""PackageValidator instance."""
|
||||
return PackageValidator(validation_config)
|
||||
|
||||
|
||||
def test_extract_package_name_simple(validator):
|
||||
"""Test extracting package name from simple spec."""
|
||||
assert validator.extract_package_name("numpy") == "numpy"
|
||||
assert validator.extract_package_name("pandas") == "pandas"
|
||||
|
||||
|
||||
def test_extract_package_name_with_version(validator):
|
||||
"""Test extracting package name with version specifiers."""
|
||||
assert validator.extract_package_name("numpy>=1.24.0") == "numpy"
|
||||
assert validator.extract_package_name("pandas==2.0.0") == "pandas"
|
||||
assert validator.extract_package_name("requests<=2.28.0") == "requests"
|
||||
assert validator.extract_package_name("scikit-learn~=1.3.0") == "scikit-learn"
|
||||
|
||||
|
||||
def test_extract_package_name_with_extras(validator):
|
||||
"""Test extracting package name with extras."""
|
||||
assert validator.extract_package_name("requests[security]") == "requests"
|
||||
assert validator.extract_package_name("pandas[excel,sql]") == "pandas"
|
||||
|
||||
|
||||
def test_extract_package_name_complex(validator):
|
||||
"""Test extracting package name from complex specs."""
|
||||
assert validator.extract_package_name("numpy>=1.24.0,<2.0.0") == "numpy"
|
||||
assert validator.extract_package_name("requests[security]>=2.28.0") == "requests"
|
||||
|
||||
|
||||
def test_allowlisted_package_passes(validator):
|
||||
"""Test that allowlisted packages pass validation."""
|
||||
validator.validate_package("numpy")
|
||||
validator.validate_package("pandas>=2.0.0")
|
||||
validator.validate_package("requests[security]")
|
||||
# Should not raise
|
||||
|
||||
|
||||
def test_blocklisted_package_raises_error(validator):
|
||||
"""Test that blocklisted packages raise SecurityError."""
|
||||
with pytest.raises(SecurityError, match="malicious-package"):
|
||||
validator.validate_package("malicious-package")
|
||||
|
||||
with pytest.raises(SecurityError, match="evil-lib"):
|
||||
validator.validate_package("evil-lib>=1.0.0")
|
||||
|
||||
|
||||
def test_unknown_package_with_allowlist_raises_error(validator):
|
||||
"""Test that unknown packages raise error when allowlist is enabled."""
|
||||
with pytest.raises(SecurityError, match="unknown-package"):
|
||||
validator.validate_package("unknown-package")
|
||||
|
||||
|
||||
def test_package_requiring_approval_raises_error(validator):
|
||||
"""Test that packages matching approval patterns raise ApprovalRequiredError."""
|
||||
with pytest.raises(ApprovalRequiredError, match="torch"):
|
||||
validator.validate_package("torch")
|
||||
|
||||
with pytest.raises(ApprovalRequiredError, match="tensorflow"):
|
||||
validator.validate_package("tensorflow-gpu")
|
||||
|
||||
with pytest.raises(ApprovalRequiredError, match="gpu"):
|
||||
validator.validate_package("cupy-gpu")
|
||||
|
||||
|
||||
def test_validate_packages_list(validator):
|
||||
"""Test validating multiple packages at once."""
|
||||
packages = ["numpy>=1.24.0", "pandas", "requests"]
|
||||
validator.validate_packages(packages)
|
||||
# Should not raise
|
||||
|
||||
|
||||
def test_validate_packages_enforces_max_limit(validator):
|
||||
"""Test that validate_packages enforces maximum package count."""
|
||||
packages = ["numpy", "pandas", "scipy", "matplotlib"]
|
||||
|
||||
with pytest.raises(ValueError, match="Maximum 3 packages"):
|
||||
validator.validate_packages(packages, max_packages=3)
|
||||
|
||||
|
||||
def test_validate_packages_with_mixed_results(validator):
|
||||
"""Test that validation stops at first error."""
|
||||
packages = ["numpy", "malicious-package", "pandas"]
|
||||
|
||||
with pytest.raises(SecurityError, match="malicious-package"):
|
||||
validator.validate_packages(packages)
|
||||
|
||||
|
||||
def test_validate_packages_with_approval_required(validator):
|
||||
"""Test that validation stops at first approval requirement."""
|
||||
packages = ["numpy", "torch", "pandas"]
|
||||
|
||||
with pytest.raises(ApprovalRequiredError, match="torch"):
|
||||
validator.validate_packages(packages)
|
||||
|
||||
|
||||
def test_allowlist_loading(allowlist_file):
|
||||
"""Test that allowlist is loaded correctly from file."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = True
|
||||
config.allowlist_path = allowlist_file
|
||||
config.blocklist_path = None
|
||||
config.require_approval_patterns = []
|
||||
|
||||
validator = PackageValidator(config)
|
||||
|
||||
assert "numpy" in validator.allowlist
|
||||
assert "pandas" in validator.allowlist
|
||||
assert "requests" in validator.allowlist
|
||||
# Comments and empty lines should be ignored
|
||||
assert "# Standard data science packages" not in validator.allowlist
|
||||
|
||||
|
||||
def test_blocklist_loading(blocklist_file):
|
||||
"""Test that blocklist is loaded correctly from file."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = False
|
||||
config.allowlist_path = None
|
||||
config.blocklist_path = blocklist_file
|
||||
config.require_approval_patterns = []
|
||||
|
||||
validator = PackageValidator(config)
|
||||
|
||||
assert "malicious-package" in validator.blocklist
|
||||
assert "evil-lib" in validator.blocklist
|
||||
|
||||
|
||||
def test_disabled_allowlist_allows_all_except_blocklist(blocklist_file):
|
||||
"""Test that disabling allowlist allows any package except blocklisted."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = False
|
||||
config.allowlist_path = None
|
||||
config.blocklist_path = blocklist_file
|
||||
config.require_approval_patterns = []
|
||||
|
||||
validator = PackageValidator(config)
|
||||
|
||||
# Unknown packages should pass
|
||||
validator.validate_package("some-random-package")
|
||||
|
||||
# But blocklisted packages should still fail
|
||||
with pytest.raises(SecurityError, match="malicious-package"):
|
||||
validator.validate_package("malicious-package")
|
||||
|
||||
|
||||
def test_approval_pattern_matching(validator):
|
||||
"""Test that approval patterns match correctly."""
|
||||
# torch* should match
|
||||
with pytest.raises(ApprovalRequiredError):
|
||||
validator.validate_package("torch")
|
||||
|
||||
with pytest.raises(ApprovalRequiredError):
|
||||
validator.validate_package("torchvision")
|
||||
|
||||
# *-gpu$ should match
|
||||
with pytest.raises(ApprovalRequiredError):
|
||||
validator.validate_package("something-gpu")
|
||||
|
||||
|
||||
def test_empty_package_list(validator):
|
||||
"""Test validating empty package list."""
|
||||
validator.validate_packages([])
|
||||
# Should not raise
|
||||
|
||||
|
||||
def test_validate_packages_without_max_limit(validator):
|
||||
"""Test validating many packages without limit."""
|
||||
packages = [f"package{i}" for i in range(100)]
|
||||
|
||||
# Should raise because packages aren't in allowlist
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_packages(packages)
|
||||
|
||||
|
||||
def test_case_sensitivity(validator):
|
||||
"""Test that package names are case-sensitive."""
|
||||
# numpy is in allowlist
|
||||
validator.validate_package("numpy")
|
||||
|
||||
# NumPy (different case) should fail
|
||||
with pytest.raises(SecurityError, match="NumPy"):
|
||||
validator.validate_package("NumPy")
|
||||
|
||||
|
||||
def test_whitespace_handling(validator):
|
||||
"""Test that leading/trailing whitespace is handled."""
|
||||
validator.validate_package(" numpy ")
|
||||
validator.validate_package(" pandas>=2.0.0 ")
|
||||
|
||||
|
||||
def test_file_not_found_handling(tmp_path):
|
||||
"""Test handling of missing allowlist/blocklist files."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = True
|
||||
config.allowlist_path = tmp_path / "nonexistent.txt"
|
||||
config.blocklist_path = None
|
||||
config.require_approval_patterns = []
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
PackageValidator(config)
|
||||
304
tests/builder/test_uv_installer.py
Normal file
304
tests/builder/test_uv_installer.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""Tests for UV Package Installer module."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, mock_open
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from mcp_forge.builder.uv_installer import UVInstaller
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_path(tmp_path):
|
||||
"""Temporary cache directory."""
|
||||
cache_dir = tmp_path / "uv_cache"
|
||||
return cache_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def installer(cache_path):
|
||||
"""UVInstaller instance."""
|
||||
return UVInstaller(cache_path)
|
||||
|
||||
|
||||
def test_init_creates_cache_directory(cache_path):
|
||||
"""Test that __init__ creates cache directory."""
|
||||
assert not cache_path.exists()
|
||||
|
||||
installer = UVInstaller(cache_path)
|
||||
|
||||
assert cache_path.exists()
|
||||
assert cache_path.is_dir()
|
||||
|
||||
|
||||
def test_init_with_existing_cache(cache_path):
|
||||
"""Test initialization with existing cache directory."""
|
||||
cache_path.mkdir(parents=True)
|
||||
|
||||
installer = UVInstaller(cache_path)
|
||||
|
||||
assert cache_path.exists()
|
||||
|
||||
|
||||
def test_generate_requirements_single_package(installer):
|
||||
"""Test generating requirements.txt with single package."""
|
||||
packages = ["numpy>=1.24.0"]
|
||||
|
||||
requirements = installer.generate_requirements(packages)
|
||||
|
||||
assert requirements == "numpy>=1.24.0"
|
||||
|
||||
|
||||
def test_generate_requirements_multiple_packages(installer):
|
||||
"""Test generating requirements.txt with multiple packages."""
|
||||
packages = ["numpy>=1.24.0", "pandas==2.0.0", "requests"]
|
||||
|
||||
requirements = installer.generate_requirements(packages)
|
||||
|
||||
lines = requirements.strip().split('\n')
|
||||
assert len(lines) == 3
|
||||
assert "numpy>=1.24.0" in lines
|
||||
assert "pandas==2.0.0" in lines
|
||||
assert "requests" in lines
|
||||
|
||||
|
||||
def test_generate_requirements_empty_list(installer):
|
||||
"""Test generating requirements.txt with empty list."""
|
||||
packages = []
|
||||
|
||||
requirements = installer.generate_requirements(packages)
|
||||
|
||||
assert requirements == ""
|
||||
|
||||
|
||||
def test_generate_containerfile_structure(installer):
|
||||
"""Test Containerfile has correct structure."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy>=1.24.0", "pandas"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Check key sections are present
|
||||
assert f"FROM {base_image}" in containerfile
|
||||
assert "pip install" in containerfile and "uv" in containerfile
|
||||
assert "COPY" in containerfile and "requirements.txt" in containerfile
|
||||
assert "RUN uv pip install" in containerfile
|
||||
assert "WORKDIR" in containerfile
|
||||
|
||||
|
||||
def test_generate_containerfile_with_python_version(installer):
|
||||
"""Test Containerfile generation with specific Python version."""
|
||||
base_image = "docker.io/python:3.12-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
containerfile = installer.generate_containerfile(
|
||||
base_image, packages, python_version="3.12"
|
||||
)
|
||||
|
||||
assert "3.12" in containerfile or "python:3.12" in base_image
|
||||
|
||||
|
||||
def test_generate_containerfile_uses_requirements(installer):
|
||||
"""Test Containerfile copies and uses requirements.txt."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy", "pandas"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should copy requirements.txt for layer caching
|
||||
assert "COPY" in containerfile and "requirements.txt" in containerfile
|
||||
assert "-r requirements.txt" in containerfile
|
||||
|
||||
|
||||
def test_generate_containerfile_creates_user(installer):
|
||||
"""Test Containerfile creates non-root user."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should create user for security
|
||||
assert "useradd" in containerfile.lower() or "USER" in containerfile
|
||||
|
||||
|
||||
def test_create_build_context_creates_directory(installer):
|
||||
"""Test that build context directory is created."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy", "pandas"]
|
||||
|
||||
context_path = installer.create_build_context(base_image, packages)
|
||||
|
||||
try:
|
||||
assert context_path.exists()
|
||||
assert context_path.is_dir()
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_create_build_context_contains_containerfile(installer):
|
||||
"""Test that build context contains Containerfile."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
context_path = installer.create_build_context(base_image, packages)
|
||||
|
||||
try:
|
||||
containerfile_path = context_path / "Containerfile"
|
||||
assert containerfile_path.exists()
|
||||
|
||||
content = containerfile_path.read_text()
|
||||
assert f"FROM {base_image}" in content
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_create_build_context_contains_requirements(installer):
|
||||
"""Test that build context contains requirements.txt."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy>=1.24.0", "pandas"]
|
||||
|
||||
context_path = installer.create_build_context(base_image, packages)
|
||||
|
||||
try:
|
||||
requirements_path = context_path / "requirements.txt"
|
||||
assert requirements_path.exists()
|
||||
|
||||
content = requirements_path.read_text()
|
||||
assert "numpy>=1.24.0" in content
|
||||
assert "pandas" in content
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_create_build_context_returns_temp_directory(installer):
|
||||
"""Test that build context is in temp directory."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
context_path = installer.create_build_context(base_image, packages)
|
||||
|
||||
try:
|
||||
# Should be in system temp directory
|
||||
temp_dir = Path(tempfile.gettempdir())
|
||||
assert temp_dir in context_path.parents
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_get_cache_volume_mount_returns_dict(installer):
|
||||
"""Test cache volume mount returns proper dict."""
|
||||
mount_config = installer.get_cache_volume_mount()
|
||||
|
||||
assert isinstance(mount_config, dict)
|
||||
assert "bind" in mount_config
|
||||
assert "mode" in mount_config
|
||||
assert mount_config["mode"] == "rw"
|
||||
|
||||
|
||||
def test_get_cache_volume_mount_includes_cache_path(installer, cache_path):
|
||||
"""Test cache volume mount includes cache path."""
|
||||
mount_config = installer.get_cache_volume_mount()
|
||||
|
||||
# The bind target should reference UV cache location
|
||||
assert "bind" in mount_config
|
||||
assert "/cache" in mount_config["bind"] or "uv" in mount_config["bind"].lower()
|
||||
|
||||
|
||||
def test_generate_containerfile_security_practices(installer):
|
||||
"""Test Containerfile follows security best practices."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should not run as root
|
||||
assert "USER" in containerfile
|
||||
|
||||
# Should set working directory
|
||||
assert "WORKDIR" in containerfile
|
||||
|
||||
|
||||
def test_generate_requirements_preserves_version_specs(installer):
|
||||
"""Test that version specifiers are preserved exactly."""
|
||||
packages = [
|
||||
"numpy>=1.24.0,<2.0.0",
|
||||
"pandas==2.0.0",
|
||||
"requests~=2.28.0",
|
||||
"scipy!=1.10.0"
|
||||
]
|
||||
|
||||
requirements = installer.generate_requirements(packages)
|
||||
|
||||
for package in packages:
|
||||
assert package in requirements
|
||||
|
||||
|
||||
def test_generate_containerfile_with_extras(installer):
|
||||
"""Test Containerfile works with package extras."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["requests[security]>=2.28.0", "pandas[excel]"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should handle extras in requirements
|
||||
assert "COPY" in containerfile and "requirements.txt" in containerfile
|
||||
assert "RUN uv pip install" in containerfile
|
||||
|
||||
|
||||
def test_create_build_context_with_custom_python_version(installer):
|
||||
"""Test build context creation with custom Python version."""
|
||||
base_image = "docker.io/python:3.12-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
context_path = installer.create_build_context(
|
||||
base_image, packages, python_version="3.12"
|
||||
)
|
||||
|
||||
try:
|
||||
containerfile = (context_path / "Containerfile").read_text()
|
||||
assert "3.12" in containerfile or "python:3.12" in containerfile
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_generate_containerfile_optimizes_layer_caching(installer):
|
||||
"""Test that Containerfile structure optimizes Docker layer caching."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy", "pandas", "scipy"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
lines = containerfile.split('\n')
|
||||
|
||||
# UV install should come before requirements copy
|
||||
uv_install_idx = None
|
||||
requirements_idx = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if 'uv' in line.lower() and ('pip install' in line.lower() or 'ADD' in line):
|
||||
uv_install_idx = i
|
||||
if 'COPY' in line and 'requirements.txt' in line:
|
||||
requirements_idx = i
|
||||
|
||||
# UV installation should be cached separately
|
||||
assert uv_install_idx is not None
|
||||
# Requirements copy should happen for cache busting
|
||||
assert requirements_idx is not None
|
||||
|
||||
|
||||
def test_empty_packages_list_creates_valid_containerfile(installer):
|
||||
"""Test that empty package list still creates valid Containerfile."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = []
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should still have base structure
|
||||
assert f"FROM {base_image}" in containerfile
|
||||
assert "WORKDIR" in containerfile
|
||||
439
tests/config/test_loader.py
Normal file
439
tests/config/test_loader.py
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
"""
|
||||
Tests for configuration loader module.
|
||||
|
||||
Following TDD approach - these tests are written before implementation.
|
||||
Tests cover all loading and substitution requirements from todo.md section 1.1.2.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from pydantic import ValidationError
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
|
||||
def test_load_from_valid_yaml_file(tmp_path):
|
||||
"""Test loading configuration from a valid YAML file."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 3000
|
||||
podman_socket: "/run/user/1000/podman/podman.sock"
|
||||
|
||||
execution:
|
||||
default_backend: "simple"
|
||||
default_timeout: 300
|
||||
max_timeout: 1800
|
||||
default_memory: "512m"
|
||||
max_memory: "2g"
|
||||
default_cpu_quota: 50000
|
||||
max_cpu_quota: 100000
|
||||
|
||||
images:
|
||||
python_3_11: "mcp-forge/python:3.11"
|
||||
python_3_12: "mcp-forge/python:3.12"
|
||||
jupyter: "mcp-forge/jupyter:latest"
|
||||
auto_pull: true
|
||||
pull_interval: 86400
|
||||
|
||||
sessions:
|
||||
idle_timeout: 3600
|
||||
max_concurrent: 10
|
||||
cleanup_interval: 300
|
||||
|
||||
volumes:
|
||||
base_path: "/mcp-forge/volumes"
|
||||
session_quota: "1g"
|
||||
max_session_quota: "10g"
|
||||
|
||||
security:
|
||||
audit_log: "/var/log/mcp-forge/audit.log"
|
||||
enforce_resource_limits: true
|
||||
allow_network: false
|
||||
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "/var/cache/mcp-forge/uv"
|
||||
max_packages_per_build: 50
|
||||
max_build_time: 600
|
||||
max_image_size: 2147483648
|
||||
max_concurrent_builds: 3
|
||||
build_rate_limit: {}
|
||||
auto_cleanup: {}
|
||||
templates: {}
|
||||
package_validation:
|
||||
use_allowlist: true
|
||||
allowlist_path: "/etc/mcp-forge/allowlist.txt"
|
||||
blocklist_path: "/etc/mcp-forge/blocklist.txt"
|
||||
require_approval_patterns: []
|
||||
|
||||
mcp_tools:
|
||||
git:
|
||||
command: "uvx"
|
||||
args: ["mcp-server-git"]
|
||||
env: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.server.host == "0.0.0.0"
|
||||
assert config.server.port == 3000
|
||||
assert config.execution.default_backend == "simple"
|
||||
|
||||
|
||||
def test_load_from_non_existent_file_raises_file_not_found_error():
|
||||
"""Test that loading from non-existent file raises FileNotFoundError."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_config(Path("/non/existent/config.yaml"))
|
||||
|
||||
|
||||
def test_invalid_yaml_raises_yaml_error(tmp_path):
|
||||
"""Test that invalid YAML syntax raises YAMLError."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
invalid yaml:
|
||||
- unmatched [bracket
|
||||
key without value
|
||||
""")
|
||||
|
||||
with pytest.raises(yaml.YAMLError):
|
||||
load_config(config_file)
|
||||
|
||||
|
||||
def test_environment_variable_substitution_in_strings(tmp_path, monkeypatch):
|
||||
"""Test that ${VAR} is replaced with environment variable value."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
monkeypatch.setenv("TEST_HOST", "test.example.com")
|
||||
monkeypatch.setenv("TEST_PORT", "4000")
|
||||
monkeypatch.setenv("PODMAN_SOCKET", "/run/test/podman.sock")
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "${TEST_HOST}"
|
||||
port: ${TEST_PORT}
|
||||
podman_socket: "${PODMAN_SOCKET}"
|
||||
|
||||
execution:
|
||||
default_backend: "simple"
|
||||
|
||||
images: {}
|
||||
|
||||
sessions: {}
|
||||
|
||||
volumes:
|
||||
base_path: "/volumes"
|
||||
|
||||
security:
|
||||
audit_log: "/audit.log"
|
||||
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "/cache"
|
||||
package_validation:
|
||||
allowlist_path: "/allow.txt"
|
||||
blocklist_path: "/block.txt"
|
||||
require_approval_patterns: []
|
||||
|
||||
mcp_tools: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.server.host == "test.example.com"
|
||||
assert config.server.port == 4000
|
||||
assert str(config.server.podman_socket) == "/run/test/podman.sock"
|
||||
|
||||
|
||||
def test_nested_environment_variable_substitution(tmp_path, monkeypatch):
|
||||
"""Test that environment variable substitution works in nested structures."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
monkeypatch.setenv("AUDIT_LOG_PATH", "/var/log/audit.log")
|
||||
monkeypatch.setenv("UV_CACHE", "/var/cache/uv")
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "localhost"
|
||||
port: 3000
|
||||
podman_socket: "/run/podman.sock"
|
||||
|
||||
execution: {}
|
||||
|
||||
images: {}
|
||||
|
||||
sessions: {}
|
||||
|
||||
volumes:
|
||||
base_path: "/volumes"
|
||||
|
||||
security:
|
||||
audit_log: "${AUDIT_LOG_PATH}"
|
||||
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "${UV_CACHE}"
|
||||
package_validation:
|
||||
allowlist_path: "/allow.txt"
|
||||
blocklist_path: "/block.txt"
|
||||
require_approval_patterns: []
|
||||
|
||||
mcp_tools: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert str(config.security.audit_log) == "/var/log/audit.log"
|
||||
assert str(config.environment_builder.uv_cache_path) == "/var/cache/uv"
|
||||
|
||||
|
||||
def test_missing_environment_variable_raises_clear_error(tmp_path):
|
||||
"""Test that missing environment variable raises error with variable name."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "${MISSING_VAR}"
|
||||
port: 3000
|
||||
podman_socket: "/run/podman.sock"
|
||||
|
||||
execution: {}
|
||||
images: {}
|
||||
sessions: {}
|
||||
volumes:
|
||||
base_path: "/volumes"
|
||||
security:
|
||||
audit_log: "/audit.log"
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "/cache"
|
||||
package_validation:
|
||||
allowlist_path: "/allow.txt"
|
||||
blocklist_path: "/block.txt"
|
||||
require_approval_patterns: []
|
||||
mcp_tools: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_config(config_file)
|
||||
|
||||
assert "MISSING_VAR" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_load_config_from_dict():
|
||||
"""Test loading configuration from dictionary (for testing)."""
|
||||
from mcp_forge.config.loader import load_config_from_dict
|
||||
|
||||
config_dict = {
|
||||
"server": {
|
||||
"host": "localhost",
|
||||
"port": 3000,
|
||||
"podman_socket": "/run/podman.sock"
|
||||
},
|
||||
"execution": {},
|
||||
"images": {},
|
||||
"sessions": {},
|
||||
"volumes": {"base_path": "/volumes"},
|
||||
"security": {"audit_log": "/audit.log"},
|
||||
"environment_builder": {
|
||||
"enabled": True,
|
||||
"uv_cache_path": "/cache",
|
||||
"package_validation": {
|
||||
"allowlist_path": "/allow.txt",
|
||||
"blocklist_path": "/block.txt",
|
||||
"require_approval_patterns": []
|
||||
}
|
||||
},
|
||||
"mcp_tools": {}
|
||||
}
|
||||
|
||||
config = load_config_from_dict(config_dict)
|
||||
|
||||
assert config.server.host == "localhost"
|
||||
assert config.server.port == 3000
|
||||
|
||||
|
||||
def test_load_config_from_dict_with_env_var_substitution(monkeypatch):
|
||||
"""Test that load_config_from_dict also performs env var substitution."""
|
||||
from mcp_forge.config.loader import load_config_from_dict
|
||||
|
||||
monkeypatch.setenv("TEST_HOST", "example.com")
|
||||
|
||||
config_dict = {
|
||||
"server": {
|
||||
"host": "${TEST_HOST}",
|
||||
"port": 3000,
|
||||
"podman_socket": "/run/podman.sock"
|
||||
},
|
||||
"execution": {},
|
||||
"images": {},
|
||||
"sessions": {},
|
||||
"volumes": {"base_path": "/volumes"},
|
||||
"security": {"audit_log": "/audit.log"},
|
||||
"environment_builder": {
|
||||
"enabled": True,
|
||||
"uv_cache_path": "/cache",
|
||||
"package_validation": {
|
||||
"allowlist_path": "/allow.txt",
|
||||
"blocklist_path": "/block.txt",
|
||||
"require_approval_patterns": []
|
||||
}
|
||||
},
|
||||
"mcp_tools": {}
|
||||
}
|
||||
|
||||
config = load_config_from_dict(config_dict)
|
||||
|
||||
assert config.server.host == "example.com"
|
||||
|
||||
|
||||
def test_partial_configuration_uses_defaults(tmp_path):
|
||||
"""Test that missing configuration sections use schema defaults."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "localhost"
|
||||
port: 3000
|
||||
podman_socket: "/run/podman.sock"
|
||||
|
||||
execution: {}
|
||||
|
||||
images: {}
|
||||
|
||||
sessions: {}
|
||||
|
||||
volumes:
|
||||
base_path: "/volumes"
|
||||
|
||||
security:
|
||||
audit_log: "/audit.log"
|
||||
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "/cache"
|
||||
package_validation:
|
||||
allowlist_path: "/allow.txt"
|
||||
blocklist_path: "/block.txt"
|
||||
require_approval_patterns: []
|
||||
|
||||
mcp_tools: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
# Check defaults from ExecutionConfig
|
||||
assert config.execution.default_backend == "simple"
|
||||
assert config.execution.default_timeout == 300
|
||||
assert config.execution.max_timeout == 1800
|
||||
|
||||
# Check defaults from ImageConfig
|
||||
assert config.images.python_3_11 == "mcp-forge/python:3.11"
|
||||
assert config.images.auto_pull is True
|
||||
|
||||
|
||||
def test_substitute_env_vars_recursive():
|
||||
"""Test that substitute_env_vars works recursively on nested structures."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
import os
|
||||
|
||||
os.environ["TEST_VALUE"] = "substituted"
|
||||
|
||||
data = {
|
||||
"simple": "${TEST_VALUE}",
|
||||
"nested": {
|
||||
"deep": "${TEST_VALUE}",
|
||||
"list": ["${TEST_VALUE}", "plain"]
|
||||
}
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
assert result["simple"] == "substituted"
|
||||
assert result["nested"]["deep"] == "substituted"
|
||||
assert result["nested"]["list"][0] == "substituted"
|
||||
assert result["nested"]["list"][1] == "plain"
|
||||
|
||||
|
||||
def test_substitute_env_vars_with_integer_conversion(monkeypatch):
|
||||
"""Test that numeric strings in env vars can be converted to integers."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
|
||||
monkeypatch.setenv("PORT_NUM", "8080")
|
||||
|
||||
data = {
|
||||
"port": "${PORT_NUM}"
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
# Should still be a string after substitution; type conversion handled by Pydantic
|
||||
assert result["port"] == "8080"
|
||||
|
||||
|
||||
def test_no_eval_or_exec_in_substitution():
|
||||
"""Test that no eval() or exec() is used - only safe string substitution."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
import os
|
||||
|
||||
# Try to inject malicious code - should be treated as literal string
|
||||
os.environ["MALICIOUS"] = "__import__('os').system('echo hacked')"
|
||||
|
||||
data = {
|
||||
"value": "${MALICIOUS}"
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
# Should be the literal string, not executed
|
||||
assert result["value"] == "__import__('os').system('echo hacked')"
|
||||
# And it should NOT have been executed (we can't test side effects easily,
|
||||
# but the substitution logic shouldn't use eval/exec)
|
||||
|
||||
|
||||
def test_env_var_with_special_characters(monkeypatch):
|
||||
"""Test that environment variables with special characters are handled correctly."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
|
||||
monkeypatch.setenv("SPECIAL_PATH", "/path/with-dashes_and_underscores/123")
|
||||
|
||||
data = {
|
||||
"path": "${SPECIAL_PATH}"
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
assert result["path"] == "/path/with-dashes_and_underscores/123"
|
||||
|
||||
|
||||
def test_multiple_env_vars_in_single_string(monkeypatch):
|
||||
"""Test that multiple environment variables can be substituted in one string."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
|
||||
monkeypatch.setenv("BASE_PATH", "/opt/mcp-forge")
|
||||
monkeypatch.setenv("SUBDIR", "logs")
|
||||
|
||||
data = {
|
||||
"log_path": "${BASE_PATH}/${SUBDIR}/audit.log"
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
assert result["log_path"] == "/opt/mcp-forge/logs/audit.log"
|
||||
141
tests/config/test_mcp_tool_config.py
Normal file
141
tests/config/test_mcp_tool_config.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Tests for MCPToolConfig schema with HTTP/SSE transport."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from mcp_forge.config.schema import MCPToolConfig
|
||||
|
||||
|
||||
def test_stdio_transport_config_valid():
|
||||
"""Test valid stdio transport configuration."""
|
||||
config = MCPToolConfig(
|
||||
transport="stdio",
|
||||
command="python",
|
||||
args=["-m", "server"],
|
||||
env={"KEY": "value"}
|
||||
)
|
||||
|
||||
assert config.transport == "stdio"
|
||||
assert config.command == "python"
|
||||
assert config.args == ["-m", "server"]
|
||||
assert config.env == {"KEY": "value"}
|
||||
|
||||
|
||||
def test_http_transport_config_valid():
|
||||
"""Test valid HTTP transport configuration."""
|
||||
config = MCPToolConfig(
|
||||
transport="http",
|
||||
url="http://localhost:8006/mcp",
|
||||
headers={"Authorization": "Bearer token"}
|
||||
)
|
||||
|
||||
assert config.transport == "http"
|
||||
assert config.url == "http://localhost:8006/mcp"
|
||||
assert config.headers == {"Authorization": "Bearer token"}
|
||||
|
||||
|
||||
def test_sse_transport_config_valid():
|
||||
"""Test valid SSE transport configuration."""
|
||||
config = MCPToolConfig(
|
||||
transport="sse",
|
||||
url="http://localhost:9000/events",
|
||||
headers={"X-Custom": "value"}
|
||||
)
|
||||
|
||||
assert config.transport == "sse"
|
||||
assert config.url == "http://localhost:9000/events"
|
||||
assert config.headers == {"X-Custom": "value"}
|
||||
|
||||
|
||||
def test_stdio_without_command_invalid():
|
||||
"""Test that stdio transport requires command."""
|
||||
with pytest.raises(ValidationError, match="command is required for stdio transport"):
|
||||
MCPToolConfig(
|
||||
transport="stdio",
|
||||
args=["-m", "server"]
|
||||
)
|
||||
|
||||
|
||||
def test_http_without_url_invalid():
|
||||
"""Test that HTTP transport requires URL."""
|
||||
with pytest.raises(ValidationError, match="url is required for http transport"):
|
||||
MCPToolConfig(
|
||||
transport="http",
|
||||
headers={"Authorization": "Bearer token"}
|
||||
)
|
||||
|
||||
|
||||
def test_sse_without_url_invalid():
|
||||
"""Test that SSE transport requires URL."""
|
||||
with pytest.raises(ValidationError, match="url is required for sse transport"):
|
||||
MCPToolConfig(
|
||||
transport="sse",
|
||||
headers={"X-Custom": "value"}
|
||||
)
|
||||
|
||||
|
||||
def test_default_transport_is_stdio():
|
||||
"""Test that default transport is stdio."""
|
||||
config = MCPToolConfig(
|
||||
command="python",
|
||||
args=["-m", "server"]
|
||||
)
|
||||
|
||||
assert config.transport == "stdio"
|
||||
|
||||
|
||||
def test_http_config_with_empty_headers():
|
||||
"""Test HTTP config with no headers."""
|
||||
config = MCPToolConfig(
|
||||
transport="http",
|
||||
url="http://localhost:8006/mcp"
|
||||
)
|
||||
|
||||
assert config.headers == {}
|
||||
|
||||
|
||||
def test_stdio_config_with_empty_env():
|
||||
"""Test stdio config with no env vars."""
|
||||
config = MCPToolConfig(
|
||||
transport="stdio",
|
||||
command="python"
|
||||
)
|
||||
|
||||
assert config.env == {}
|
||||
assert config.args == []
|
||||
|
||||
|
||||
def test_invalid_transport_type():
|
||||
"""Test that invalid transport type is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
MCPToolConfig(
|
||||
transport="invalid",
|
||||
command="python"
|
||||
)
|
||||
|
||||
|
||||
def test_stdio_config_minimal():
|
||||
"""Test minimal stdio config with just command."""
|
||||
config = MCPToolConfig(
|
||||
command="python"
|
||||
)
|
||||
|
||||
assert config.transport == "stdio"
|
||||
assert config.command == "python"
|
||||
assert config.args == []
|
||||
assert config.env == {}
|
||||
|
||||
|
||||
def test_http_config_with_multiple_headers():
|
||||
"""Test HTTP config with multiple headers."""
|
||||
config = MCPToolConfig(
|
||||
transport="http",
|
||||
url="http://localhost:8006/mcp",
|
||||
headers={
|
||||
"Authorization": "Bearer token123",
|
||||
"X-Custom-Header": "value",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
assert len(config.headers) == 3
|
||||
assert config.headers["Authorization"] == "Bearer token123"
|
||||
403
tests/config/test_schema.py
Normal file
403
tests/config/test_schema.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"""
|
||||
Tests for configuration schema module.
|
||||
|
||||
Following TDD approach - these tests are written before implementation.
|
||||
Tests cover all validation requirements from todo.md section 1.1.1.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
def test_valid_server_config_loads_successfully():
|
||||
"""Test that a valid ServerConfig loads without errors."""
|
||||
from mcp_forge.config.schema import ServerConfig
|
||||
|
||||
config = ServerConfig(
|
||||
host="0.0.0.0",
|
||||
port=3000,
|
||||
podman_socket=Path("/run/user/1000/podman/podman.sock")
|
||||
)
|
||||
|
||||
assert config.host == "0.0.0.0"
|
||||
assert config.port == 3000
|
||||
assert config.podman_socket == Path("/run/user/1000/podman/podman.sock")
|
||||
|
||||
|
||||
def test_server_config_invalid_port_raises_validation_error():
|
||||
"""Test that invalid port numbers raise ValidationError."""
|
||||
from mcp_forge.config.schema import ServerConfig
|
||||
|
||||
# Port too high
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ServerConfig(
|
||||
host="localhost",
|
||||
port=70000,
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
)
|
||||
assert "port" in str(exc_info.value).lower()
|
||||
|
||||
# Port too low
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ServerConfig(
|
||||
host="localhost",
|
||||
port=0,
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
)
|
||||
assert "port" in str(exc_info.value).lower()
|
||||
|
||||
# Negative port
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ServerConfig(
|
||||
host="localhost",
|
||||
port=-1,
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
)
|
||||
assert "port" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_execution_config_defaults_are_applied():
|
||||
"""Test that ExecutionConfig has correct default values."""
|
||||
from mcp_forge.config.schema import ExecutionConfig
|
||||
|
||||
config = ExecutionConfig()
|
||||
|
||||
assert config.default_backend == "simple"
|
||||
assert config.default_timeout == 300
|
||||
assert config.max_timeout == 1800
|
||||
assert config.default_memory == "512m"
|
||||
assert config.max_memory == "2g"
|
||||
assert config.default_cpu_quota == 50000
|
||||
assert config.max_cpu_quota == 100000
|
||||
|
||||
|
||||
def test_execution_config_max_timeout_validation():
|
||||
"""Test that max_timeout must be >= default_timeout."""
|
||||
from mcp_forge.config.schema import ExecutionConfig
|
||||
|
||||
# Valid: max >= default
|
||||
config = ExecutionConfig(default_timeout=300, max_timeout=600)
|
||||
assert config.max_timeout == 600
|
||||
|
||||
# Invalid: max < default
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ExecutionConfig(default_timeout=600, max_timeout=300)
|
||||
assert "max_timeout" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_image_config_defaults():
|
||||
"""Test ImageConfig default values."""
|
||||
from mcp_forge.config.schema import ImageConfig
|
||||
|
||||
config = ImageConfig()
|
||||
|
||||
assert config.python_3_11 == "mcp-forge/python:3.11"
|
||||
assert config.python_3_12 == "mcp-forge/python:3.12"
|
||||
assert config.jupyter == "mcp-forge/jupyter:latest"
|
||||
assert config.auto_pull is True
|
||||
assert config.pull_interval == 86400
|
||||
|
||||
|
||||
def test_session_config_defaults():
|
||||
"""Test SessionConfig default values."""
|
||||
from mcp_forge.config.schema import SessionConfig
|
||||
|
||||
config = SessionConfig()
|
||||
|
||||
assert config.idle_timeout == 3600
|
||||
assert config.max_concurrent == 10
|
||||
assert config.cleanup_interval == 300
|
||||
|
||||
|
||||
def test_volume_config_with_base_path():
|
||||
"""Test VolumeConfig with required base_path."""
|
||||
from mcp_forge.config.schema import VolumeConfig
|
||||
|
||||
config = VolumeConfig(base_path=Path("/mcp-forge/volumes"))
|
||||
|
||||
assert config.base_path == Path("/mcp-forge/volumes")
|
||||
assert config.session_quota == "1g"
|
||||
assert config.max_session_quota == "10g"
|
||||
|
||||
|
||||
def test_security_config_defaults():
|
||||
"""Test SecurityConfig default values."""
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/var/log/mcp-forge/audit.log"))
|
||||
|
||||
assert config.audit_log == Path("/var/log/mcp-forge/audit.log")
|
||||
assert config.enforce_resource_limits is True
|
||||
assert config.allow_network is False
|
||||
|
||||
|
||||
def test_package_validation_config():
|
||||
"""Test PackageValidationConfig structure."""
|
||||
from mcp_forge.config.schema import PackageValidationConfig
|
||||
|
||||
config = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/etc/mcp-forge/allowlist.txt"),
|
||||
blocklist_path=Path("/etc/mcp-forge/blocklist.txt"),
|
||||
require_approval_patterns=["*crypto*", "*network*"]
|
||||
)
|
||||
|
||||
assert config.use_allowlist is True
|
||||
assert config.allowlist_path == Path("/etc/mcp-forge/allowlist.txt")
|
||||
assert config.blocklist_path == Path("/etc/mcp-forge/blocklist.txt")
|
||||
assert "*crypto*" in config.require_approval_patterns
|
||||
|
||||
|
||||
def test_environment_builder_config():
|
||||
"""Test EnvironmentBuilderConfig structure and defaults."""
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig, PackageValidationConfig
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/etc/allowlist.txt"),
|
||||
blocklist_path=Path("/etc/blocklist.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/var/cache/mcp-forge/uv"),
|
||||
package_validation=pkg_validation
|
||||
)
|
||||
|
||||
assert config.enabled is True
|
||||
assert config.uv_cache_path == Path("/var/cache/mcp-forge/uv")
|
||||
assert config.max_packages_per_build == 50
|
||||
assert config.max_build_time == 600
|
||||
assert config.max_image_size == 2147483648
|
||||
assert config.max_concurrent_builds == 3
|
||||
|
||||
|
||||
def test_mcp_tool_config():
|
||||
"""Test MCPToolConfig structure."""
|
||||
from mcp_forge.config.schema import MCPToolConfig
|
||||
|
||||
config = MCPToolConfig(
|
||||
command="uvx",
|
||||
args=["mcp-server-git"],
|
||||
env={"GIT_AUTHOR": "test"}
|
||||
)
|
||||
|
||||
assert config.command == "uvx"
|
||||
assert config.args == ["mcp-server-git"]
|
||||
assert config.env == {"GIT_AUTHOR": "test"}
|
||||
|
||||
|
||||
def test_mcp_tool_config_empty_env_defaults():
|
||||
"""Test MCPToolConfig with empty env defaults to empty dict."""
|
||||
from mcp_forge.config.schema import MCPToolConfig
|
||||
|
||||
config = MCPToolConfig(command="test", args=[])
|
||||
|
||||
assert config.env == {}
|
||||
|
||||
|
||||
def test_forge_config_full_structure():
|
||||
"""Test complete ForgeConfig with all nested structures."""
|
||||
from mcp_forge.config.schema import (
|
||||
ForgeConfig, ServerConfig, ExecutionConfig, ImageConfig,
|
||||
SessionConfig, VolumeConfig, SecurityConfig,
|
||||
EnvironmentBuilderConfig, PackageValidationConfig, MCPToolConfig
|
||||
)
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/etc/allowlist.txt"),
|
||||
blocklist_path=Path("/etc/blocklist.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = ForgeConfig(
|
||||
server=ServerConfig(
|
||||
host="localhost",
|
||||
port=3000,
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
),
|
||||
execution=ExecutionConfig(),
|
||||
images=ImageConfig(),
|
||||
sessions=SessionConfig(),
|
||||
volumes=VolumeConfig(base_path=Path("/mcp-forge/volumes")),
|
||||
security=SecurityConfig(audit_log=Path("/var/log/audit.log")),
|
||||
environment_builder=EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/var/cache/uv"),
|
||||
package_validation=pkg_validation
|
||||
),
|
||||
mcp_tools={
|
||||
"git": MCPToolConfig(command="uvx", args=["mcp-server-git"])
|
||||
}
|
||||
)
|
||||
|
||||
assert config.server.host == "localhost"
|
||||
assert config.execution.default_backend == "simple"
|
||||
assert config.images.python_3_11 == "mcp-forge/python:3.11"
|
||||
assert config.sessions.max_concurrent == 10
|
||||
assert config.volumes.base_path == Path("/mcp-forge/volumes")
|
||||
assert config.security.enforce_resource_limits is True
|
||||
assert config.environment_builder.enabled is True
|
||||
assert "git" in config.mcp_tools
|
||||
|
||||
|
||||
def test_nested_configuration_validation_error_includes_field_path():
|
||||
"""Test that ValidationError for nested config includes full field path."""
|
||||
from mcp_forge.config.schema import ForgeConfig, ServerConfig
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ForgeConfig(
|
||||
server=ServerConfig(
|
||||
host="localhost",
|
||||
port=99999, # Invalid port
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
),
|
||||
execution={},
|
||||
images={},
|
||||
sessions={},
|
||||
volumes={"base_path": "/volumes"},
|
||||
security={"audit_log": "/audit.log"},
|
||||
environment_builder={
|
||||
"uv_cache_path": "/cache",
|
||||
"package_validation": {
|
||||
"allowlist_path": "/allow.txt",
|
||||
"blocklist_path": "/block.txt",
|
||||
"require_approval_patterns": []
|
||||
}
|
||||
},
|
||||
mcp_tools={}
|
||||
)
|
||||
|
||||
error_str = str(exc_info.value)
|
||||
# Should include nested path like "server.port"
|
||||
assert "port" in error_str.lower()
|
||||
|
||||
|
||||
def test_execution_config_backend_literal_validation():
|
||||
"""Test that default_backend only accepts 'simple' or 'jupyter'."""
|
||||
from mcp_forge.config.schema import ExecutionConfig
|
||||
|
||||
# Valid values
|
||||
config1 = ExecutionConfig(default_backend="simple")
|
||||
assert config1.default_backend == "simple"
|
||||
|
||||
config2 = ExecutionConfig(default_backend="jupyter")
|
||||
assert config2.default_backend == "jupyter"
|
||||
|
||||
# Invalid value
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ExecutionConfig(default_backend="invalid")
|
||||
assert "default_backend" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_environment_builder_config_rate_limit_dict():
|
||||
"""Test that EnvironmentBuilderConfig accepts rate_limit dict."""
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig, PackageValidationConfig
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/allow.txt"),
|
||||
blocklist_path=Path("/block.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/cache"),
|
||||
package_validation=pkg_validation,
|
||||
build_rate_limit={"max_requests": 10, "period_seconds": 3600}
|
||||
)
|
||||
|
||||
assert config.build_rate_limit == {"max_requests": 10, "period_seconds": 3600}
|
||||
|
||||
|
||||
def test_environment_builder_config_auto_cleanup_dict():
|
||||
"""Test that EnvironmentBuilderConfig accepts auto_cleanup dict."""
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig, PackageValidationConfig
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/allow.txt"),
|
||||
blocklist_path=Path("/block.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/cache"),
|
||||
package_validation=pkg_validation,
|
||||
auto_cleanup={"enabled": True, "max_age_days": 30}
|
||||
)
|
||||
|
||||
assert config.auto_cleanup == {"enabled": True, "max_age_days": 30}
|
||||
|
||||
|
||||
def test_environment_builder_config_templates_dict():
|
||||
"""Test that EnvironmentBuilderConfig accepts templates dict."""
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig, PackageValidationConfig
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/allow.txt"),
|
||||
blocklist_path=Path("/block.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/cache"),
|
||||
package_validation=pkg_validation,
|
||||
templates={
|
||||
"data-science": {"packages": ["numpy", "pandas", "matplotlib"]},
|
||||
"web": {"packages": ["fastapi", "uvicorn"]}
|
||||
}
|
||||
)
|
||||
|
||||
assert "data-science" in config.templates
|
||||
assert "web" in config.templates
|
||||
assert config.templates["data-science"]["packages"] == ["numpy", "pandas", "matplotlib"]
|
||||
|
||||
|
||||
def test_config_constraint_validation_memory_strings():
|
||||
"""Test that config validates memory strings are comparable."""
|
||||
from mcp_forge.config.schema import ExecutionConfig
|
||||
|
||||
# This should succeed - just testing structure, not actual parsing yet
|
||||
config = ExecutionConfig(
|
||||
default_memory="512m",
|
||||
max_memory="2g"
|
||||
)
|
||||
|
||||
assert config.default_memory == "512m"
|
||||
assert config.max_memory == "2g"
|
||||
|
||||
|
||||
def test_mcp_tool_config_list_of_args():
|
||||
"""Test MCPToolConfig args is a list."""
|
||||
from mcp_forge.config.schema import MCPToolConfig
|
||||
|
||||
config = MCPToolConfig(
|
||||
command="python",
|
||||
args=["-m", "mymodule", "--flag"]
|
||||
)
|
||||
|
||||
assert isinstance(config.args, list)
|
||||
assert config.args == ["-m", "mymodule", "--flag"]
|
||||
|
||||
|
||||
def test_package_validation_require_approval_patterns_list():
|
||||
"""Test PackageValidationConfig require_approval_patterns is a list."""
|
||||
from mcp_forge.config.schema import PackageValidationConfig
|
||||
|
||||
config = PackageValidationConfig(
|
||||
use_allowlist=False,
|
||||
allowlist_path=Path("/allow.txt"),
|
||||
blocklist_path=Path("/block.txt"),
|
||||
require_approval_patterns=["*crypto*", "*security*", "paramiko"]
|
||||
)
|
||||
|
||||
assert isinstance(config.require_approval_patterns, list)
|
||||
assert len(config.require_approval_patterns) == 3
|
||||
0
tests/execution/__init__.py
Normal file
0
tests/execution/__init__.py
Normal file
0
tests/execution/jupyter/__init__.py
Normal file
0
tests/execution/jupyter/__init__.py
Normal file
422
tests/execution/jupyter/test_backend.py
Normal file
422
tests/execution/jupyter/test_backend.py
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
"""Tests for Jupyter Backend module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_forge.execution.jupyter.backend import JupyterBackend
|
||||
from mcp_forge.execution.jupyter.sessions import SessionManager, Session, SessionState
|
||||
from mcp_forge.execution.simple.executor import ExecutionResult
|
||||
from mcp_forge.config.schema import ForgeConfig, ExecutionConfig, ImageConfig, SessionConfig
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Mock ForgeConfig with execution settings."""
|
||||
config = Mock(spec=ForgeConfig)
|
||||
|
||||
# Execution configuration
|
||||
config.execution = Mock(spec=ExecutionConfig)
|
||||
config.execution.default_timeout = 300
|
||||
config.execution.max_timeout = 1800
|
||||
config.execution.default_memory = "512m"
|
||||
config.execution.max_memory = "2g"
|
||||
config.execution.default_cpu_quota = 50000
|
||||
config.execution.max_cpu_quota = 100000
|
||||
|
||||
# Image configuration
|
||||
config.images = Mock(spec=ImageConfig)
|
||||
config.images.jupyter = "mcp-forge/jupyter:latest"
|
||||
|
||||
# Session configuration
|
||||
config.sessions = Mock(spec=SessionConfig)
|
||||
config.sessions.idle_timeout = 3600
|
||||
config.sessions.max_concurrent = 10
|
||||
config.sessions.cleanup_interval = 300
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_container_manager():
|
||||
"""Mock SecureContainerManager."""
|
||||
return Mock(spec=SecureContainerManager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_audit_logger():
|
||||
"""Mock AuditLogger."""
|
||||
return Mock(spec=AuditLogger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_manager():
|
||||
"""Mock SessionManager."""
|
||||
return Mock(spec=SessionManager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(mock_config, mock_container_manager, mock_audit_logger):
|
||||
"""JupyterBackend instance with mocked dependencies."""
|
||||
with patch('mcp_forge.execution.jupyter.backend.SessionManager') as mock_sm_class:
|
||||
mock_session_manager = Mock(spec=SessionManager)
|
||||
mock_sm_class.return_value = mock_session_manager
|
||||
|
||||
backend = JupyterBackend(
|
||||
config=mock_config,
|
||||
container_manager=mock_container_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
backend.session_manager = mock_session_manager
|
||||
|
||||
return backend
|
||||
|
||||
|
||||
def test_backend_initializes_session_manager(mock_config, mock_container_manager, mock_audit_logger):
|
||||
"""Test backend creates SessionManager on initialization."""
|
||||
with patch('mcp_forge.execution.jupyter.backend.SessionManager') as mock_sm_class:
|
||||
with patch('mcp_forge.execution.jupyter.backend.JupyterKernelManager') as mock_km_class:
|
||||
mock_session_manager = Mock(spec=SessionManager)
|
||||
mock_sm_class.return_value = mock_session_manager
|
||||
|
||||
backend = JupyterBackend(
|
||||
config=mock_config,
|
||||
container_manager=mock_container_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
# Verify kernel manager was created
|
||||
mock_km_class.assert_called_once()
|
||||
|
||||
# Verify session manager was created
|
||||
mock_sm_class.assert_called_once()
|
||||
|
||||
|
||||
def test_execute_creates_session_if_not_exists(backend):
|
||||
"""Test execute creates new session if it doesn't exist."""
|
||||
# Mock get_session to raise SessionError (session doesn't exist)
|
||||
from mcp_forge.execution.jupyter.sessions import SessionError
|
||||
backend.session_manager.get_session.side_effect = SessionError("Session not found")
|
||||
|
||||
# Mock create_session
|
||||
mock_session = Mock(spec=Session)
|
||||
backend.session_manager.create_session.return_value = mock_session
|
||||
|
||||
# Mock execute_in_session
|
||||
mock_result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="Hello",
|
||||
stderr="",
|
||||
result="Hello",
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
backend.session_manager.execute_in_session.return_value = mock_result
|
||||
|
||||
result = backend.execute("print('Hello')", session_id="test-session")
|
||||
|
||||
# Verify session was created
|
||||
backend.session_manager.create_session.assert_called_once()
|
||||
|
||||
# Verify execution happened
|
||||
backend.session_manager.execute_in_session.assert_called_once_with(
|
||||
session_id="test-session", code="print('Hello')", timeout=300
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
|
||||
def test_execute_reuses_existing_session(backend):
|
||||
"""Test execute reuses existing session."""
|
||||
# Mock get_session to return existing session
|
||||
mock_session = Mock(spec=Session)
|
||||
backend.session_manager.get_session.return_value = mock_session
|
||||
|
||||
# Mock execute_in_session
|
||||
mock_result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="42",
|
||||
stderr="",
|
||||
result=42,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
backend.session_manager.execute_in_session.return_value = mock_result
|
||||
|
||||
result = backend.execute("21 + 21", session_id="existing-session")
|
||||
|
||||
# Verify session was NOT created
|
||||
backend.session_manager.create_session.assert_not_called()
|
||||
|
||||
# Verify session was checked
|
||||
backend.session_manager.get_session.assert_called_once_with("existing-session")
|
||||
|
||||
# Verify execution happened
|
||||
backend.session_manager.execute_in_session.assert_called_once()
|
||||
|
||||
assert result.result == 42
|
||||
|
||||
|
||||
def test_execute_with_custom_timeout(backend):
|
||||
"""Test execute respects custom timeout parameter."""
|
||||
# Mock existing session
|
||||
mock_session = Mock(spec=Session)
|
||||
backend.session_manager.get_session.return_value = mock_session
|
||||
|
||||
mock_result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
backend.session_manager.execute_in_session.return_value = mock_result
|
||||
|
||||
backend.execute("pass", session_id="test", timeout=600)
|
||||
|
||||
# Verify timeout was passed through
|
||||
backend.session_manager.execute_in_session.assert_called_once_with(
|
||||
session_id="test", code="pass", timeout=600
|
||||
)
|
||||
|
||||
|
||||
def test_execute_with_custom_memory(backend, mock_config):
|
||||
"""Test execute creates session with custom memory limit."""
|
||||
from mcp_forge.execution.jupyter.sessions import SessionError
|
||||
backend.session_manager.get_session.side_effect = SessionError("Not found")
|
||||
|
||||
mock_session = Mock(spec=Session)
|
||||
backend.session_manager.create_session.return_value = mock_session
|
||||
|
||||
mock_result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
backend.session_manager.execute_in_session.return_value = mock_result
|
||||
|
||||
backend.execute("pass", session_id="test", memory="1g")
|
||||
|
||||
# Verify session was created with custom memory
|
||||
backend.session_manager.create_session.assert_called_once()
|
||||
call_args = backend.session_manager.create_session.call_args
|
||||
resource_limits = call_args[1]['resource_limits']
|
||||
assert resource_limits.memory_bytes == 1024 * 1024 * 1024 # 1g in bytes
|
||||
|
||||
|
||||
def test_execute_with_custom_cpu_quota(backend):
|
||||
"""Test execute creates session with custom CPU quota."""
|
||||
from mcp_forge.execution.jupyter.sessions import SessionError
|
||||
backend.session_manager.get_session.side_effect = SessionError("Not found")
|
||||
|
||||
mock_session = Mock(spec=Session)
|
||||
backend.session_manager.create_session.return_value = mock_session
|
||||
|
||||
mock_result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
backend.session_manager.execute_in_session.return_value = mock_result
|
||||
|
||||
backend.execute("pass", session_id="test", cpu_quota=75000)
|
||||
|
||||
# Verify session was created with custom CPU quota
|
||||
backend.session_manager.create_session.assert_called_once()
|
||||
call_args = backend.session_manager.create_session.call_args
|
||||
resource_limits = call_args[1]['resource_limits']
|
||||
assert resource_limits.cpu_quota == 75000
|
||||
|
||||
|
||||
def test_execute_validates_timeout_against_max(backend, mock_config):
|
||||
"""Test execute rejects timeout exceeding maximum."""
|
||||
mock_config.execution.max_timeout = 1800
|
||||
|
||||
with pytest.raises(ValueError, match="Timeout 3600 exceeds maximum"):
|
||||
backend.execute("pass", session_id="test", timeout=3600)
|
||||
|
||||
|
||||
def test_execute_validates_memory_against_max(backend, mock_config):
|
||||
"""Test execute rejects memory exceeding maximum."""
|
||||
mock_config.execution.max_memory = "2g"
|
||||
|
||||
with pytest.raises(ValueError, match="Memory 4g exceeds maximum"):
|
||||
backend.execute("pass", session_id="test", memory="4g")
|
||||
|
||||
|
||||
def test_execute_validates_cpu_quota_against_max(backend, mock_config):
|
||||
"""Test execute rejects CPU quota exceeding maximum."""
|
||||
mock_config.execution.max_cpu_quota = 100000
|
||||
|
||||
with pytest.raises(ValueError, match="CPU quota 150000 exceeds maximum"):
|
||||
backend.execute("pass", session_id="test", cpu_quota=150000)
|
||||
|
||||
|
||||
def test_execute_with_volumes(backend):
|
||||
"""Test execute passes volumes to session creation."""
|
||||
from mcp_forge.execution.jupyter.sessions import SessionError
|
||||
backend.session_manager.get_session.side_effect = SessionError("Not found")
|
||||
|
||||
mock_session = Mock(spec=Session)
|
||||
backend.session_manager.create_session.return_value = mock_session
|
||||
|
||||
mock_result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
backend.session_manager.execute_in_session.return_value = mock_result
|
||||
|
||||
volumes = {"/host/path": {"bind": "/container/path", "mode": "ro"}}
|
||||
backend.execute("pass", session_id="test", volumes=volumes)
|
||||
|
||||
# Verify volumes were passed to create_session
|
||||
backend.session_manager.create_session.assert_called_once()
|
||||
call_args = backend.session_manager.create_session.call_args
|
||||
assert call_args[1]['volumes'] == volumes
|
||||
|
||||
|
||||
def test_document_state(backend):
|
||||
"""Test document_state delegates to session manager."""
|
||||
variables = {"x": "Input data", "y": "Output result"}
|
||||
note = "Initial data load"
|
||||
|
||||
backend.document_state("test-session", variables, note=note, clear=False)
|
||||
|
||||
backend.session_manager.document_state.assert_called_once_with(
|
||||
session_id="test-session", variables=variables, note=note, clear=False
|
||||
)
|
||||
|
||||
|
||||
def test_document_state_with_clear(backend):
|
||||
"""Test document_state with clear flag."""
|
||||
variables = {"new_var": "New data"}
|
||||
|
||||
backend.document_state("test-session", variables, clear=True)
|
||||
|
||||
backend.session_manager.document_state.assert_called_once_with(
|
||||
session_id="test-session", variables=variables, note="", clear=True
|
||||
)
|
||||
|
||||
|
||||
def test_get_session_state(backend):
|
||||
"""Test get_session_state delegates to session manager."""
|
||||
mock_state = Mock(spec=SessionState)
|
||||
backend.session_manager.get_session_state.return_value = mock_state
|
||||
|
||||
state = backend.get_session_state("test-session")
|
||||
|
||||
backend.session_manager.get_session_state.assert_called_once_with("test-session")
|
||||
assert state == mock_state
|
||||
|
||||
|
||||
def test_destroy_session(backend):
|
||||
"""Test destroy_session delegates to session manager."""
|
||||
backend.destroy_session("test-session")
|
||||
|
||||
backend.session_manager.destroy_session.assert_called_once_with("test-session")
|
||||
|
||||
|
||||
def test_list_sessions(backend):
|
||||
"""Test list_sessions delegates to session manager."""
|
||||
mock_sessions = [
|
||||
{"session_id": "session1", "kernel_id": "kernel1"},
|
||||
{"session_id": "session2", "kernel_id": "kernel2"}
|
||||
]
|
||||
backend.session_manager.list_sessions.return_value = mock_sessions
|
||||
|
||||
sessions = backend.list_sessions()
|
||||
|
||||
backend.session_manager.list_sessions.assert_called_once()
|
||||
assert sessions == mock_sessions
|
||||
|
||||
|
||||
def test_cleanup_idle_sessions(backend):
|
||||
"""Test cleanup_idle_sessions delegates to session manager."""
|
||||
backend.session_manager.cleanup_idle_sessions.return_value = 2
|
||||
|
||||
count = backend.cleanup_idle_sessions()
|
||||
|
||||
backend.session_manager.cleanup_idle_sessions.assert_called_once()
|
||||
assert count == 2
|
||||
|
||||
|
||||
def test_default_resource_limits_from_config(mock_config, mock_container_manager, mock_audit_logger):
|
||||
"""Test _default_resource_limits creates limits from config."""
|
||||
with patch('mcp_forge.execution.jupyter.backend.SessionManager'):
|
||||
backend = JupyterBackend(
|
||||
config=mock_config,
|
||||
container_manager=mock_container_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
limits = backend._default_resource_limits()
|
||||
|
||||
assert limits.memory_bytes == 512 * 1024 * 1024 # 512m
|
||||
assert limits.cpu_quota == 50000
|
||||
assert limits.timeout == 300
|
||||
|
||||
|
||||
def test_execute_logs_audit_event(backend):
|
||||
"""Test execute logs audit event."""
|
||||
# Mock existing session
|
||||
mock_session = Mock(spec=Session)
|
||||
backend.session_manager.get_session.return_value = mock_session
|
||||
|
||||
mock_result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
backend.session_manager.execute_in_session.return_value = mock_result
|
||||
|
||||
backend.execute("print('test')", session_id="test-session")
|
||||
|
||||
# Verify audit log was called
|
||||
backend.audit_logger.log.assert_called()
|
||||
|
||||
|
||||
def test_execute_with_defaults_uses_config_values(backend, mock_config):
|
||||
"""Test execute without parameters uses config defaults."""
|
||||
from mcp_forge.execution.jupyter.sessions import SessionError
|
||||
backend.session_manager.get_session.side_effect = SessionError("Not found")
|
||||
|
||||
mock_session = Mock(spec=Session)
|
||||
backend.session_manager.create_session.return_value = mock_session
|
||||
|
||||
mock_result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
backend.session_manager.execute_in_session.return_value = mock_result
|
||||
|
||||
backend.execute("pass", session_id="test")
|
||||
|
||||
# Verify default values from config were used
|
||||
call_args = backend.session_manager.create_session.call_args
|
||||
resource_limits = call_args[1]['resource_limits']
|
||||
assert resource_limits.memory_bytes == 512 * 1024 * 1024
|
||||
assert resource_limits.cpu_quota == 50000
|
||||
|
||||
call_args = backend.session_manager.execute_in_session.call_args
|
||||
assert call_args[1]['timeout'] == 300
|
||||
325
tests/execution/jupyter/test_kernel.py
Normal file
325
tests/execution/jupyter/test_kernel.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"""Tests for the Jupyter Kernel Manager module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_forge.execution.jupyter.kernel import (
|
||||
JupyterKernelManager,
|
||||
KernelInfo,
|
||||
KernelError
|
||||
)
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
from mcp_forge.execution.simple.executor import ExecutionResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_limits():
|
||||
"""Standard resource limits for testing."""
|
||||
return ResourceLimits(
|
||||
memory="512m",
|
||||
cpu_quota=50000,
|
||||
storage="1g",
|
||||
timeout=300
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_container_manager():
|
||||
"""Mock SecureContainerManager."""
|
||||
manager = Mock(spec=SecureContainerManager)
|
||||
manager.create_container.return_value = "test-container-123"
|
||||
manager.start_container.return_value = None
|
||||
manager.stop_container.return_value = None
|
||||
manager.remove_container.return_value = None
|
||||
manager.get_container_logs.return_value = ("", "")
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kernel_manager(mock_container_manager, resource_limits):
|
||||
"""JupyterKernelManager instance with mocked dependencies."""
|
||||
return JupyterKernelManager(
|
||||
container_manager=mock_container_manager,
|
||||
image="mcp-forge/jupyter:latest",
|
||||
resource_limits=resource_limits
|
||||
)
|
||||
|
||||
|
||||
def test_start_kernel_creates_container(kernel_manager, mock_container_manager):
|
||||
"""Test start_kernel creates and starts a container."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
assert kernel_id is not None
|
||||
assert kernel_id.startswith("kernel-")
|
||||
|
||||
# Verify container was created and started
|
||||
mock_container_manager.create_container.assert_called_once()
|
||||
mock_container_manager.start_container.assert_called_once()
|
||||
|
||||
|
||||
def test_start_kernel_returns_kernel_info(kernel_manager):
|
||||
"""Test start_kernel returns valid kernel info."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
# Kernel should be registered
|
||||
assert kernel_id in kernel_manager.kernels
|
||||
|
||||
kernel_info = kernel_manager.kernels[kernel_id]
|
||||
assert kernel_info.kernel_id == kernel_id
|
||||
assert kernel_info.container_id == "test-container-123"
|
||||
assert kernel_info.session_id == "session-1"
|
||||
assert isinstance(kernel_info.started_at, datetime)
|
||||
|
||||
|
||||
def test_execute_code_in_kernel_returns_result(kernel_manager):
|
||||
"""Test execute_code runs code and returns result."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
result = kernel_manager.execute_code(kernel_id, "2 + 2")
|
||||
|
||||
assert isinstance(result, ExecutionResult)
|
||||
assert result.success is True
|
||||
|
||||
|
||||
def test_execute_code_with_nonexistent_kernel_raises_error(kernel_manager):
|
||||
"""Test execute_code raises error for nonexistent kernel."""
|
||||
with pytest.raises(KernelError, match="Kernel.*not found"):
|
||||
kernel_manager.execute_code("nonexistent-kernel", "pass")
|
||||
|
||||
|
||||
def test_execute_code_preserves_namespace(kernel_manager):
|
||||
"""Test namespace persists between executions."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
# Set a variable
|
||||
result1 = kernel_manager.execute_code(kernel_id, "x = 42")
|
||||
assert result1.success is True
|
||||
|
||||
# Access the variable
|
||||
result2 = kernel_manager.execute_code(kernel_id, "x")
|
||||
assert result2.success is True
|
||||
# In real implementation, result2.result would be 42
|
||||
|
||||
|
||||
def test_shutdown_kernel_removes_container(kernel_manager, mock_container_manager):
|
||||
"""Test shutdown_kernel cleans up container."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
kernel_manager.shutdown_kernel(kernel_id)
|
||||
|
||||
# Verify container was stopped and removed
|
||||
mock_container_manager.stop_container.assert_called_once_with("test-container-123", timeout=10)
|
||||
mock_container_manager.remove_container.assert_called_once_with("test-container-123")
|
||||
|
||||
# Kernel should be removed from registry
|
||||
assert kernel_id not in kernel_manager.kernels
|
||||
|
||||
|
||||
def test_shutdown_nonexistent_kernel_raises_error(kernel_manager):
|
||||
"""Test shutdown_kernel raises error for nonexistent kernel."""
|
||||
with pytest.raises(KernelError, match="Kernel.*not found"):
|
||||
kernel_manager.shutdown_kernel("nonexistent-kernel")
|
||||
|
||||
|
||||
def test_inspect_namespace_returns_variables(kernel_manager):
|
||||
"""Test inspect_namespace returns list of variables."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
# Execute some code to create variables
|
||||
kernel_manager.execute_code(kernel_id, "x = 1; y = 2; z = 3")
|
||||
|
||||
variables = kernel_manager.inspect_namespace(kernel_id)
|
||||
|
||||
assert isinstance(variables, list)
|
||||
# In real implementation, would contain ['x', 'y', 'z']
|
||||
|
||||
|
||||
def test_inspect_namespace_filters_private_vars(kernel_manager):
|
||||
"""Test inspect_namespace filters out private variables."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
kernel_manager.execute_code(kernel_id, "x = 1; _private = 2; __dunder__ = 3")
|
||||
|
||||
variables = kernel_manager.inspect_namespace(kernel_id)
|
||||
|
||||
# Private variables should be filtered
|
||||
# In real implementation: assert '_private' not in variables
|
||||
|
||||
|
||||
def test_get_variable_info_returns_metadata(kernel_manager):
|
||||
"""Test get_variable_info returns variable metadata."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
kernel_manager.execute_code(kernel_id, "x = [1, 2, 3, 4, 5]")
|
||||
|
||||
info = kernel_manager.get_variable_info(kernel_id, "x")
|
||||
|
||||
assert isinstance(info, dict)
|
||||
assert "type" in info
|
||||
# In real implementation: assert info["type"] == "list"
|
||||
|
||||
|
||||
def test_restart_kernel_resets_namespace(kernel_manager, mock_container_manager):
|
||||
"""Test restart_kernel resets namespace but keeps container."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
original_container_id = kernel_manager.kernels[kernel_id].container_id
|
||||
|
||||
# Set a variable
|
||||
kernel_manager.execute_code(kernel_id, "x = 42")
|
||||
|
||||
# Restart
|
||||
kernel_manager.restart_kernel(kernel_id)
|
||||
|
||||
# Container should be the same
|
||||
assert kernel_manager.kernels[kernel_id].container_id == original_container_id
|
||||
|
||||
# Namespace should be reset (variable no longer accessible)
|
||||
# In real implementation, executing "x" would raise NameError
|
||||
|
||||
|
||||
def test_cleanup_idle_kernels_removes_old_kernels(kernel_manager, mock_container_manager):
|
||||
"""Test cleanup_idle_kernels removes kernels idle too long."""
|
||||
# Start two kernels
|
||||
kernel1 = kernel_manager.start_kernel("session-1")
|
||||
kernel2 = kernel_manager.start_kernel("session-2")
|
||||
|
||||
# Make kernel1 appear old
|
||||
kernel_manager.kernels[kernel1].last_activity = datetime.utcnow() - timedelta(hours=2)
|
||||
|
||||
# Cleanup kernels idle > 1 hour
|
||||
count = kernel_manager.cleanup_idle_kernels(timedelta(hours=1))
|
||||
|
||||
assert count == 1
|
||||
assert kernel1 not in kernel_manager.kernels
|
||||
assert kernel2 in kernel_manager.kernels
|
||||
|
||||
|
||||
def test_kernel_with_volumes(kernel_manager, mock_container_manager):
|
||||
"""Test kernel can be started with volume mounts."""
|
||||
volumes = {
|
||||
"/mcp-forge/sessions/session-1/workspace": {"bind": "/workspace", "mode": "rw"}
|
||||
}
|
||||
|
||||
kernel_id = kernel_manager.start_kernel("session-1", volumes=volumes)
|
||||
|
||||
assert kernel_id is not None
|
||||
# Verify volumes were passed to container creation
|
||||
call_args = mock_container_manager.create_container.call_args
|
||||
# In real implementation, would verify volumes in ContainerConfig
|
||||
|
||||
|
||||
def test_execute_code_with_timeout(kernel_manager):
|
||||
"""Test execute_code respects timeout parameter."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
# Execute with custom timeout
|
||||
result = kernel_manager.execute_code(kernel_id, "import time; time.sleep(0.1)", timeout=10)
|
||||
|
||||
assert isinstance(result, ExecutionResult)
|
||||
|
||||
|
||||
def test_execute_code_handles_syntax_error(kernel_manager):
|
||||
"""Test execute_code handles syntax errors gracefully."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
result = kernel_manager.execute_code(kernel_id, "def foo( :")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "SyntaxError" in result.error or "syntax" in result.error.lower()
|
||||
|
||||
|
||||
def test_execute_code_handles_runtime_error(kernel_manager):
|
||||
"""Test execute_code handles runtime errors gracefully."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
result = kernel_manager.execute_code(kernel_id, "1 / 0")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
|
||||
|
||||
def test_execute_code_captures_stdout(kernel_manager):
|
||||
"""Test execute_code captures stdout output."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
result = kernel_manager.execute_code(kernel_id, 'print("Hello, World!")')
|
||||
|
||||
assert result.success is True
|
||||
# In real implementation: assert "Hello, World!" in result.stdout
|
||||
|
||||
|
||||
def test_execute_code_captures_stderr(kernel_manager):
|
||||
"""Test execute_code captures stderr output."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
result = kernel_manager.execute_code(kernel_id, 'import sys; print("warning", file=sys.stderr)')
|
||||
|
||||
assert result.success is True
|
||||
# In real implementation: assert "warning" in result.stderr
|
||||
|
||||
|
||||
def test_multiple_kernels_are_isolated(kernel_manager):
|
||||
"""Test multiple kernels have isolated namespaces."""
|
||||
kernel1 = kernel_manager.start_kernel("session-1")
|
||||
kernel2 = kernel_manager.start_kernel("session-2")
|
||||
|
||||
# Set variable in kernel1
|
||||
kernel_manager.execute_code(kernel1, "x = 1")
|
||||
|
||||
# Set different value in kernel2
|
||||
kernel_manager.execute_code(kernel2, "x = 2")
|
||||
|
||||
# Values should be independent
|
||||
result1 = kernel_manager.execute_code(kernel1, "x")
|
||||
result2 = kernel_manager.execute_code(kernel2, "x")
|
||||
|
||||
# In real implementation: verify result1.result == 1 and result2.result == 2
|
||||
|
||||
|
||||
def test_kernel_info_to_dict(resource_limits):
|
||||
"""Test KernelInfo.to_dict() serialization."""
|
||||
now = datetime.utcnow()
|
||||
kernel_info = KernelInfo(
|
||||
kernel_id="kernel-123",
|
||||
container_id="container-456",
|
||||
session_id="session-789",
|
||||
started_at=now,
|
||||
last_activity=now
|
||||
)
|
||||
|
||||
info_dict = kernel_info.to_dict()
|
||||
|
||||
assert isinstance(info_dict, dict)
|
||||
assert info_dict["kernel_id"] == "kernel-123"
|
||||
assert info_dict["container_id"] == "container-456"
|
||||
assert info_dict["session_id"] == "session-789"
|
||||
assert "started_at" in info_dict
|
||||
assert "last_activity" in info_dict
|
||||
|
||||
|
||||
def test_start_kernel_with_session_id_tracking(kernel_manager):
|
||||
"""Test kernel tracks session_id correctly."""
|
||||
kernel_id = kernel_manager.start_kernel("my-session")
|
||||
|
||||
kernel_info = kernel_manager.kernels[kernel_id]
|
||||
assert kernel_info.session_id == "my-session"
|
||||
|
||||
|
||||
def test_update_activity_timestamp(kernel_manager):
|
||||
"""Test executing code updates last_activity timestamp."""
|
||||
kernel_id = kernel_manager.start_kernel("session-1")
|
||||
|
||||
original_activity = kernel_manager.kernels[kernel_id].last_activity
|
||||
|
||||
# Small delay to ensure timestamp difference
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
kernel_manager.execute_code(kernel_id, "pass")
|
||||
|
||||
new_activity = kernel_manager.kernels[kernel_id].last_activity
|
||||
assert new_activity > original_activity
|
||||
371
tests/execution/jupyter/test_sessions.py
Normal file
371
tests/execution/jupyter/test_sessions.py
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
"""Tests for the Session Manager module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_forge.execution.jupyter.sessions import (
|
||||
SessionManager,
|
||||
Session,
|
||||
SessionState,
|
||||
SessionError
|
||||
)
|
||||
from mcp_forge.execution.jupyter.kernel import JupyterKernelManager
|
||||
from mcp_forge.config.schema import SessionConfig
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
from mcp_forge.execution.simple.executor import ExecutionResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_config():
|
||||
"""Mock SessionConfig."""
|
||||
config = Mock(spec=SessionConfig)
|
||||
config.idle_timeout = 3600
|
||||
config.max_concurrent = 10
|
||||
config.cleanup_interval = 300
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_kernel_manager():
|
||||
"""Mock JupyterKernelManager."""
|
||||
manager = Mock(spec=JupyterKernelManager)
|
||||
manager.start_kernel.return_value = "kernel-123"
|
||||
manager.execute_code.return_value = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
manager.shutdown_kernel.return_value = None
|
||||
manager.inspect_namespace.return_value = []
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_audit_logger(tmp_path):
|
||||
"""Mock AuditLogger."""
|
||||
log_file = tmp_path / "audit.log"
|
||||
return Mock(spec=AuditLogger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_limits():
|
||||
"""Standard resource limits."""
|
||||
return ResourceLimits(
|
||||
memory="512m",
|
||||
cpu_quota=50000,
|
||||
storage="1g",
|
||||
timeout=300
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_manager(session_config, mock_kernel_manager, mock_audit_logger):
|
||||
"""SessionManager instance with mocked dependencies."""
|
||||
return SessionManager(
|
||||
config=session_config,
|
||||
kernel_manager=mock_kernel_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
|
||||
def test_create_session_starts_kernel(session_manager, mock_kernel_manager, resource_limits):
|
||||
"""Test create_session starts a kernel."""
|
||||
session = session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
assert session.session_id == "session-1"
|
||||
assert session.kernel_id == "kernel-123"
|
||||
mock_kernel_manager.start_kernel.assert_called_once_with("session-1", volumes=None)
|
||||
|
||||
|
||||
def test_create_session_with_duplicate_id_raises_error(session_manager, resource_limits):
|
||||
"""Test creating session with existing ID raises error."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
with pytest.raises(SessionError, match="already exists"):
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
|
||||
def test_create_session_enforces_max_concurrent(session_manager, session_config, resource_limits):
|
||||
"""Test max concurrent sessions is enforced."""
|
||||
session_config.max_concurrent = 2
|
||||
|
||||
# Create 2 sessions (at limit)
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
session_manager.create_session("session-2", resource_limits)
|
||||
|
||||
# Try to create 3rd session
|
||||
with pytest.raises(SessionError, match="Maximum concurrent sessions"):
|
||||
session_manager.create_session("session-3", resource_limits)
|
||||
|
||||
|
||||
def test_get_session_returns_existing_session(session_manager, resource_limits):
|
||||
"""Test get_session returns existing session."""
|
||||
created = session_manager.create_session("session-1", resource_limits)
|
||||
retrieved = session_manager.get_session("session-1")
|
||||
|
||||
assert retrieved.session_id == created.session_id
|
||||
assert retrieved.kernel_id == created.kernel_id
|
||||
|
||||
|
||||
def test_get_session_raises_error_for_nonexistent(session_manager):
|
||||
"""Test get_session raises error for nonexistent session."""
|
||||
with pytest.raises(SessionError, match="not found"):
|
||||
session_manager.get_session("nonexistent")
|
||||
|
||||
|
||||
def test_execute_in_session_runs_code(session_manager, mock_kernel_manager, resource_limits):
|
||||
"""Test execute_in_session runs code in kernel."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
result = session_manager.execute_in_session("session-1", "x = 42")
|
||||
|
||||
assert result.success is True
|
||||
mock_kernel_manager.execute_code.assert_called_once_with("kernel-123", "x = 42", timeout=300)
|
||||
|
||||
|
||||
def test_execute_in_session_updates_activity(session_manager, resource_limits):
|
||||
"""Test execute_in_session updates last activity timestamp."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
session = session_manager.get_session("session-1")
|
||||
|
||||
original_activity = session.last_activity
|
||||
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
session_manager.execute_in_session("session-1", "pass")
|
||||
|
||||
assert session.last_activity > original_activity
|
||||
|
||||
|
||||
def test_document_state_updates_session(session_manager, resource_limits):
|
||||
"""Test document_state updates session state."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
variables = {"x": "The result of computation", "y": "Another variable"}
|
||||
session_manager.document_state("session-1", variables, note="Test note")
|
||||
|
||||
state = session_manager.get_session_state("session-1")
|
||||
|
||||
assert state.documented_variables == variables
|
||||
assert state.note == "Test note"
|
||||
|
||||
|
||||
def test_document_state_with_clear_replaces_variables(session_manager, resource_limits):
|
||||
"""Test document_state with clear=True replaces all variables."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
# Set initial variables
|
||||
session_manager.document_state("session-1", {"x": "var x"})
|
||||
|
||||
# Clear and set new variables
|
||||
session_manager.document_state("session-1", {"y": "var y"}, clear=True)
|
||||
|
||||
state = session_manager.get_session_state("session-1")
|
||||
|
||||
assert "x" not in state.documented_variables
|
||||
assert "y" in state.documented_variables
|
||||
|
||||
|
||||
def test_document_state_without_clear_merges_variables(session_manager, resource_limits):
|
||||
"""Test document_state without clear merges variables."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
session_manager.document_state("session-1", {"x": "var x"})
|
||||
session_manager.document_state("session-1", {"y": "var y"})
|
||||
|
||||
state = session_manager.get_session_state("session-1")
|
||||
|
||||
assert "x" in state.documented_variables
|
||||
assert "y" in state.documented_variables
|
||||
|
||||
|
||||
def test_document_state_runs_introspection(session_manager, mock_kernel_manager, resource_limits):
|
||||
"""Test document_state runs namespace introspection."""
|
||||
mock_kernel_manager.inspect_namespace.return_value = ["x", "y", "z"]
|
||||
mock_kernel_manager.get_variable_info.return_value = {"type": "int", "repr": "42"}
|
||||
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
session_manager.document_state("session-1", {"x": "documented"})
|
||||
|
||||
state = session_manager.get_session_state("session-1")
|
||||
|
||||
assert state.all_variables == ["x", "y", "z"]
|
||||
mock_kernel_manager.inspect_namespace.assert_called_once_with("kernel-123")
|
||||
|
||||
|
||||
def test_get_session_state_returns_state(session_manager, resource_limits):
|
||||
"""Test get_session_state returns SessionState."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
state = session_manager.get_session_state("session-1")
|
||||
|
||||
assert isinstance(state, SessionState)
|
||||
assert state.session_id == "session-1"
|
||||
|
||||
|
||||
def test_destroy_session_shuts_down_kernel(session_manager, mock_kernel_manager, resource_limits):
|
||||
"""Test destroy_session shuts down kernel."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
session_manager.destroy_session("session-1")
|
||||
|
||||
mock_kernel_manager.shutdown_kernel.assert_called_once_with("kernel-123")
|
||||
|
||||
# Session should be removed
|
||||
with pytest.raises(SessionError):
|
||||
session_manager.get_session("session-1")
|
||||
|
||||
|
||||
def test_cleanup_idle_sessions_removes_old_sessions(session_manager, session_config, resource_limits):
|
||||
"""Test cleanup_idle_sessions removes idle sessions."""
|
||||
session_config.idle_timeout = 3600 # 1 hour
|
||||
|
||||
# Create two sessions
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
session_manager.create_session("session-2", resource_limits)
|
||||
|
||||
# Make session-1 appear old
|
||||
session1 = session_manager.get_session("session-1")
|
||||
session1.last_activity = datetime.utcnow() - timedelta(hours=2)
|
||||
|
||||
# Cleanup
|
||||
count = session_manager.cleanup_idle_sessions()
|
||||
|
||||
assert count == 1
|
||||
|
||||
# session-1 should be removed
|
||||
with pytest.raises(SessionError):
|
||||
session_manager.get_session("session-1")
|
||||
|
||||
# session-2 should still exist
|
||||
assert session_manager.get_session("session-2") is not None
|
||||
|
||||
|
||||
def test_session_is_idle_check(resource_limits):
|
||||
"""Test Session.is_idle() check."""
|
||||
now = datetime.utcnow()
|
||||
session = Session(
|
||||
session_id="session-1",
|
||||
kernel_id="kernel-123",
|
||||
created_at=now,
|
||||
resource_limits=resource_limits
|
||||
)
|
||||
|
||||
# Fresh session is not idle
|
||||
assert not session.is_idle(timedelta(hours=1))
|
||||
|
||||
# Make it old
|
||||
session.last_activity = now - timedelta(hours=2)
|
||||
|
||||
# Now it's idle
|
||||
assert session.is_idle(timedelta(hours=1))
|
||||
|
||||
|
||||
def test_session_update_activity(resource_limits):
|
||||
"""Test Session.update_activity() updates timestamp."""
|
||||
now = datetime.utcnow()
|
||||
session = Session(
|
||||
session_id="session-1",
|
||||
kernel_id="kernel-123",
|
||||
created_at=now,
|
||||
resource_limits=resource_limits
|
||||
)
|
||||
|
||||
original = session.last_activity
|
||||
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
session.update_activity()
|
||||
|
||||
assert session.last_activity > original
|
||||
|
||||
|
||||
def test_session_state_to_dict(resource_limits):
|
||||
"""Test SessionState.to_dict() serialization."""
|
||||
state = SessionState(
|
||||
session_id="session-1",
|
||||
documented_variables={"x": "var x"},
|
||||
note="Test note",
|
||||
all_variables=["x", "y"],
|
||||
introspection={"x": {"type": "int"}}
|
||||
)
|
||||
|
||||
state_dict = state.to_dict()
|
||||
|
||||
assert isinstance(state_dict, dict)
|
||||
assert state_dict["session_id"] == "session-1"
|
||||
assert state_dict["documented_variables"] == {"x": "var x"}
|
||||
assert state_dict["note"] == "Test note"
|
||||
assert state_dict["all_variables"] == ["x", "y"]
|
||||
|
||||
|
||||
def test_create_session_with_volumes(session_manager, mock_kernel_manager, resource_limits):
|
||||
"""Test create_session passes volumes to kernel manager."""
|
||||
volumes = {
|
||||
"/mcp-forge/sessions/session-1/workspace": {"bind": "/workspace", "mode": "rw"}
|
||||
}
|
||||
|
||||
session_manager.create_session("session-1", resource_limits, volumes=volumes)
|
||||
|
||||
mock_kernel_manager.start_kernel.assert_called_once_with("session-1", volumes=volumes)
|
||||
|
||||
|
||||
def test_list_sessions_returns_all_sessions(session_manager, resource_limits):
|
||||
"""Test list_sessions returns all active sessions."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
session_manager.create_session("session-2", resource_limits)
|
||||
|
||||
sessions = session_manager.list_sessions()
|
||||
|
||||
assert len(sessions) == 2
|
||||
session_ids = [s["session_id"] for s in sessions]
|
||||
assert "session-1" in session_ids
|
||||
assert "session-2" in session_ids
|
||||
|
||||
|
||||
def test_execute_in_nonexistent_session_raises_error(session_manager):
|
||||
"""Test execute_in_session raises error for nonexistent session."""
|
||||
with pytest.raises(SessionError, match="not found"):
|
||||
session_manager.execute_in_session("nonexistent", "pass")
|
||||
|
||||
|
||||
def test_document_state_for_nonexistent_session_raises_error(session_manager):
|
||||
"""Test document_state raises error for nonexistent session."""
|
||||
with pytest.raises(SessionError, match="not found"):
|
||||
session_manager.document_state("nonexistent", {})
|
||||
|
||||
|
||||
def test_destroy_nonexistent_session_raises_error(session_manager):
|
||||
"""Test destroy_session raises error for nonexistent session."""
|
||||
with pytest.raises(SessionError, match="not found"):
|
||||
session_manager.destroy_session("nonexistent")
|
||||
|
||||
|
||||
def test_session_isolation(session_manager, resource_limits):
|
||||
"""Test sessions are isolated from each other."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
session_manager.create_session("session-2", resource_limits)
|
||||
|
||||
# Document state in session-1
|
||||
session_manager.document_state("session-1", {"x": "session 1 var"})
|
||||
|
||||
# State should not appear in session-2
|
||||
state2 = session_manager.get_session_state("session-2")
|
||||
assert "x" not in state2.documented_variables
|
||||
|
||||
|
||||
def test_session_with_custom_timeout(session_manager, mock_kernel_manager, resource_limits):
|
||||
"""Test execute_in_session with custom timeout."""
|
||||
session_manager.create_session("session-1", resource_limits)
|
||||
|
||||
session_manager.execute_in_session("session-1", "pass", timeout=600)
|
||||
|
||||
mock_kernel_manager.execute_code.assert_called_once_with("kernel-123", "pass", timeout=600)
|
||||
0
tests/execution/simple/__init__.py
Normal file
0
tests/execution/simple/__init__.py
Normal file
395
tests/execution/simple/test_backend.py
Normal file
395
tests/execution/simple/test_backend.py
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
"""Tests for the Simple Backend module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_forge.execution.simple.backend import SimpleBackend
|
||||
from mcp_forge.execution.simple.executor import ExecutionResult
|
||||
from mcp_forge.config.schema import ForgeConfig, ExecutionConfig, ImageConfig
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Mock ForgeConfig with execution settings."""
|
||||
config = Mock(spec=ForgeConfig)
|
||||
|
||||
# Execution configuration
|
||||
config.execution = Mock(spec=ExecutionConfig)
|
||||
config.execution.default_timeout = 300
|
||||
config.execution.max_timeout = 1800
|
||||
config.execution.default_memory = "512m"
|
||||
config.execution.max_memory = "2g"
|
||||
config.execution.default_cpu_quota = 50000
|
||||
config.execution.max_cpu_quota = 100000
|
||||
|
||||
# Image configuration
|
||||
config.images = Mock(spec=ImageConfig)
|
||||
config.images.python_3_11 = "mcp-forge/python:3.11"
|
||||
config.images.python_3_12 = "mcp-forge/python:3.12"
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_container_manager():
|
||||
"""Mock SecureContainerManager."""
|
||||
return Mock(spec=SecureContainerManager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_audit_logger(tmp_path):
|
||||
"""Mock AuditLogger."""
|
||||
log_file = tmp_path / "audit.log"
|
||||
return Mock(spec=AuditLogger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(mock_config, mock_container_manager, mock_audit_logger):
|
||||
"""SimpleBackend instance with mocked dependencies."""
|
||||
return SimpleBackend(
|
||||
config=mock_config,
|
||||
container_manager=mock_container_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
|
||||
def test_execute_without_custom_params_uses_defaults(backend, mock_config):
|
||||
"""Test execute uses configuration defaults when no params specified."""
|
||||
# Mock executor to return a result
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.return_value = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=42,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
result = backend.execute("2 + 2")
|
||||
|
||||
# Verify executor was created with default limits
|
||||
mock_executor_class.assert_called_once()
|
||||
args, kwargs = mock_executor_class.call_args
|
||||
|
||||
# Check resource limits
|
||||
resource_limits = kwargs.get('resource_limits')
|
||||
assert resource_limits is not None
|
||||
assert resource_limits.memory_bytes == 512 * 1024 * 1024 # 512m in bytes
|
||||
assert resource_limits.cpu_quota == 50000
|
||||
assert resource_limits.timeout == 300
|
||||
|
||||
# Check image
|
||||
assert kwargs.get('image') == "mcp-forge/python:3.11"
|
||||
|
||||
|
||||
def test_execute_with_custom_timeout(backend, mock_config):
|
||||
"""Test execute respects custom timeout parameter."""
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.return_value = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
backend.execute("pass", timeout=600)
|
||||
|
||||
# Verify resource limits include custom timeout
|
||||
args, kwargs = mock_executor_class.call_args
|
||||
resource_limits = kwargs.get('resource_limits')
|
||||
assert resource_limits.timeout == 600
|
||||
|
||||
|
||||
def test_execute_with_custom_memory(backend, mock_config):
|
||||
"""Test execute respects custom memory parameter."""
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.return_value = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
backend.execute("pass", memory="1g")
|
||||
|
||||
# Verify resource limits include custom memory
|
||||
args, kwargs = mock_executor_class.call_args
|
||||
resource_limits = kwargs.get('resource_limits')
|
||||
assert resource_limits.memory_bytes == 1024 * 1024 * 1024 # 1g in bytes
|
||||
|
||||
|
||||
def test_execute_with_custom_cpu_quota(backend, mock_config):
|
||||
"""Test execute respects custom CPU quota parameter."""
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.return_value = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
backend.execute("pass", cpu_quota=75000)
|
||||
|
||||
# Verify resource limits include custom CPU quota
|
||||
args, kwargs = mock_executor_class.call_args
|
||||
resource_limits = kwargs.get('resource_limits')
|
||||
assert resource_limits.cpu_quota == 75000
|
||||
|
||||
|
||||
def test_execute_with_custom_image(backend, mock_config):
|
||||
"""Test execute respects custom image parameter."""
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.return_value = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
backend.execute("pass", custom_image="mcp-forge/python:3.12")
|
||||
|
||||
# Verify correct image was used
|
||||
args, kwargs = mock_executor_class.call_args
|
||||
assert kwargs.get('image') == "mcp-forge/python:3.12"
|
||||
|
||||
|
||||
def test_execute_validates_timeout_against_max(backend, mock_config):
|
||||
"""Test execute rejects timeout exceeding max."""
|
||||
with pytest.raises(ValueError, match="timeout.*exceeds maximum"):
|
||||
backend.execute("pass", timeout=2000) # max is 1800
|
||||
|
||||
|
||||
def test_execute_validates_memory_against_max(backend, mock_config):
|
||||
"""Test execute rejects memory exceeding max."""
|
||||
with pytest.raises(ValueError, match="memory.*exceeds maximum"):
|
||||
backend.execute("pass", memory="4g") # max is 2g
|
||||
|
||||
|
||||
def test_execute_validates_cpu_quota_against_max(backend, mock_config):
|
||||
"""Test execute rejects CPU quota exceeding max."""
|
||||
with pytest.raises(ValueError, match="cpu_quota.*exceeds maximum"):
|
||||
backend.execute("pass", cpu_quota=150000) # max is 100000
|
||||
|
||||
|
||||
def test_execute_logs_to_audit(backend, mock_audit_logger):
|
||||
"""Test execute logs execution to audit log."""
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.return_value = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=42,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
backend.execute("x = 2 + 2")
|
||||
|
||||
# Verify audit log was called
|
||||
mock_audit_logger.log.assert_called()
|
||||
call_args = mock_audit_logger.log.call_args
|
||||
|
||||
# Check that code hash is logged, not actual code
|
||||
log_data = call_args[1]
|
||||
assert 'code_hash' in log_data or 'details' in log_data
|
||||
|
||||
|
||||
def test_execute_with_volumes(backend, mock_container_manager):
|
||||
"""Test execute passes volume configuration to container manager."""
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.return_value = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
volumes = {
|
||||
"/mcp-forge/sessions/test-session/workspace": {"bind": "/workspace", "mode": "rw"}
|
||||
}
|
||||
|
||||
backend.execute("pass", volumes=volumes)
|
||||
|
||||
# Verify volumes were passed through
|
||||
args, kwargs = mock_executor_class.call_args
|
||||
# Volumes should be passed to container_manager through executor
|
||||
# This is verified through the executor initialization
|
||||
assert mock_executor_class.called
|
||||
|
||||
|
||||
def test_execute_returns_result(backend):
|
||||
"""Test execute returns ExecutionResult from executor."""
|
||||
expected_result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="Hello\n",
|
||||
stderr="",
|
||||
result=42,
|
||||
execution_time=0.5,
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.return_value = expected_result
|
||||
|
||||
result = backend.execute('print("Hello"); 42')
|
||||
|
||||
assert result == expected_result
|
||||
assert result.success is True
|
||||
assert result.result == 42
|
||||
|
||||
|
||||
def test_execute_handles_executor_errors(backend):
|
||||
"""Test execute propagates executor errors."""
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.side_effect = RuntimeError("Container failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Container failed"):
|
||||
backend.execute("pass")
|
||||
|
||||
|
||||
def test_validate_limits_accepts_valid_limits(backend):
|
||||
"""Test _validate_limits accepts limits within maximums."""
|
||||
# Should not raise
|
||||
backend._validate_limits(
|
||||
timeout=1000,
|
||||
memory="1g",
|
||||
cpu_quota=75000
|
||||
)
|
||||
|
||||
|
||||
def test_validate_limits_rejects_excessive_timeout(backend):
|
||||
"""Test _validate_limits rejects excessive timeout."""
|
||||
with pytest.raises(ValueError, match="timeout"):
|
||||
backend._validate_limits(
|
||||
timeout=2000,
|
||||
memory="512m",
|
||||
cpu_quota=50000
|
||||
)
|
||||
|
||||
|
||||
def test_validate_limits_rejects_excessive_memory(backend):
|
||||
"""Test _validate_limits rejects excessive memory."""
|
||||
with pytest.raises(ValueError, match="memory"):
|
||||
backend._validate_limits(
|
||||
timeout=300,
|
||||
memory="4g",
|
||||
cpu_quota=50000
|
||||
)
|
||||
|
||||
|
||||
def test_validate_limits_rejects_excessive_cpu_quota(backend):
|
||||
"""Test _validate_limits rejects excessive CPU quota."""
|
||||
with pytest.raises(ValueError, match="cpu_quota"):
|
||||
backend._validate_limits(
|
||||
timeout=300,
|
||||
memory="512m",
|
||||
cpu_quota=150000
|
||||
)
|
||||
|
||||
|
||||
def test_get_image_returns_custom_when_provided(backend):
|
||||
"""Test _get_image returns custom image when provided."""
|
||||
image = backend._get_image("mcp-forge/custom:latest")
|
||||
assert image == "mcp-forge/custom:latest"
|
||||
|
||||
|
||||
def test_get_image_returns_default_when_none(backend, mock_config):
|
||||
"""Test _get_image returns default image when None provided."""
|
||||
image = backend._get_image(None)
|
||||
assert image == mock_config.images.python_3_11
|
||||
|
||||
|
||||
def test_concurrent_executions_are_independent(backend):
|
||||
"""Test multiple concurrent executions don't interfere."""
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
# Create separate mock executors for each call
|
||||
executor1 = Mock()
|
||||
executor2 = Mock()
|
||||
mock_executor_class.side_effect = [executor1, executor2]
|
||||
|
||||
executor1.execute.return_value = ExecutionResult(
|
||||
success=True, stdout="", stderr="", result=1,
|
||||
execution_time=0.1, exit_code=0
|
||||
)
|
||||
executor2.execute.return_value = ExecutionResult(
|
||||
success=True, stdout="", stderr="", result=2,
|
||||
execution_time=0.1, exit_code=0
|
||||
)
|
||||
|
||||
result1 = backend.execute("1")
|
||||
result2 = backend.execute("2")
|
||||
|
||||
assert result1.result == 1
|
||||
assert result2.result == 2
|
||||
|
||||
# Each execution should create its own executor
|
||||
assert mock_executor_class.call_count == 2
|
||||
|
||||
|
||||
def test_execute_with_all_custom_params(backend):
|
||||
"""Test execute with all parameters customized."""
|
||||
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
|
||||
mock_executor = Mock()
|
||||
mock_executor_class.return_value = mock_executor
|
||||
mock_executor.execute.return_value = ExecutionResult(
|
||||
success=True,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
volumes = {"/mcp-forge/sessions/test/work": {"bind": "/workspace", "mode": "rw"}}
|
||||
|
||||
backend.execute(
|
||||
"pass",
|
||||
timeout=600,
|
||||
memory="1g",
|
||||
cpu_quota=75000,
|
||||
custom_image="mcp-forge/python:3.12",
|
||||
volumes=volumes
|
||||
)
|
||||
|
||||
# Verify all parameters were applied
|
||||
args, kwargs = mock_executor_class.call_args
|
||||
|
||||
resource_limits = kwargs.get('resource_limits')
|
||||
assert resource_limits.timeout == 600
|
||||
assert resource_limits.memory_bytes == 1024 * 1024 * 1024 # 1g in bytes
|
||||
assert resource_limits.cpu_quota == 75000
|
||||
|
||||
assert kwargs.get('image') == "mcp-forge/python:3.12"
|
||||
299
tests/execution/simple/test_executor.py
Normal file
299
tests/execution/simple/test_executor.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"""Tests for the Code Executor module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
import json
|
||||
|
||||
from mcp_forge.execution.simple.executor import CodeExecutor, ExecutionResult
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_limits():
|
||||
"""Standard resource limits for testing."""
|
||||
return ResourceLimits(
|
||||
memory="512m",
|
||||
cpu_quota=50000,
|
||||
storage="1g",
|
||||
timeout=30
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_container_manager():
|
||||
"""Mock SecureContainerManager."""
|
||||
manager = Mock(spec=SecureContainerManager)
|
||||
|
||||
# Mock container lifecycle
|
||||
manager.create_container.return_value = "test-container-123"
|
||||
manager.start_container.return_value = None
|
||||
manager.stop_container.return_value = None
|
||||
manager.remove_container.return_value = None
|
||||
manager.wait_for_container.return_value = 0 # exit code
|
||||
manager.get_container_logs.return_value = ("", "") # (stdout, stderr)
|
||||
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor(mock_container_manager, resource_limits):
|
||||
"""CodeExecutor instance with mocked dependencies."""
|
||||
return CodeExecutor(
|
||||
container_manager=mock_container_manager,
|
||||
image="mcp-forge/python:3.11",
|
||||
resource_limits=resource_limits
|
||||
)
|
||||
|
||||
|
||||
def test_execute_simple_python_code_returns_result(executor, mock_container_manager):
|
||||
"""Test executing simple Python code returns the result."""
|
||||
# Mock successful execution with result
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": 42, "error": None}),
|
||||
""
|
||||
)
|
||||
|
||||
result = executor.execute("2 + 2")
|
||||
|
||||
assert result.success is True
|
||||
assert result.result == 42
|
||||
assert result.exit_code == 0
|
||||
assert result.error is None
|
||||
|
||||
# Verify container lifecycle
|
||||
mock_container_manager.create_container.assert_called_once()
|
||||
mock_container_manager.start_container.assert_called_once_with("test-container-123")
|
||||
mock_container_manager.wait_for_container.assert_called_once_with("test-container-123", timeout=30)
|
||||
mock_container_manager.remove_container.assert_called_once_with("test-container-123")
|
||||
|
||||
|
||||
def test_execute_code_with_stdout_capture(executor, mock_container_manager):
|
||||
"""Test code execution captures stdout."""
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": None, "error": None}) + "\n" + "Hello, World!",
|
||||
""
|
||||
)
|
||||
|
||||
result = executor.execute('print("Hello, World!")')
|
||||
|
||||
assert result.success is True
|
||||
assert "Hello, World!" in result.stdout
|
||||
assert result.stderr == ""
|
||||
|
||||
|
||||
def test_execute_code_with_stderr_capture(executor, mock_container_manager):
|
||||
"""Test code execution captures stderr."""
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": None, "error": None}),
|
||||
"Warning: something happened"
|
||||
)
|
||||
|
||||
result = executor.execute('import sys; print("warning", file=sys.stderr)')
|
||||
|
||||
assert result.success is True
|
||||
assert result.stderr == "Warning: something happened"
|
||||
|
||||
|
||||
def test_execute_code_timeout_enforcement(executor, mock_container_manager):
|
||||
"""Test code execution enforces timeout."""
|
||||
# Simulate timeout by having wait_for_container take too long
|
||||
mock_container_manager.wait_for_container.side_effect = TimeoutError("Container exceeded timeout")
|
||||
|
||||
result = executor.execute("import time; time.sleep(60)", timeout=1)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert "timeout" in result.error.lower()
|
||||
|
||||
# Verify cleanup still happens
|
||||
mock_container_manager.remove_container.assert_called_once_with("test-container-123")
|
||||
|
||||
|
||||
def test_execute_code_with_exception_handling(executor, mock_container_manager):
|
||||
"""Test code execution handles exceptions gracefully."""
|
||||
error_msg = "ZeroDivisionError: division by zero"
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": None, "error": error_msg}),
|
||||
""
|
||||
)
|
||||
mock_container_manager.wait_for_container.return_value = 1 # non-zero exit
|
||||
|
||||
result = executor.execute("1 / 0")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == error_msg
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
def test_execute_code_with_syntax_error_returns_clear_error(executor, mock_container_manager):
|
||||
"""Test code with syntax error returns clear error message."""
|
||||
error_msg = "SyntaxError: invalid syntax"
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": None, "error": error_msg}),
|
||||
""
|
||||
)
|
||||
mock_container_manager.wait_for_container.return_value = 1
|
||||
|
||||
result = executor.execute("def foo( :")
|
||||
|
||||
assert result.success is False
|
||||
assert "SyntaxError" in result.error
|
||||
|
||||
|
||||
def test_execute_code_with_runtime_error_returns_clear_error(executor, mock_container_manager):
|
||||
"""Test code with runtime error returns clear error with traceback."""
|
||||
error_msg = "NameError: name 'undefined_var' is not defined"
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": None, "error": error_msg}),
|
||||
""
|
||||
)
|
||||
mock_container_manager.wait_for_container.return_value = 1
|
||||
|
||||
result = executor.execute("print(undefined_var)")
|
||||
|
||||
assert result.success is False
|
||||
assert "NameError" in result.error
|
||||
|
||||
|
||||
def test_result_serialization_json_compatible_types(executor, mock_container_manager):
|
||||
"""Test execution result contains only JSON-serializable data."""
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": [1, 2, {"key": "value"}], "error": None}),
|
||||
""
|
||||
)
|
||||
|
||||
result = executor.execute('[1, 2, {"key": "value"}]')
|
||||
|
||||
# Verify result can be serialized to JSON
|
||||
result_dict = result.to_dict()
|
||||
json_str = json.dumps(result_dict)
|
||||
assert json_str is not None
|
||||
|
||||
# Verify result data
|
||||
assert result.result == [1, 2, {"key": "value"}]
|
||||
|
||||
|
||||
def test_large_output_handling(executor, mock_container_manager):
|
||||
"""Test execution handles large output without issues."""
|
||||
large_output = "x" * 10000 # 10KB of output
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": None, "error": None}) + "\n" + large_output,
|
||||
""
|
||||
)
|
||||
|
||||
result = executor.execute('print("x" * 10000)')
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.stdout) >= 10000
|
||||
|
||||
|
||||
def test_execution_result_to_dict(resource_limits):
|
||||
"""Test ExecutionResult.to_dict() returns proper dictionary."""
|
||||
result = ExecutionResult(
|
||||
success=True,
|
||||
stdout="output",
|
||||
stderr="",
|
||||
result=42,
|
||||
execution_time=0.5,
|
||||
exit_code=0,
|
||||
error=None
|
||||
)
|
||||
|
||||
result_dict = result.to_dict()
|
||||
|
||||
assert isinstance(result_dict, dict)
|
||||
assert result_dict["success"] is True
|
||||
assert result_dict["stdout"] == "output"
|
||||
assert result_dict["stderr"] == ""
|
||||
assert result_dict["result"] == 42
|
||||
assert result_dict["execution_time"] == 0.5
|
||||
assert result_dict["exit_code"] == 0
|
||||
assert result_dict["error"] is None
|
||||
|
||||
|
||||
def test_prepare_code_wraps_code_properly(executor):
|
||||
"""Test _prepare_code wraps code to capture result."""
|
||||
code = "x = 2 + 2\nx"
|
||||
wrapped = executor._prepare_code(code)
|
||||
|
||||
# Wrapped code should be executable Python
|
||||
assert "import" in wrapped
|
||||
assert "json" in wrapped
|
||||
assert code in wrapped or "2 + 2" in wrapped
|
||||
|
||||
|
||||
def test_parse_output_extracts_result_and_error(executor):
|
||||
"""Test _parse_output correctly extracts result and error from JSON."""
|
||||
# Test successful result
|
||||
stdout = json.dumps({"result": 42, "error": None})
|
||||
result, error = executor._parse_output(stdout)
|
||||
assert result == 42
|
||||
assert error is None
|
||||
|
||||
# Test error
|
||||
stdout = json.dumps({"result": None, "error": "ValueError: invalid"})
|
||||
result, error = executor._parse_output(stdout)
|
||||
assert result is None
|
||||
assert error == "ValueError: invalid"
|
||||
|
||||
|
||||
def test_cleanup_happens_even_on_create_failure(executor, mock_container_manager):
|
||||
"""Test container cleanup happens even if create fails."""
|
||||
mock_container_manager.create_container.side_effect = Exception("Create failed")
|
||||
|
||||
with pytest.raises(Exception, match="Create failed"):
|
||||
executor.execute("print('test')")
|
||||
|
||||
# No container to remove since create failed
|
||||
mock_container_manager.remove_container.assert_not_called()
|
||||
|
||||
|
||||
def test_cleanup_happens_even_on_start_failure(executor, mock_container_manager):
|
||||
"""Test container cleanup happens even if start fails."""
|
||||
mock_container_manager.start_container.side_effect = Exception("Start failed")
|
||||
|
||||
with pytest.raises(Exception, match="Start failed"):
|
||||
executor.execute("print('test')")
|
||||
|
||||
# Container should still be removed
|
||||
mock_container_manager.remove_container.assert_called_once_with("test-container-123")
|
||||
|
||||
|
||||
def test_execution_time_tracking(executor, mock_container_manager):
|
||||
"""Test execution time is tracked accurately."""
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": None, "error": None}),
|
||||
""
|
||||
)
|
||||
|
||||
result = executor.execute("pass")
|
||||
|
||||
assert result.execution_time >= 0
|
||||
assert isinstance(result.execution_time, float)
|
||||
|
||||
|
||||
def test_execute_with_custom_timeout(executor, mock_container_manager):
|
||||
"""Test execute respects custom timeout parameter."""
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": None, "error": None}),
|
||||
""
|
||||
)
|
||||
|
||||
executor.execute("pass", timeout=60)
|
||||
|
||||
# Verify wait was called with custom timeout
|
||||
mock_container_manager.wait_for_container.assert_called_with("test-container-123", timeout=60)
|
||||
|
||||
|
||||
def test_execute_uses_default_timeout_from_resource_limits(executor, mock_container_manager):
|
||||
"""Test execute uses default timeout from resource limits when not specified."""
|
||||
mock_container_manager.get_container_logs.return_value = (
|
||||
json.dumps({"result": None, "error": None}),
|
||||
""
|
||||
)
|
||||
|
||||
executor.execute("pass") # No timeout specified
|
||||
|
||||
# Should use resource_limits.timeout (30)
|
||||
mock_container_manager.wait_for_container.assert_called_with("test-container-123", timeout=30)
|
||||
59
tests/integration/conftest.py
Normal file
59
tests/integration/conftest.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Shared fixtures for integration tests."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_forge.config.schema import (
|
||||
ForgeConfig, ServerConfig, SecurityConfig, ExecutionConfig, SessionConfig,
|
||||
ImageConfig, VolumeConfig, EnvironmentBuilderConfig, PackageValidationConfig
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_config(tmp_path):
|
||||
"""Real configuration with all required fields for integration tests."""
|
||||
# Create required files
|
||||
(tmp_path / "allowlist.txt").write_text("requests\npandas\nnumpy\n")
|
||||
(tmp_path / "blocklist.txt").write_text("")
|
||||
|
||||
config = ForgeConfig(
|
||||
server=ServerConfig(
|
||||
host="localhost",
|
||||
port=3000,
|
||||
podman_socket=Path("/run/user/1000/podman/podman.sock")
|
||||
),
|
||||
security=SecurityConfig(
|
||||
audit_log=tmp_path / "audit.log",
|
||||
enforce_resource_limits=True,
|
||||
allow_network=False
|
||||
),
|
||||
execution=ExecutionConfig(
|
||||
default_backend="simple",
|
||||
default_timeout=300,
|
||||
max_timeout=1800,
|
||||
default_memory="512m",
|
||||
max_memory="2g"
|
||||
),
|
||||
images=ImageConfig(
|
||||
python_3_11="mcp-forge/python:3.11",
|
||||
python_3_12="mcp-forge/python:3.12",
|
||||
auto_pull=False
|
||||
),
|
||||
sessions=SessionConfig(
|
||||
max_concurrent=10,
|
||||
idle_timeout=3600
|
||||
),
|
||||
volumes=VolumeConfig(
|
||||
base_path=tmp_path / "volumes"
|
||||
),
|
||||
environment_builder=EnvironmentBuilderConfig(
|
||||
uv_cache_path=tmp_path / "cache",
|
||||
build_rate_limit={"requests": 5, "period": 60},
|
||||
package_validation=PackageValidationConfig(
|
||||
allowlist_path=tmp_path / "allowlist.txt",
|
||||
blocklist_path=tmp_path / "blocklist.txt"
|
||||
)
|
||||
),
|
||||
mcp_tools={}
|
||||
)
|
||||
return config
|
||||
56
tests/integration/test_environment_build.py
Normal file
56
tests/integration/test_environment_build.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Integration tests for environment building workflow."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_environment_build_end_to_end(tmp_path):
|
||||
"""Test: Package list → validation → UV install → image build → container creation."""
|
||||
# Setup: Create environment builder with all dependencies
|
||||
# Execute:
|
||||
# 1. Request environment with packages: ["requests", "pandas"]
|
||||
# 2. Validate packages (should pass)
|
||||
# 3. Install with UV
|
||||
# 4. Build container image
|
||||
# 5. Create running container
|
||||
# Verify: Container has packages installed and importable
|
||||
pytest.skip("TODO: Phase 5.3 - Environment build flow")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_package_prevents_build(tmp_path):
|
||||
"""Test: Security validation prevents building environments with blocked packages."""
|
||||
# Setup: Config with blocked packages
|
||||
# Execute: Try to build environment with blocked package
|
||||
# Verify: Build fails at validation stage, no container created
|
||||
pytest.skip("TODO: Phase 5.3 - Security validation integration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_package_name_fails_gracefully(tmp_path):
|
||||
"""Test: Invalid package names are caught early."""
|
||||
# Setup: Environment builder
|
||||
# Execute: Request build with non-existent package
|
||||
# Verify: Validation or install fails with clear error message
|
||||
pytest.skip("TODO: Phase 5.3 - Error handling in build flow")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_environment_caching(tmp_path):
|
||||
"""Test: Building same environment twice uses cache."""
|
||||
# Setup: Environment builder with caching enabled
|
||||
# Execute:
|
||||
# 1. Build environment with ["requests"]
|
||||
# 2. Build same environment again
|
||||
# Verify: Second build is faster (uses cached image)
|
||||
pytest.skip("TODO: Phase 5.3 - Build caching")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_builds_respect_limits(tmp_path):
|
||||
"""Test: Multiple simultaneous builds respect max_parallel_builds limit."""
|
||||
# Setup: Environment builder with max_parallel_builds=2
|
||||
# Execute: Trigger 5 builds simultaneously
|
||||
# Verify: Only 2 run at once, others wait
|
||||
pytest.skip("TODO: Phase 5.3 - Build rate limiting")
|
||||
48
tests/integration/test_error_handling.py
Normal file
48
tests/integration/test_error_handling.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Integration tests for error handling and recovery."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_container_crash_cleanup(tmp_path):
|
||||
"""Test: If container crashes, resources are cleaned up."""
|
||||
# Setup: Create session with container
|
||||
# Execute: Crash the container (kill -9)
|
||||
# Verify: Session marked as failed, container removed, resources freed
|
||||
pytest.skip("TODO: Phase 5.3 - Crash recovery")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_error_propagation(tmp_path):
|
||||
"""Test: Python errors in execution are returned with full traceback."""
|
||||
# Setup: Create backend
|
||||
# Execute: Code with syntax error or runtime error
|
||||
# Verify: Error returned to caller with traceback, doesn't crash server
|
||||
pytest.skip("TODO: Phase 5.3 - Error propagation")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_podman_connection_loss_handling(tmp_path):
|
||||
"""Test: If Podman socket disconnects, errors are clear."""
|
||||
# Setup: Create system connected to Podman
|
||||
# Execute: Simulate socket disconnect
|
||||
# Verify: Operations fail with clear "Podman unavailable" error
|
||||
pytest.skip("TODO: Phase 5.3 - Connection loss handling")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_session_cleanup(tmp_path):
|
||||
"""Test: Cleaning up many sessions simultaneously doesn't deadlock."""
|
||||
# Setup: Create 100 sessions
|
||||
# Execute: Cleanup all simultaneously
|
||||
# Verify: All cleaned up without deadlock or resource leaks
|
||||
pytest.skip("TODO: Phase 5.3 - Concurrent cleanup")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_mcp_tool_call_error_handling(tmp_path):
|
||||
"""Test: Calling MCP tool with wrong arguments returns clear error."""
|
||||
# Setup: Register MCP tool
|
||||
# Execute: Call with invalid arguments
|
||||
# Verify: Returns validation error, doesn't crash bridge
|
||||
pytest.skip("TODO: Phase 5.3 - MCP error handling")
|
||||
33
tests/integration/test_execution_flow.py
Normal file
33
tests/integration/test_execution_flow.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Integration tests for end-to-end execution flows."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_backend_execution_flow(real_config):
|
||||
"""Test complete flow: code submission → execution → result return."""
|
||||
pytest.skip("TODO: Phase 5.4 - Requires proper container registration and security setup")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jupyter_session_lifecycle(real_config):
|
||||
"""Test: Create session → execute multiple code blocks → cleanup."""
|
||||
pytest.skip("TODO: Phase 5.4 - Complex Jupyter session testing requires more setup")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jupyter_session_isolation(real_config):
|
||||
"""Test: Two sessions don't share state."""
|
||||
pytest.skip("TODO: Phase 5.4 - Complex Jupyter session testing requires more setup")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_with_timeout(real_config):
|
||||
"""Test: Long-running code gets killed after timeout."""
|
||||
pytest.skip("TODO: Phase 5.4 - Requires proper container registration and security setup")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_with_memory_limit(real_config):
|
||||
"""Test: Memory-intensive code respects limits."""
|
||||
pytest.skip("TODO: Phase 5.4 - Requires proper container registration and security setup")
|
||||
66
tests/integration/test_mcp_integration.py
Normal file
66
tests/integration/test_mcp_integration.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Integration tests for MCP tool and bridge integration."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_bridge_connection(tmp_path):
|
||||
"""Test: MCP client → bridge server → tool execution."""
|
||||
# Setup: Start bridge server, create MCP client, register tools
|
||||
# Execute: Client calls tool through bridge
|
||||
# Verify: Tool executes and returns result through bridge
|
||||
pytest.skip("TODO: Phase 5.3 - MCP bridge integration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_injection_into_execution(tmp_path):
|
||||
"""Test: MCP tools are injected into Python execution environment."""
|
||||
# Setup: Create session with MCP tools available
|
||||
# Execute: Python code that calls MCP tool (e.g., mcp_tools.search_web())
|
||||
# Verify: Tool is callable and returns expected result
|
||||
pytest.skip("TODO: Phase 5.3 - Tool injection integration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_mcp_clients_no_collision(tmp_path):
|
||||
"""Test: Multiple MCP clients with same tool names don't conflict."""
|
||||
# Setup: Register two clients, both with "search" tool
|
||||
# Execute: Call search tool
|
||||
# Verify: Collision detected and handled (namespacing or error)
|
||||
pytest.skip("TODO: Phase 5.3 - Tool collision detection")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_handler_execute_python(tmp_path):
|
||||
"""Test: execute_python MCP tool end-to-end."""
|
||||
# Setup: Create ForgeServer
|
||||
# Execute: Call execute_python tool with simple code
|
||||
# Verify: Code executes and returns stdout/result
|
||||
pytest.skip("TODO: Phase 5.3 - ExecutePythonTool integration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_handler_document_state(tmp_path):
|
||||
"""Test: document_state MCP tool shows session variables."""
|
||||
# Setup: Create session, execute code that sets variables
|
||||
# Execute: Call document_state tool
|
||||
# Verify: Returns list of variables and their values
|
||||
pytest.skip("TODO: Phase 5.3 - DocumentStateTool integration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_handler_build_environment(tmp_path):
|
||||
"""Test: build_environment MCP tool creates custom environment."""
|
||||
# Setup: Create ForgeServer
|
||||
# Execute: Call build_environment with package list
|
||||
# Verify: Environment built and can be used for execution
|
||||
pytest.skip("TODO: Phase 5.3 - BuildEnvironmentTool integration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_resource_handlers(tmp_path):
|
||||
"""Test: MCP resource handlers return correct data."""
|
||||
# Setup: Create ForgeServer with sessions and environments
|
||||
# Execute: Read resources (tools/available, sessions/list, environments/list)
|
||||
# Verify: Resources return expected data
|
||||
pytest.skip("TODO: Phase 5.3 - Resource handler integration")
|
||||
48
tests/integration/test_security_enforcement.py
Normal file
48
tests/integration/test_security_enforcement.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Integration tests for security enforcement across components."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_package_import_prevented(tmp_path):
|
||||
"""Test: Attempting to import blocked package fails."""
|
||||
# Setup: Config with blocked packages (e.g., ["subprocess", "os"])
|
||||
# Execute: Try to execute code that imports blocked package
|
||||
# Verify: Execution blocked or import fails
|
||||
pytest.skip("TODO: Phase 5.3 - Package blocking enforcement")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_limits_enforced_in_container(tmp_path):
|
||||
"""Test: Container actually respects memory/CPU/timeout limits."""
|
||||
# Setup: Create container with strict limits
|
||||
# Execute: Run code that tries to exceed limits
|
||||
# Verify: Container killed or limited appropriately
|
||||
pytest.skip("TODO: Phase 5.3 - Resource limit enforcement")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_logging_across_operations(tmp_path):
|
||||
"""Test: All operations are logged to audit trail."""
|
||||
# Setup: Create system with audit logging
|
||||
# Execute: Multiple operations (create session, execute code, build env)
|
||||
# Verify: All operations appear in audit log with correct metadata
|
||||
pytest.skip("TODO: Phase 5.3 - Audit trail integration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_isolation_in_containers(tmp_path):
|
||||
"""Test: Containers cannot access external network (if configured)."""
|
||||
# Setup: Create container with network disabled
|
||||
# Execute: Try to make HTTP request
|
||||
# Verify: Request fails (network isolated)
|
||||
pytest.skip("TODO: Phase 5.3 - Network isolation")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filesystem_isolation_in_containers(tmp_path):
|
||||
"""Test: Containers cannot access host filesystem outside mounts."""
|
||||
# Setup: Create container
|
||||
# Execute: Try to read /etc/passwd or other host files
|
||||
# Verify: Access denied
|
||||
pytest.skip("TODO: Phase 5.3 - Filesystem isolation")
|
||||
359
tests/mcp/test_bridge.py
Normal file
359
tests/mcp/test_bridge.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
"""Tests for Tool Bridge Server."""
|
||||
|
||||
import pytest
|
||||
import socket
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from mcp_forge.mcp.bridge import ToolBridgeServer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_socket_path():
|
||||
"""Create temporary socket path."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir) / "test_bridge.sock"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client_manager():
|
||||
"""Create mock MCP client manager."""
|
||||
manager = AsyncMock()
|
||||
manager.call_tool = AsyncMock(return_value={"result": "success"})
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_audit_logger():
|
||||
"""Create mock audit logger."""
|
||||
logger = Mock()
|
||||
logger.log = Mock()
|
||||
return logger
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_server_starts_and_stops(temp_socket_path, mock_client_manager, mock_audit_logger):
|
||||
"""Test that bridge server starts and stops cleanly."""
|
||||
bridge = ToolBridgeServer(
|
||||
socket_path=temp_socket_path,
|
||||
client_manager=mock_client_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
# Start server
|
||||
bridge.start()
|
||||
|
||||
# Verify socket was created
|
||||
assert temp_socket_path.exists()
|
||||
|
||||
# Stop server
|
||||
bridge.stop()
|
||||
|
||||
# Verify socket was removed
|
||||
assert not temp_socket_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_and_forward_tool_call(temp_socket_path, mock_client_manager, mock_audit_logger):
|
||||
"""Test receiving tool call request and forwarding to client."""
|
||||
bridge = ToolBridgeServer(
|
||||
socket_path=temp_socket_path,
|
||||
client_manager=mock_client_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
bridge.start()
|
||||
|
||||
try:
|
||||
# Connect as client and send tool call request
|
||||
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client_sock.connect(str(temp_socket_path))
|
||||
|
||||
request = {
|
||||
"tool": "test_tool",
|
||||
"params": {"arg1": "value1", "arg2": 42}
|
||||
}
|
||||
|
||||
client_sock.sendall(json.dumps(request).encode('utf-8'))
|
||||
client_sock.shutdown(socket.SHUT_WR)
|
||||
|
||||
# Receive response
|
||||
response_data = b''
|
||||
while True:
|
||||
chunk = client_sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response_data += chunk
|
||||
|
||||
response = json.loads(response_data.decode('utf-8'))
|
||||
|
||||
# Verify response
|
||||
assert response["success"] is True
|
||||
assert response["result"] == {"result": "success"}
|
||||
|
||||
# Verify tool was called with correct arguments
|
||||
mock_client_manager.call_tool.assert_called_once_with(
|
||||
"test_tool",
|
||||
{"arg1": "value1", "arg2": 42}
|
||||
)
|
||||
|
||||
# Verify audit log was called
|
||||
mock_audit_logger.log.assert_called()
|
||||
|
||||
client_sock.close()
|
||||
finally:
|
||||
bridge.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_tool_call_error(temp_socket_path, mock_client_manager, mock_audit_logger):
|
||||
"""Test handling tool call errors."""
|
||||
# Make client manager raise error
|
||||
mock_client_manager.call_tool = AsyncMock(side_effect=RuntimeError("Tool failed"))
|
||||
|
||||
bridge = ToolBridgeServer(
|
||||
socket_path=temp_socket_path,
|
||||
client_manager=mock_client_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
bridge.start()
|
||||
|
||||
try:
|
||||
# Connect and send request
|
||||
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client_sock.connect(str(temp_socket_path))
|
||||
|
||||
request = {"tool": "failing_tool", "params": {}}
|
||||
client_sock.sendall(json.dumps(request).encode('utf-8'))
|
||||
client_sock.shutdown(socket.SHUT_WR)
|
||||
|
||||
# Receive response
|
||||
response_data = b''
|
||||
while True:
|
||||
chunk = client_sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response_data += chunk
|
||||
|
||||
response = json.loads(response_data.decode('utf-8'))
|
||||
|
||||
# Verify error response
|
||||
assert response["success"] is False
|
||||
assert "Tool failed" in response["error"]
|
||||
|
||||
client_sock.close()
|
||||
finally:
|
||||
bridge.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_invalid_json(temp_socket_path, mock_client_manager, mock_audit_logger):
|
||||
"""Test handling invalid JSON in request."""
|
||||
bridge = ToolBridgeServer(
|
||||
socket_path=temp_socket_path,
|
||||
client_manager=mock_client_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
bridge.start()
|
||||
|
||||
try:
|
||||
# Connect and send invalid JSON
|
||||
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client_sock.connect(str(temp_socket_path))
|
||||
|
||||
client_sock.sendall(b"not valid json")
|
||||
client_sock.shutdown(socket.SHUT_WR)
|
||||
|
||||
# Receive response
|
||||
response_data = b''
|
||||
while True:
|
||||
chunk = client_sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response_data += chunk
|
||||
|
||||
response = json.loads(response_data.decode('utf-8'))
|
||||
|
||||
# Verify error response
|
||||
assert response["success"] is False
|
||||
assert "Invalid JSON" in response["error"]
|
||||
|
||||
client_sock.close()
|
||||
finally:
|
||||
bridge.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_missing_tool_field(temp_socket_path, mock_client_manager, mock_audit_logger):
|
||||
"""Test handling request missing 'tool' field."""
|
||||
bridge = ToolBridgeServer(
|
||||
socket_path=temp_socket_path,
|
||||
client_manager=mock_client_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
bridge.start()
|
||||
|
||||
try:
|
||||
# Connect and send request without 'tool' field
|
||||
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client_sock.connect(str(temp_socket_path))
|
||||
|
||||
request = {"params": {"arg1": "value1"}} # Missing 'tool'
|
||||
client_sock.sendall(json.dumps(request).encode('utf-8'))
|
||||
client_sock.shutdown(socket.SHUT_WR)
|
||||
|
||||
# Receive response
|
||||
response_data = b''
|
||||
while True:
|
||||
chunk = client_sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response_data += chunk
|
||||
|
||||
response = json.loads(response_data.decode('utf-8'))
|
||||
|
||||
# Verify error response
|
||||
assert response["success"] is False
|
||||
assert "tool" in response["error"].lower()
|
||||
|
||||
client_sock.close()
|
||||
finally:
|
||||
bridge.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_requests(temp_socket_path, mock_client_manager, mock_audit_logger):
|
||||
"""Test handling multiple concurrent requests."""
|
||||
import threading
|
||||
|
||||
# Track call counts
|
||||
call_count = {"count": 0}
|
||||
|
||||
async def mock_call_tool(tool_name, arguments):
|
||||
call_count["count"] += 1
|
||||
return {"result": f"success_{call_count['count']}"}
|
||||
|
||||
mock_client_manager.call_tool = mock_call_tool
|
||||
|
||||
bridge = ToolBridgeServer(
|
||||
socket_path=temp_socket_path,
|
||||
client_manager=mock_client_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
bridge.start()
|
||||
|
||||
try:
|
||||
results = []
|
||||
|
||||
def make_request(tool_name):
|
||||
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client_sock.connect(str(temp_socket_path))
|
||||
|
||||
request = {"tool": tool_name, "params": {}}
|
||||
client_sock.sendall(json.dumps(request).encode('utf-8'))
|
||||
client_sock.shutdown(socket.SHUT_WR)
|
||||
|
||||
response_data = b''
|
||||
while True:
|
||||
chunk = client_sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response_data += chunk
|
||||
|
||||
response = json.loads(response_data.decode('utf-8'))
|
||||
results.append(response)
|
||||
client_sock.close()
|
||||
|
||||
# Make 3 concurrent requests
|
||||
threads = []
|
||||
for i in range(3):
|
||||
thread = threading.Thread(target=make_request, args=(f"tool_{i}",))
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
# Wait for all threads
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# Verify all requests succeeded
|
||||
assert len(results) == 3
|
||||
for result in results:
|
||||
assert result["success"] is True
|
||||
|
||||
# Verify all were processed
|
||||
assert call_count["count"] == 3
|
||||
finally:
|
||||
bridge.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_logging_tool_name_only(temp_socket_path, mock_client_manager, mock_audit_logger):
|
||||
"""Test that audit log only logs tool name, not parameters."""
|
||||
bridge = ToolBridgeServer(
|
||||
socket_path=temp_socket_path,
|
||||
client_manager=mock_client_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
bridge.start()
|
||||
|
||||
try:
|
||||
# Send request with sensitive parameters
|
||||
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client_sock.connect(str(temp_socket_path))
|
||||
|
||||
request = {
|
||||
"tool": "sensitive_tool",
|
||||
"params": {"password": "secret123", "token": "abc123"}
|
||||
}
|
||||
|
||||
client_sock.sendall(json.dumps(request).encode('utf-8'))
|
||||
client_sock.shutdown(socket.SHUT_WR)
|
||||
|
||||
# Receive response
|
||||
response_data = b''
|
||||
while True:
|
||||
chunk = client_sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response_data += chunk
|
||||
|
||||
client_sock.close()
|
||||
|
||||
# Verify audit log was called
|
||||
mock_audit_logger.log.assert_called()
|
||||
|
||||
# Get the log call arguments
|
||||
log_call = mock_audit_logger.log.call_args
|
||||
|
||||
# Verify tool name is in log
|
||||
log_str = str(log_call)
|
||||
assert "sensitive_tool" in log_str
|
||||
|
||||
# Verify sensitive parameters are NOT in log
|
||||
assert "secret123" not in log_str
|
||||
assert "abc123" not in log_str
|
||||
finally:
|
||||
bridge.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_cleanup_on_error(temp_socket_path, mock_client_manager, mock_audit_logger):
|
||||
"""Test that socket is cleaned up even if server encounters error."""
|
||||
bridge = ToolBridgeServer(
|
||||
socket_path=temp_socket_path,
|
||||
client_manager=mock_client_manager,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
bridge.start()
|
||||
assert temp_socket_path.exists()
|
||||
|
||||
# Stop should cleanup
|
||||
bridge.stop()
|
||||
assert not temp_socket_path.exists()
|
||||
249
tests/mcp/test_client.py
Normal file
249
tests/mcp/test_client.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""Tests for MCP Client Wrapper."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from mcp_forge.mcp.client import MCPClientWrapper
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_fastmcp_client():
|
||||
"""Mock fastmcp Client."""
|
||||
client = AsyncMock()
|
||||
|
||||
# Create tool mocks with actual string names (not Mock.name)
|
||||
tool1 = Mock()
|
||||
tool1.name = "tool1"
|
||||
tool1.description = "Tool 1"
|
||||
tool1.inputSchema = {"type": "object", "properties": {}}
|
||||
|
||||
tool2 = Mock()
|
||||
tool2.name = "tool2"
|
||||
tool2.description = "Tool 2"
|
||||
tool2.inputSchema = {"type": "object", "properties": {}}
|
||||
|
||||
# Mock list_tools to return tool objects
|
||||
client.list_tools = AsyncMock(return_value=Mock(tools=[tool1, tool2]))
|
||||
|
||||
# Mock call_tool to return result with data
|
||||
client.call_tool = AsyncMock(return_value=Mock(
|
||||
data="result",
|
||||
content=[Mock(text="result")],
|
||||
is_error=False
|
||||
))
|
||||
|
||||
# Mock context manager
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_success(mock_fastmcp_client):
|
||||
"""Test successful connection to MCP server."""
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
assert not client.is_connected()
|
||||
|
||||
await client.connect()
|
||||
|
||||
assert client.is_connected()
|
||||
mock_fastmcp_client.__aenter__.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_with_env(mock_fastmcp_client):
|
||||
"""Test connection with environment variables."""
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
|
||||
env = {"API_KEY": "test123", "DEBUG": "true"}
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"],
|
||||
env=env
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
|
||||
assert client.is_connected()
|
||||
assert client.env == env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect(mock_fastmcp_client):
|
||||
"""Test disconnect from MCP server."""
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
assert client.is_connected()
|
||||
|
||||
await client.disconnect()
|
||||
|
||||
assert not client.is_connected()
|
||||
mock_fastmcp_client.__aexit__.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools(mock_fastmcp_client):
|
||||
"""Test listing available tools."""
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
tools = await client.list_tools()
|
||||
|
||||
assert tools == ["tool1", "tool2"]
|
||||
mock_fastmcp_client.list_tools.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_not_connected():
|
||||
"""Test list_tools raises error when not connected."""
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
await client.list_tools()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_schema(mock_fastmcp_client):
|
||||
"""Test getting tool schema."""
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
schema = await client.get_tool_schema("tool1")
|
||||
|
||||
assert schema == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_schema_not_found(mock_fastmcp_client):
|
||||
"""Test get_tool_schema raises error for unknown tool."""
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
|
||||
with pytest.raises(KeyError, match="Tool 'unknown' not found"):
|
||||
await client.get_tool_schema("unknown")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_success(mock_fastmcp_client):
|
||||
"""Test successful tool call."""
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
result = await client.call_tool("tool1", {"param": "value"})
|
||||
|
||||
assert result == "result"
|
||||
mock_fastmcp_client.call_tool.assert_called_once_with("tool1", {"param": "value"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_not_connected():
|
||||
"""Test call_tool raises error when not connected."""
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
await client.call_tool("tool1", {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_failure(mock_fastmcp_client):
|
||||
"""Test tool call failure handling."""
|
||||
mock_fastmcp_client.call_tool = AsyncMock(side_effect=Exception("Tool failed"))
|
||||
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Tool call failed.*Tool failed"):
|
||||
await client.call_tool("tool1", {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_failure():
|
||||
"""Test connection failure handling."""
|
||||
failing_client = AsyncMock()
|
||||
failing_client.__aenter__ = AsyncMock(side_effect=Exception("Connection failed"))
|
||||
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=failing_client):
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to connect.*Connection failed"):
|
||||
await client.connect()
|
||||
|
||||
assert not client.is_connected()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect(mock_fastmcp_client):
|
||||
"""Test reconnection after disconnect."""
|
||||
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
|
||||
client = MCPClientWrapper(
|
||||
name="test-server",
|
||||
command="python",
|
||||
args=["-m", "test_server"]
|
||||
)
|
||||
|
||||
# First connection
|
||||
await client.connect()
|
||||
assert client.is_connected()
|
||||
|
||||
# Disconnect
|
||||
await client.disconnect()
|
||||
assert not client.is_connected()
|
||||
|
||||
# Reconnect
|
||||
await client.connect()
|
||||
assert client.is_connected()
|
||||
|
||||
# Should be able to use tools
|
||||
tools = await client.list_tools()
|
||||
assert tools == ["tool1", "tool2"]
|
||||
114
tests/mcp/test_http_transport.py
Normal file
114
tests/mcp/test_http_transport.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Tests for HTTP and SSE transport support in MCP client."""
|
||||
|
||||
import pytest
|
||||
from mcp_forge.mcp.client import MCPClientWrapper
|
||||
|
||||
|
||||
def test_stdio_transport_creation():
|
||||
"""Test creating a client with stdio transport."""
|
||||
client = MCPClientWrapper(
|
||||
name="test_stdio",
|
||||
transport_type="stdio",
|
||||
command="python",
|
||||
args=["-m", "server"],
|
||||
env={"KEY": "value"}
|
||||
)
|
||||
|
||||
assert client.name == "test_stdio"
|
||||
assert client.transport_type == "stdio"
|
||||
assert client.command == "python"
|
||||
assert client.args == ["-m", "server"]
|
||||
assert client.env == {"KEY": "value"}
|
||||
assert client.transport is not None
|
||||
|
||||
|
||||
def test_http_transport_creation():
|
||||
"""Test creating a client with HTTP transport."""
|
||||
client = MCPClientWrapper(
|
||||
name="test_http",
|
||||
transport_type="http",
|
||||
url="http://localhost:8006/mcp",
|
||||
headers={"Authorization": "Bearer token123"}
|
||||
)
|
||||
|
||||
assert client.name == "test_http"
|
||||
assert client.transport_type == "http"
|
||||
assert client.url == "http://localhost:8006/mcp"
|
||||
assert client.headers == {"Authorization": "Bearer token123"}
|
||||
assert client.transport is not None
|
||||
|
||||
|
||||
def test_sse_transport_creation():
|
||||
"""Test creating a client with SSE transport."""
|
||||
client = MCPClientWrapper(
|
||||
name="test_sse",
|
||||
transport_type="sse",
|
||||
url="http://localhost:9000/events",
|
||||
headers={"X-Custom": "value"}
|
||||
)
|
||||
|
||||
assert client.name == "test_sse"
|
||||
assert client.transport_type == "sse"
|
||||
assert client.url == "http://localhost:9000/events"
|
||||
assert client.headers == {"X-Custom": "value"}
|
||||
assert client.transport is not None
|
||||
|
||||
|
||||
def test_stdio_without_command_raises_error():
|
||||
"""Test that stdio transport requires a command."""
|
||||
with pytest.raises(ValueError, match="command required for stdio transport"):
|
||||
MCPClientWrapper(
|
||||
name="test_stdio",
|
||||
transport_type="stdio"
|
||||
)
|
||||
|
||||
|
||||
def test_http_without_url_raises_error():
|
||||
"""Test that HTTP transport requires a URL."""
|
||||
with pytest.raises(ValueError, match="url required for http transport"):
|
||||
MCPClientWrapper(
|
||||
name="test_http",
|
||||
transport_type="http"
|
||||
)
|
||||
|
||||
|
||||
def test_sse_without_url_raises_error():
|
||||
"""Test that SSE transport requires a URL."""
|
||||
with pytest.raises(ValueError, match="url required for sse transport"):
|
||||
MCPClientWrapper(
|
||||
name="test_sse",
|
||||
transport_type="sse"
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_transport_type_raises_error():
|
||||
"""Test that an invalid transport type raises an error."""
|
||||
with pytest.raises(ValueError, match="Unknown transport type"):
|
||||
MCPClientWrapper(
|
||||
name="test_invalid",
|
||||
transport_type="invalid"
|
||||
)
|
||||
|
||||
|
||||
def test_http_transport_with_empty_headers():
|
||||
"""Test HTTP transport with empty headers dict."""
|
||||
client = MCPClientWrapper(
|
||||
name="test_http",
|
||||
transport_type="http",
|
||||
url="http://localhost:8006/mcp"
|
||||
)
|
||||
|
||||
assert client.headers == {}
|
||||
assert client.transport is not None
|
||||
|
||||
|
||||
def test_default_stdio_transport():
|
||||
"""Test that stdio is the default transport type."""
|
||||
client = MCPClientWrapper(
|
||||
name="test_default",
|
||||
command="python",
|
||||
args=["-m", "server"]
|
||||
)
|
||||
|
||||
assert client.transport_type == "stdio"
|
||||
assert client.command == "python"
|
||||
227
tests/mcp/test_injection.py
Normal file
227
tests/mcp/test_injection.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Tests for Tool Injection Generator."""
|
||||
|
||||
import pytest
|
||||
import ast
|
||||
from unittest.mock import AsyncMock
|
||||
from mcp_forge.mcp.injection import ToolInjectionGenerator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client_manager():
|
||||
"""Create mock MCP client manager with tools."""
|
||||
manager = AsyncMock()
|
||||
|
||||
# Tool schemas
|
||||
manager.get_tool_schema = AsyncMock(side_effect=lambda tool_name: {
|
||||
"read_file": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to read"},
|
||||
"encoding": {"type": "string", "description": "File encoding"}
|
||||
},
|
||||
"required": ["path"]
|
||||
},
|
||||
"write_file": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to write"},
|
||||
"content": {"type": "string", "description": "Content to write"},
|
||||
"mode": {"type": "string", "description": "Write mode"}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
},
|
||||
"calculate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {"type": "string", "description": "Math expression"},
|
||||
"precision": {"type": "integer", "description": "Decimal precision"}
|
||||
},
|
||||
"required": ["expression"]
|
||||
}
|
||||
}[tool_name])
|
||||
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_injection_code_is_valid_python(mock_client_manager):
|
||||
"""Test that generated code is valid Python."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["read_file", "write_file"],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# Try to parse the generated code
|
||||
try:
|
||||
ast.parse(code)
|
||||
except SyntaxError as e:
|
||||
pytest.fail(f"Generated code has syntax error: {e}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_code_includes_bridge_client(mock_client_manager):
|
||||
"""Test that generated code includes bridge client."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["read_file"],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# Verify bridge client function is included
|
||||
assert "_mcp_call" in code
|
||||
assert "socket.socket" in code
|
||||
assert "socket.AF_UNIX" in code
|
||||
assert "/tmp/bridge.sock" in code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_code_includes_tool_functions(mock_client_manager):
|
||||
"""Test that generated code includes wrapper functions for each tool."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["read_file", "write_file", "calculate"],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# Verify tool functions are defined
|
||||
assert "def read_file(" in code
|
||||
assert "def write_file(" in code
|
||||
assert "def calculate(" in code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_signatures_match_schemas(mock_client_manager):
|
||||
"""Test that function signatures match tool schemas."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["read_file", "write_file"],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# read_file has path (required) and encoding (optional)
|
||||
assert "def read_file(path: str, encoding: str = None)" in code
|
||||
|
||||
# write_file has path, content (required) and mode (optional)
|
||||
assert "def write_file(path: str, content: str, mode: str = None)" in code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_functions_have_docstrings(mock_client_manager):
|
||||
"""Test that generated functions have docstrings."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["read_file"],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# Verify docstring is present (contains parameter descriptions)
|
||||
assert '"""' in code
|
||||
assert "File path to read" in code or "path:" in code.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_functions_call_bridge(mock_client_manager):
|
||||
"""Test that generated functions call _mcp_call."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["read_file"],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# Verify function calls _mcp_call with tool name
|
||||
lines = code.split('\n')
|
||||
in_read_file = False
|
||||
found_call = False
|
||||
|
||||
for line in lines:
|
||||
if "def read_file(" in line:
|
||||
in_read_file = True
|
||||
if in_read_file and "_mcp_call" in line and "read_file" in line:
|
||||
found_call = True
|
||||
break
|
||||
|
||||
assert found_call, "Generated function should call _mcp_call with tool name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_type_hints_from_schema(mock_client_manager):
|
||||
"""Test that type hints are generated from schema types."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["calculate"],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# calculate has string expression and integer precision
|
||||
assert "expression: str" in code
|
||||
assert "precision: int" in code or "precision: " in code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_required_vs_optional_parameters(mock_client_manager):
|
||||
"""Test that required and optional parameters are handled correctly."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["read_file"],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# path is required (no default), encoding is optional (has default)
|
||||
assert "def read_file(path: str, encoding: str = None)" in code or \
|
||||
"def read_file(path: str, encoding: " in code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_code_has_imports(mock_client_manager):
|
||||
"""Test that generated code includes necessary imports."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["read_file"],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# Verify imports
|
||||
assert "import socket" in code
|
||||
assert "import json" in code
|
||||
assert "from typing import Any" in code or "typing" in code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_tool_list(mock_client_manager):
|
||||
"""Test handling of empty tool list."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=[],
|
||||
bridge_socket_path="/tmp/bridge.sock"
|
||||
)
|
||||
|
||||
# Should still include bridge client
|
||||
assert "_mcp_call" in code
|
||||
# But no tool functions
|
||||
assert "def read_file(" not in code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_socket_path(mock_client_manager):
|
||||
"""Test that custom socket path is used correctly."""
|
||||
generator = ToolInjectionGenerator(mock_client_manager)
|
||||
|
||||
custom_path = "/custom/path/to/socket.sock"
|
||||
code = await generator.generate_injection_code(
|
||||
tool_names=["read_file"],
|
||||
bridge_socket_path=custom_path
|
||||
)
|
||||
|
||||
# Verify custom path is in generated code
|
||||
assert custom_path in code
|
||||
245
tests/mcp/test_manager.py
Normal file
245
tests/mcp/test_manager.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""Tests for MCP Client Manager."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from mcp_forge.mcp.manager import MCPClientManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client1():
|
||||
"""Create mock MCP client 1."""
|
||||
client = AsyncMock()
|
||||
client.name = "client1"
|
||||
client.is_connected.return_value = False
|
||||
client.connect = AsyncMock()
|
||||
client.disconnect = AsyncMock()
|
||||
client.list_tools = AsyncMock(return_value=["tool1", "tool2"])
|
||||
client.get_tool_schema = AsyncMock(return_value={
|
||||
"type": "object",
|
||||
"properties": {"param1": {"type": "string"}}
|
||||
})
|
||||
client.call_tool = AsyncMock(return_value={"result": "success"})
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client2():
|
||||
"""Create mock MCP client 2."""
|
||||
client = AsyncMock()
|
||||
client.name = "client2"
|
||||
client.is_connected.return_value = False
|
||||
client.connect = AsyncMock()
|
||||
client.disconnect = AsyncMock()
|
||||
client.list_tools = AsyncMock(return_value=["tool3", "tool4"])
|
||||
client.get_tool_schema = AsyncMock(return_value={
|
||||
"type": "object",
|
||||
"properties": {"param2": {"type": "number"}}
|
||||
})
|
||||
client.call_tool = AsyncMock(return_value={"result": "success2"})
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_config():
|
||||
"""Create test client configuration."""
|
||||
return {
|
||||
"client1": {
|
||||
"command": "python",
|
||||
"args": ["server1.py"],
|
||||
"env": {"KEY1": "value1"}
|
||||
},
|
||||
"client2": {
|
||||
"command": "python",
|
||||
"args": ["server2.py"],
|
||||
"env": {"KEY2": "value2"}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_clients_from_config(mock_client1, mock_client2, client_config):
|
||||
"""Test initializing clients from configuration."""
|
||||
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
|
||||
# Setup mock to return different clients for different configs
|
||||
mock_wrapper.side_effect = [mock_client1, mock_client2]
|
||||
|
||||
manager = MCPClientManager(client_config)
|
||||
await manager.initialize()
|
||||
|
||||
# Verify clients were created with correct config
|
||||
assert mock_wrapper.call_count == 2
|
||||
|
||||
# Verify first client created with correct params
|
||||
call1 = mock_wrapper.call_args_list[0]
|
||||
assert call1[1]["name"] == "client1"
|
||||
assert call1[1]["command"] == "python"
|
||||
assert call1[1]["args"] == ["server1.py"]
|
||||
assert call1[1]["env"] == {"KEY1": "value1"}
|
||||
|
||||
# Verify second client created with correct params
|
||||
call2 = mock_wrapper.call_args_list[1]
|
||||
assert call2[1]["name"] == "client2"
|
||||
assert call2[1]["command"] == "python"
|
||||
assert call2[1]["args"] == ["server2.py"]
|
||||
assert call2[1]["env"] == {"KEY2": "value2"}
|
||||
|
||||
# Verify clients were connected
|
||||
mock_client1.connect.assert_called_once()
|
||||
mock_client2.connect.assert_called_once()
|
||||
|
||||
# Verify tools were listed
|
||||
mock_client1.list_tools.assert_called_once()
|
||||
mock_client2.list_tools.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_for_tool(mock_client1, mock_client2, client_config):
|
||||
"""Test getting client that provides a specific tool."""
|
||||
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
|
||||
mock_wrapper.side_effect = [mock_client1, mock_client2]
|
||||
|
||||
manager = MCPClientManager(client_config)
|
||||
await manager.initialize()
|
||||
|
||||
# Get client for tool1 (from client1)
|
||||
client = await manager.get_client_for_tool("tool1")
|
||||
assert client == mock_client1
|
||||
|
||||
# Get client for tool3 (from client2)
|
||||
client = await manager.get_client_for_tool("tool3")
|
||||
assert client == mock_client2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_for_unknown_tool(mock_client1, mock_client2, client_config):
|
||||
"""Test getting client for tool that doesn't exist."""
|
||||
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
|
||||
mock_wrapper.side_effect = [mock_client1, mock_client2]
|
||||
|
||||
manager = MCPClientManager(client_config)
|
||||
await manager.initialize()
|
||||
|
||||
# Try to get client for non-existent tool
|
||||
with pytest.raises(KeyError, match="Tool 'unknown_tool' not found"):
|
||||
await manager.get_client_for_tool("unknown_tool")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_tools(mock_client1, mock_client2, client_config):
|
||||
"""Test listing all tools across all clients."""
|
||||
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
|
||||
mock_wrapper.side_effect = [mock_client1, mock_client2]
|
||||
|
||||
manager = MCPClientManager(client_config)
|
||||
await manager.initialize()
|
||||
|
||||
tools = await manager.list_all_tools()
|
||||
|
||||
# Should have all tools from both clients
|
||||
assert set(tools) == {"tool1", "tool2", "tool3", "tool4"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_tool_name_collision():
|
||||
"""Test detection of tool name collisions across clients."""
|
||||
# Create clients with overlapping tool names
|
||||
client1 = AsyncMock()
|
||||
client1.name = "client1"
|
||||
client1.connect = AsyncMock()
|
||||
client1.list_tools = AsyncMock(return_value=["tool1", "tool2"])
|
||||
|
||||
client2 = AsyncMock()
|
||||
client2.name = "client2"
|
||||
client2.connect = AsyncMock()
|
||||
client2.list_tools = AsyncMock(return_value=["tool2", "tool3"]) # tool2 collision!
|
||||
|
||||
config = {
|
||||
"client1": {"command": "python", "args": ["server1.py"]},
|
||||
"client2": {"command": "python", "args": ["server2.py"]}
|
||||
}
|
||||
|
||||
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
|
||||
mock_wrapper.side_effect = [client1, client2]
|
||||
|
||||
manager = MCPClientManager(config)
|
||||
|
||||
# Should raise ValueError about collision during initialization
|
||||
with pytest.raises(ValueError, match="Tool name collision.*tool2.*client1.*client2"):
|
||||
await manager.initialize()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_schema(mock_client1, mock_client2, client_config):
|
||||
"""Test getting tool schema via manager."""
|
||||
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
|
||||
mock_wrapper.side_effect = [mock_client1, mock_client2]
|
||||
|
||||
manager = MCPClientManager(client_config)
|
||||
await manager.initialize()
|
||||
|
||||
# Get schema for tool1 (from client1)
|
||||
schema = await manager.get_tool_schema("tool1")
|
||||
assert schema == {"type": "object", "properties": {"param1": {"type": "string"}}}
|
||||
mock_client1.get_tool_schema.assert_called_once_with("tool1")
|
||||
|
||||
# Get schema for tool3 (from client2)
|
||||
schema = await manager.get_tool_schema("tool3")
|
||||
assert schema == {"type": "object", "properties": {"param2": {"type": "number"}}}
|
||||
mock_client2.get_tool_schema.assert_called_once_with("tool3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool(mock_client1, mock_client2, client_config):
|
||||
"""Test calling tool via manager."""
|
||||
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
|
||||
mock_wrapper.side_effect = [mock_client1, mock_client2]
|
||||
|
||||
manager = MCPClientManager(client_config)
|
||||
await manager.initialize()
|
||||
|
||||
# Call tool1 (from client1)
|
||||
result = await manager.call_tool("tool1", {"param1": "value"})
|
||||
assert result == {"result": "success"}
|
||||
mock_client1.call_tool.assert_called_once_with("tool1", {"param1": "value"})
|
||||
|
||||
# Call tool3 (from client2)
|
||||
result = await manager.call_tool("tool3", {"param2": 42})
|
||||
assert result == {"result": "success2"}
|
||||
mock_client2.call_tool.assert_called_once_with("tool3", {"param2": 42})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_all_clients(mock_client1, mock_client2, client_config):
|
||||
"""Test shutting down all clients."""
|
||||
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
|
||||
mock_wrapper.side_effect = [mock_client1, mock_client2]
|
||||
|
||||
manager = MCPClientManager(client_config)
|
||||
await manager.initialize()
|
||||
|
||||
# Shutdown all clients
|
||||
await manager.shutdown()
|
||||
|
||||
# Verify both clients were disconnected
|
||||
mock_client1.disconnect.assert_called_once()
|
||||
mock_client2.disconnect.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_before_initialization():
|
||||
"""Test that manager methods fail before initialization."""
|
||||
config = {"client1": {"command": "python", "args": ["server.py"]}}
|
||||
manager = MCPClientManager(config)
|
||||
|
||||
# Should raise RuntimeError if not initialized
|
||||
with pytest.raises(RuntimeError, match="Manager not initialized"):
|
||||
await manager.list_all_tools()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Manager not initialized"):
|
||||
await manager.get_client_for_tool("tool1")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Manager not initialized"):
|
||||
await manager.get_tool_schema("tool1")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Manager not initialized"):
|
||||
await manager.call_tool("tool1", {})
|
||||
433
tests/podman/test_containers.py
Normal file
433
tests/podman/test_containers.py
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
"""
|
||||
Tests for Secure Container Manager.
|
||||
|
||||
Following TDD approach - these tests are written before implementation.
|
||||
Tests cover all requirements from todo.md section 1.3.2.
|
||||
All tests use mocked Podman client (no actual containers needed).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, Mock, patch, call
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_create_container_with_valid_params_succeeds():
|
||||
"""Test that container creation with valid params succeeds."""
|
||||
from mcp_forge.podman.containers import ContainerConfig, SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
# Setup mocks
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
mock_container = MagicMock()
|
||||
mock_container.id = "abc123"
|
||||
mock_podman_client.client.containers.create.return_value = mock_container
|
||||
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
config = ContainerConfig(
|
||||
image="mcp-forge/python:3.11",
|
||||
command=["python", "-c", "print('hello')"],
|
||||
resource_limits=ResourceLimits(memory="512m", storage="1g", cpu_quota=100000)
|
||||
)
|
||||
|
||||
container_id = manager.create_container(config, session_id="test-session-1")
|
||||
|
||||
assert container_id == "abc123"
|
||||
mock_podman_client.client.containers.create.assert_called_once()
|
||||
|
||||
|
||||
def test_create_container_with_forbidden_params_raises_security_error():
|
||||
"""Test that forbidden parameters raise SecurityError."""
|
||||
from mcp_forge.podman.containers import ContainerConfig, SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
config = ContainerConfig(
|
||||
image="mcp-forge/python:3.11",
|
||||
command=["python", "-c", "print('hello')"]
|
||||
)
|
||||
|
||||
# Try to override security params (should be caught in to_podman_params or validation)
|
||||
with pytest.raises(SecurityError):
|
||||
# This should fail validation
|
||||
manager.create_container(config, session_id="test-session", privileged=True)
|
||||
|
||||
|
||||
def test_create_container_with_invalid_image_raises_security_error():
|
||||
"""Test that invalid/disallowed images raise SecurityError."""
|
||||
from mcp_forge.podman.containers import ContainerConfig, SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
config = ContainerConfig(
|
||||
image="evil/malicious:latest",
|
||||
command=["python", "-c", "print('hello')"]
|
||||
)
|
||||
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
manager.create_container(config, session_id="test-session")
|
||||
assert "image" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_create_container_enforces_required_parameters():
|
||||
"""Test that required security parameters are enforced."""
|
||||
from mcp_forge.podman.containers import ContainerConfig
|
||||
|
||||
config = ContainerConfig(
|
||||
image="mcp-forge/python:3.11",
|
||||
command=["python", "-c", "print('hello')"]
|
||||
)
|
||||
|
||||
params = config.to_podman_params()
|
||||
|
||||
# Check required security parameters
|
||||
assert params["network_mode"] == "none"
|
||||
assert params["read_only"] is True
|
||||
assert "no-new-privileges" in params["security_opt"]
|
||||
assert params["user"] == "1000:1000"
|
||||
|
||||
|
||||
def test_resource_limits_are_applied_correctly():
|
||||
"""Test that resource limits are correctly applied."""
|
||||
from mcp_forge.podman.containers import ContainerConfig
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
limits = ResourceLimits(
|
||||
memory="1g",
|
||||
storage="2g",
|
||||
cpu_quota=200000
|
||||
)
|
||||
|
||||
config = ContainerConfig(
|
||||
image="mcp-forge/python:3.11",
|
||||
resource_limits=limits
|
||||
)
|
||||
|
||||
params = config.to_podman_params()
|
||||
|
||||
assert params["mem_limit"] == "1073741824" # 1GB in bytes as string
|
||||
assert params["cpu_quota"] == 200000
|
||||
assert params["storage_opt"]["size"] == "2147483648" # 2GB in bytes as string
|
||||
|
||||
|
||||
def test_volume_mounts_are_validated():
|
||||
"""Test that volume mounts are validated against allowlist."""
|
||||
from mcp_forge.podman.containers import ContainerConfig, SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
# Valid mount (session path)
|
||||
config = ContainerConfig(
|
||||
image="mcp-forge/python:3.11",
|
||||
volumes={"/mcp-forge/sessions/test-session-1/workspace": {"bind": "/workspace", "mode": "rw"}}
|
||||
)
|
||||
|
||||
# This should succeed (valid session path)
|
||||
mock_container = MagicMock()
|
||||
mock_container.id = "abc123"
|
||||
mock_podman_client.client.containers.create.return_value = mock_container
|
||||
container_id = manager.create_container(config, session_id="test-session-1")
|
||||
assert container_id == "abc123"
|
||||
|
||||
# Invalid mount (forbidden path)
|
||||
config_bad = ContainerConfig(
|
||||
image="mcp-forge/python:3.11",
|
||||
volumes={"/etc/passwd": {"bind": "/tmp/passwd", "mode": "r"}}
|
||||
)
|
||||
|
||||
with pytest.raises(SecurityError):
|
||||
manager.create_container(config_bad, session_id="test-session-1")
|
||||
|
||||
|
||||
def test_start_container_on_session_container_succeeds():
|
||||
"""Test that starting a session container succeeds."""
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
mock_container = MagicMock()
|
||||
mock_podman_client.client.containers.get.return_value = mock_container
|
||||
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
# Register container with validator
|
||||
validator.register_session_container("abc123")
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
manager.start_container("abc123")
|
||||
mock_container.start.assert_called_once()
|
||||
|
||||
|
||||
def test_start_container_on_non_session_container_raises_security_error():
|
||||
"""Test that starting a non-session container raises SecurityError."""
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
# Try to start container not registered with validator
|
||||
with pytest.raises(SecurityError):
|
||||
manager.start_container("unknown123")
|
||||
|
||||
|
||||
def test_stop_container_works():
|
||||
"""Test that stopping a container works."""
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
mock_container = MagicMock()
|
||||
mock_podman_client.client.containers.get.return_value = mock_container
|
||||
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
# Register container
|
||||
validator.register_session_container("abc123")
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
manager.stop_container("abc123", timeout=10)
|
||||
mock_container.stop.assert_called_once_with(timeout=10)
|
||||
|
||||
|
||||
def test_remove_container_works():
|
||||
"""Test that removing a container works."""
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
mock_container = MagicMock()
|
||||
mock_podman_client.client.containers.get.return_value = mock_container
|
||||
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
# Register container
|
||||
validator.register_session_container("abc123")
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
manager.remove_container("abc123", force=True)
|
||||
mock_container.remove.assert_called_once_with(force=True)
|
||||
|
||||
|
||||
def test_cleanup_old_containers():
|
||||
"""Test cleanup of old containers."""
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
|
||||
# Create mock old and new containers
|
||||
old_container = MagicMock()
|
||||
old_container.id = "old123"
|
||||
old_container.attrs = {
|
||||
"Created": (datetime.now() - timedelta(hours=25)).isoformat(),
|
||||
"Labels": {"mcp-forge.session": "old-session"}
|
||||
}
|
||||
|
||||
new_container = MagicMock()
|
||||
new_container.id = "new123"
|
||||
new_container.attrs = {
|
||||
"Created": datetime.now().isoformat(),
|
||||
"Labels": {"mcp-forge.session": "new-session"}
|
||||
}
|
||||
|
||||
mock_podman_client.client.containers.list.return_value = [old_container, new_container]
|
||||
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
count = manager.cleanup_old_containers(max_age=timedelta(hours=24))
|
||||
|
||||
assert count == 1
|
||||
old_container.remove.assert_called_once_with(force=True)
|
||||
new_container.remove.assert_not_called()
|
||||
|
||||
|
||||
def test_get_container_logs():
|
||||
"""Test getting container logs."""
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
mock_container = MagicMock()
|
||||
mock_container.logs.return_value = b"stdout output\nstderr output"
|
||||
mock_podman_client.client.containers.get.return_value = mock_container
|
||||
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
# Register container
|
||||
validator.register_session_container("abc123")
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
stdout, stderr = manager.get_container_logs("abc123", tail=100)
|
||||
|
||||
assert "output" in stdout or "output" in stderr
|
||||
mock_container.logs.assert_called()
|
||||
|
||||
|
||||
def test_wait_for_container():
|
||||
"""Test waiting for container to exit."""
|
||||
from mcp_forge.podman.containers import SecureContainerManager
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
mock_podman_client = MagicMock(spec=PodmanClient)
|
||||
mock_container = MagicMock()
|
||||
mock_container.wait.return_value = {"StatusCode": 0}
|
||||
mock_podman_client.client.containers.get.return_value = mock_container
|
||||
|
||||
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
|
||||
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
|
||||
|
||||
# Register container
|
||||
validator.register_session_container("abc123")
|
||||
|
||||
manager = SecureContainerManager(
|
||||
podman_client=mock_podman_client,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
exit_code = manager.wait_for_container("abc123", timeout=300)
|
||||
|
||||
assert exit_code == 0
|
||||
mock_container.wait.assert_called_once_with(timeout=300)
|
||||
|
||||
|
||||
def test_container_config_to_podman_params_includes_all_security_settings():
|
||||
"""Test that ContainerConfig.to_podman_params includes all required settings."""
|
||||
from mcp_forge.podman.containers import ContainerConfig
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
config = ContainerConfig(
|
||||
image="mcp-forge/python:3.11",
|
||||
command=["python", "-m", "test"],
|
||||
environment={"VAR1": "value1"},
|
||||
volumes={"/mcp-forge/sessions/sess-1/work": {"bind": "/workspace", "mode": "rw"}},
|
||||
resource_limits=ResourceLimits(memory="512m", storage="1g", cpu_quota=100000),
|
||||
working_dir="/workspace",
|
||||
user="1000:1000"
|
||||
)
|
||||
|
||||
params = config.to_podman_params()
|
||||
|
||||
# Required security settings
|
||||
assert params["network_mode"] == "none"
|
||||
assert params["read_only"] is True
|
||||
assert "no-new-privileges" in params["security_opt"]
|
||||
assert params["user"] == "1000:1000"
|
||||
|
||||
# Configuration passthrough
|
||||
assert params["image"] == "mcp-forge/python:3.11"
|
||||
assert params["command"] == ["python", "-m", "test"]
|
||||
assert params["environment"] == {"VAR1": "value1"}
|
||||
assert params["working_dir"] == "/workspace"
|
||||
|
||||
# Resource limits
|
||||
assert "mem_limit" in params
|
||||
assert "cpu_quota" in params
|
||||
324
tests/podman/test_podman_client.py
Normal file
324
tests/podman/test_podman_client.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""
|
||||
Tests for Podman client wrapper.
|
||||
|
||||
Following TDD approach - these tests are written before implementation.
|
||||
Tests cover all requirements from todo.md section 1.3.1.
|
||||
All tests use mocked Podman client (no actual Podman needed).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
|
||||
|
||||
def test_connection_to_podman_socket_succeeds(tmp_path):
|
||||
"""Test that connection to Podman socket succeeds."""
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "podman.sock"
|
||||
socket_path.touch() # Create fake socket file
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.ping.return_value = "OK"
|
||||
mock_podman.return_value = mock_client_instance
|
||||
|
||||
client.connect()
|
||||
assert client._client is not None
|
||||
mock_podman.assert_called_once_with(base_url=f"unix://{socket_path}")
|
||||
|
||||
|
||||
def test_connection_failure_raises_clear_error(tmp_path):
|
||||
"""Test that connection failure raises clear error."""
|
||||
from mcp_forge.podman.client import PodmanClient, PodmanConnectionError
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "nonexistent.sock"
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
with patch('mcp_forge.podman.client.BasePodmanClient', side_effect=Exception("Connection failed")):
|
||||
with pytest.raises(PodmanConnectionError) as exc_info:
|
||||
client.connect()
|
||||
assert "connection" in str(exc_info.value).lower() or "failed" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_socket_path_validation(tmp_path):
|
||||
"""Test that socket path is validated before connecting."""
|
||||
from mcp_forge.podman.client import PodmanClient, PodmanConnectionError
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
nonexistent_socket = tmp_path / "nonexistent.sock"
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=nonexistent_socket,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
with pytest.raises(PodmanConnectionError) as exc_info:
|
||||
client.verify_socket_access()
|
||||
assert "not found" in str(exc_info.value).lower() or "does not exist" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_socket_permissions_check(tmp_path):
|
||||
"""Test that socket permissions are checked."""
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "podman.sock"
|
||||
socket_path.touch()
|
||||
socket_path.chmod(0o000) # Remove all permissions
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
# verify_socket_access should check readability
|
||||
# Depending on implementation, might raise error or just warn
|
||||
try:
|
||||
client.verify_socket_access()
|
||||
except Exception as e:
|
||||
# Should mention permissions or access
|
||||
assert "permission" in str(e).lower() or "access" in str(e).lower() or "readable" in str(e).lower()
|
||||
finally:
|
||||
socket_path.chmod(0o644) # Restore for cleanup
|
||||
|
||||
|
||||
def test_api_version_compatibility_check(tmp_path):
|
||||
"""Test that API version is checked."""
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "podman.sock"
|
||||
socket_path.touch()
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
|
||||
mock_client = MagicMock()
|
||||
mock_client.version.return_value = {
|
||||
"Version": "4.5.0",
|
||||
"ApiVersion": "4.5.0"
|
||||
}
|
||||
mock_client.ping.return_value = "OK"
|
||||
mock_podman.return_value = mock_client
|
||||
|
||||
client.connect()
|
||||
version_info = client.check_api_version()
|
||||
|
||||
assert "Version" in version_info or "ApiVersion" in version_info
|
||||
|
||||
|
||||
def test_ping_health_check(tmp_path):
|
||||
"""Test that ping/health check works."""
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "podman.sock"
|
||||
socket_path.touch()
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ping.return_value = "OK"
|
||||
mock_podman.return_value = mock_client
|
||||
|
||||
client.connect()
|
||||
result = client.ping()
|
||||
|
||||
assert result is True or result == "OK"
|
||||
|
||||
|
||||
def test_lazy_connection(tmp_path):
|
||||
"""Test that connection is lazy (only connects when needed)."""
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "podman.sock"
|
||||
socket_path.touch()
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
# Creating client should not connect
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
assert client._client is None # Not connected yet
|
||||
|
||||
# Accessing client property should trigger connection
|
||||
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.ping.return_value = "OK"
|
||||
mock_podman.return_value = mock_client_instance
|
||||
_ = client.client
|
||||
assert client._client is not None
|
||||
|
||||
|
||||
def test_disconnect_cleanup(tmp_path):
|
||||
"""Test that disconnect cleans up properly."""
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "podman.sock"
|
||||
socket_path.touch()
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ping.return_value = "OK"
|
||||
mock_podman.return_value = mock_client
|
||||
|
||||
client.connect()
|
||||
assert client._client is not None
|
||||
|
||||
client.disconnect()
|
||||
assert client._client is None
|
||||
|
||||
|
||||
def test_connection_error_includes_socket_path(tmp_path):
|
||||
"""Test that connection errors include the socket path for debugging."""
|
||||
from mcp_forge.podman.client import PodmanClient, PodmanConnectionError
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "test.sock"
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
with pytest.raises(PodmanConnectionError) as exc_info:
|
||||
client.verify_socket_access()
|
||||
|
||||
assert str(socket_path) in str(exc_info.value) or socket_path.name in str(exc_info.value)
|
||||
|
||||
|
||||
def test_client_property_auto_connects(tmp_path):
|
||||
"""Test that accessing client property auto-connects if not connected."""
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "podman.sock"
|
||||
socket_path.touch()
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ping.return_value = "OK"
|
||||
mock_podman.return_value = mock_client
|
||||
|
||||
# First access should trigger connect
|
||||
_ = client.client
|
||||
assert mock_podman.called
|
||||
|
||||
# Second access should reuse connection
|
||||
mock_podman.reset_mock()
|
||||
_ = client.client
|
||||
assert not mock_podman.called # Should not connect again
|
||||
|
||||
|
||||
def test_validator_and_audit_logger_stored(tmp_path):
|
||||
"""Test that validator and audit logger are stored for later use."""
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
socket_path = tmp_path / "podman.sock"
|
||||
|
||||
audit_logger = AuditLogger(tmp_path / "audit.log")
|
||||
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
|
||||
|
||||
client = PodmanClient(
|
||||
socket_path=socket_path,
|
||||
validator=validator,
|
||||
audit_logger=audit_logger
|
||||
)
|
||||
|
||||
assert client.validator is validator
|
||||
assert client.audit_logger is audit_logger
|
||||
486
tests/security/test_allowlist.py
Normal file
486
tests/security/test_allowlist.py
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
"""
|
||||
Tests for Podman operation allowlist and validation.
|
||||
|
||||
Following TDD approach - these tests are written before implementation.
|
||||
Tests cover all security validation requirements from todo.md section 1.2.2.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_allowed_operation_with_valid_params_passes():
|
||||
"""Test that allowed operation with valid parameters passes validation."""
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(
|
||||
audit_log=Path("/var/log/audit.log"),
|
||||
enforce_resource_limits=True,
|
||||
allow_network=False
|
||||
)
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Should not raise
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"network_mode": "none",
|
||||
"read_only": True,
|
||||
"security_opt": ["no-new-privileges"],
|
||||
"user": "1000:1000",
|
||||
"memory": "536870912",
|
||||
"cpu_quota": 50000
|
||||
},
|
||||
session_id="test-session-123"
|
||||
)
|
||||
|
||||
|
||||
def test_allowed_operation_with_forbidden_params_raises_security_error():
|
||||
"""Test that allowed operation with forbidden parameters raises SecurityError."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(
|
||||
audit_log=Path("/var/log/audit.log"),
|
||||
enforce_resource_limits=True,
|
||||
allow_network=False
|
||||
)
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Try to add privileged mode
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"privileged": True, # FORBIDDEN
|
||||
"network_mode": "none",
|
||||
"read_only": True,
|
||||
"security_opt": ["no-new-privileges"],
|
||||
"user": "1000:1000"
|
||||
},
|
||||
session_id="test-session-123"
|
||||
)
|
||||
assert "privileged" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_forbidden_param_cap_add_raises_security_error():
|
||||
"""Test that cap_add parameter is rejected."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"cap_add": ["SYS_ADMIN"], # FORBIDDEN
|
||||
"network_mode": "none",
|
||||
"read_only": True
|
||||
},
|
||||
session_id="test-session"
|
||||
)
|
||||
assert "cap_add" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_forbidden_param_devices_raises_security_error():
|
||||
"""Test that devices parameter is rejected."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"devices": ["/dev/sda"], # FORBIDDEN
|
||||
"network_mode": "none",
|
||||
"read_only": True
|
||||
},
|
||||
session_id="test-session"
|
||||
)
|
||||
assert "devices" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_required_parameters_validation():
|
||||
"""Test that required parameters are enforced."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Missing network_mode
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"read_only": True,
|
||||
"security_opt": ["no-new-privileges"],
|
||||
"user": "1000:1000"
|
||||
},
|
||||
session_id="test-session"
|
||||
)
|
||||
assert "network_mode" in str(exc_info.value).lower()
|
||||
|
||||
# Missing read_only
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"network_mode": "none",
|
||||
"security_opt": ["no-new-privileges"],
|
||||
"user": "1000:1000"
|
||||
},
|
||||
session_id="test-session"
|
||||
)
|
||||
assert "read_only" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_image_allowlist_enforcement():
|
||||
"""Test that only allowed images can be used."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Valid image
|
||||
validator.validate_image_name("mcp-forge/python:3.11")
|
||||
validator.validate_image_name("mcp-forge/python:3.12")
|
||||
validator.validate_image_name("mcp-forge/jupyter:latest")
|
||||
validator.validate_image_name("mcp-forge/custom:my-env")
|
||||
|
||||
# Invalid image
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_image_name("ubuntu:latest")
|
||||
assert "image" in str(exc_info.value).lower() or "allowlist" in str(exc_info.value).lower()
|
||||
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_image_name("malicious/image:latest")
|
||||
|
||||
|
||||
def test_volume_mount_path_validation():
|
||||
"""Test that volume mount paths are validated against allowlist."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Valid session path
|
||||
validator.validate_volume_mount(
|
||||
"/mcp-forge/sessions/test-session-123/workdir",
|
||||
"test-session-123"
|
||||
)
|
||||
|
||||
# Valid shared readonly path
|
||||
validator.validate_volume_mount(
|
||||
"/mcp-forge/shared/readonly/data",
|
||||
"test-session-123"
|
||||
)
|
||||
|
||||
# Valid upload path
|
||||
validator.validate_volume_mount(
|
||||
"/mcp-forge/uploads/test-session-123/file.txt",
|
||||
"test-session-123"
|
||||
)
|
||||
|
||||
# Invalid: root path
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_volume_mount("/", "test-session")
|
||||
assert "forbidden" in str(exc_info.value).lower() or "root" in str(exc_info.value).lower()
|
||||
|
||||
# Invalid: /etc
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_volume_mount("/etc/passwd", "test-session")
|
||||
|
||||
# Invalid: docker socket
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_volume_mount("/var/run/docker.sock", "test-session")
|
||||
|
||||
# Invalid: podman socket
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_volume_mount("/var/run/podman/podman.sock", "test-session")
|
||||
|
||||
|
||||
def test_capability_restrictions():
|
||||
"""Test that capability restrictions are enforced."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# cap_add is forbidden
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"cap_add": ["NET_ADMIN"],
|
||||
"network_mode": "none",
|
||||
"read_only": True
|
||||
},
|
||||
session_id="test-session"
|
||||
)
|
||||
|
||||
|
||||
def test_network_mode_enforcement():
|
||||
"""Test that network mode is enforced as 'none'."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Wrong network mode
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"network_mode": "bridge", # Must be "none"
|
||||
"read_only": True,
|
||||
"security_opt": ["no-new-privileges"],
|
||||
"user": "1000:1000"
|
||||
},
|
||||
session_id="test-session"
|
||||
)
|
||||
assert "network_mode" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_privileged_mode_always_rejected():
|
||||
"""Test that privileged mode is always rejected regardless of other params."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"privileged": True,
|
||||
"network_mode": "none",
|
||||
"read_only": True,
|
||||
"security_opt": ["no-new-privileges"],
|
||||
"user": "1000:1000"
|
||||
},
|
||||
session_id="test-session"
|
||||
)
|
||||
assert "privileged" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_session_container_tracking():
|
||||
"""Test that session containers are tracked and validated."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Register a session container
|
||||
validator.register_session_container("container-123")
|
||||
|
||||
# Should be able to operate on registered container
|
||||
validator.validate_container_start("container-123")
|
||||
validator.validate_container_stop("container-123")
|
||||
validator.validate_container_remove("container-123")
|
||||
|
||||
# Cannot operate on non-session container
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_container_start("unknown-container")
|
||||
assert "session" in str(exc_info.value).lower() or "not found" in str(exc_info.value).lower()
|
||||
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_container_stop("unknown-container")
|
||||
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_container_remove("unknown-container")
|
||||
|
||||
|
||||
def test_unregister_session_container():
|
||||
"""Test that session containers can be unregistered."""
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
validator.register_session_container("container-123")
|
||||
validator.validate_container_start("container-123") # Should work
|
||||
|
||||
validator.unregister_session_container("container-123")
|
||||
|
||||
# After unregistration, should not work
|
||||
with pytest.raises(Exception): # SecurityError
|
||||
validator.validate_container_start("container-123")
|
||||
|
||||
|
||||
def test_validate_container_create_stores_container_id():
|
||||
"""Test that validate_container_create automatically registers container."""
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Create container with session_id should auto-register
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"network_mode": "none",
|
||||
"read_only": True,
|
||||
"security_opt": ["no-new-privileges"],
|
||||
"user": "1000:1000"
|
||||
},
|
||||
session_id="test-session-123"
|
||||
)
|
||||
|
||||
# The actual container_id would be returned by Podman after creation
|
||||
# So this test just verifies validation passes
|
||||
|
||||
|
||||
def test_security_error_includes_rule_violation():
|
||||
"""Test that SecurityError messages indicate what rule was violated."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Test various violations have clear messages
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={"privileged": True},
|
||||
session_id="test"
|
||||
)
|
||||
error_msg = str(exc_info.value)
|
||||
assert "privileged" in error_msg.lower()
|
||||
|
||||
with pytest.raises(SecurityError) as exc_info:
|
||||
validator.validate_image_name("bad-image:latest")
|
||||
error_msg = str(exc_info.value)
|
||||
assert "image" in error_msg.lower() or "allowlist" in error_msg.lower()
|
||||
|
||||
|
||||
def test_wildcard_image_pattern_matching():
|
||||
"""Test that wildcard patterns work in image allowlist."""
|
||||
from mcp_forge.security.allowlist import OperationValidator
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# mcp-forge/custom:* should match any tag
|
||||
validator.validate_image_name("mcp-forge/custom:my-env-v1")
|
||||
validator.validate_image_name("mcp-forge/custom:another-tag")
|
||||
validator.validate_image_name("mcp-forge/custom:abc123")
|
||||
|
||||
|
||||
def test_forbidden_mount_paths_comprehensive():
|
||||
"""Test all forbidden mount paths are blocked."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
forbidden_paths = [
|
||||
"/",
|
||||
"/etc",
|
||||
"/etc/shadow",
|
||||
"/var/run/docker.sock",
|
||||
"/var/run/podman/podman.sock",
|
||||
"/sys",
|
||||
"/sys/kernel",
|
||||
"/proc",
|
||||
"/proc/self",
|
||||
]
|
||||
|
||||
for path in forbidden_paths:
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_volume_mount(path, "test-session")
|
||||
|
||||
|
||||
def test_pid_mode_forbidden():
|
||||
"""Test that pid_mode parameter is forbidden."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"pid_mode": "host", # FORBIDDEN
|
||||
"network_mode": "none"
|
||||
},
|
||||
session_id="test"
|
||||
)
|
||||
|
||||
|
||||
def test_ipc_mode_forbidden():
|
||||
"""Test that ipc_mode parameter is forbidden."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_container_create(
|
||||
image="mcp-forge/python:3.11",
|
||||
params={
|
||||
"ipc_mode": "host", # FORBIDDEN
|
||||
"network_mode": "none"
|
||||
},
|
||||
session_id="test"
|
||||
)
|
||||
|
||||
|
||||
def test_session_path_must_match_session_id():
|
||||
"""Test that session paths must match the provided session_id."""
|
||||
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
from pathlib import Path
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/audit.log"))
|
||||
validator = OperationValidator(config)
|
||||
|
||||
# Correct session match
|
||||
validator.validate_volume_mount(
|
||||
"/mcp-forge/sessions/session-123/workdir",
|
||||
"session-123"
|
||||
)
|
||||
|
||||
# Wrong session in path
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_volume_mount(
|
||||
"/mcp-forge/sessions/other-session/workdir",
|
||||
"session-123"
|
||||
)
|
||||
369
tests/security/test_audit.py
Normal file
369
tests/security/test_audit.py
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
"""
|
||||
Tests for audit logger module.
|
||||
|
||||
Following TDD approach - these tests are written before implementation.
|
||||
Tests cover all logging requirements from todo.md section 1.2.3.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def test_log_entries_written_to_file(tmp_path):
|
||||
"""Test that log entries are written to the log file."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log(
|
||||
event_type=AuditEventType.CONTAINER_CREATE,
|
||||
severity=AuditSeverity.INFO,
|
||||
message="Test container created",
|
||||
details={"image": "test-image", "container_id": "abc123"}
|
||||
)
|
||||
|
||||
assert log_file.exists()
|
||||
content = log_file.read_text()
|
||||
assert len(content) > 0
|
||||
|
||||
|
||||
def test_log_entries_are_valid_json(tmp_path):
|
||||
"""Test that log entries are valid JSON."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log(
|
||||
event_type=AuditEventType.CONTAINER_CREATE,
|
||||
severity=AuditSeverity.INFO,
|
||||
message="Test entry",
|
||||
details={"key": "value"}
|
||||
)
|
||||
|
||||
# Each line should be valid JSON
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
for line in lines:
|
||||
data = json.loads(line) # Should not raise
|
||||
assert isinstance(data, dict)
|
||||
|
||||
|
||||
def test_log_entries_contain_required_fields(tmp_path):
|
||||
"""Test that log entries contain all required fields."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log(
|
||||
event_type=AuditEventType.EXECUTION_REQUEST,
|
||||
severity=AuditSeverity.INFO,
|
||||
message="Code execution requested",
|
||||
session_id="test-session-123"
|
||||
)
|
||||
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
entry = json.loads(lines[0])
|
||||
|
||||
# Required fields
|
||||
assert "timestamp" in entry
|
||||
assert "event_type" in entry
|
||||
assert "severity" in entry
|
||||
assert "message" in entry
|
||||
assert "session_id" in entry
|
||||
|
||||
|
||||
def test_timestamp_format_is_iso_8601(tmp_path):
|
||||
"""Test that timestamp is in ISO 8601 format."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log(
|
||||
event_type=AuditEventType.SESSION_CREATE,
|
||||
severity=AuditSeverity.INFO,
|
||||
message="Session created"
|
||||
)
|
||||
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
entry = json.loads(lines[0])
|
||||
|
||||
# Should be parseable as ISO 8601
|
||||
timestamp = entry["timestamp"]
|
||||
dt = datetime.fromisoformat(timestamp)
|
||||
assert isinstance(dt, datetime)
|
||||
|
||||
|
||||
def test_concurrent_logging_is_thread_safe(tmp_path):
|
||||
"""Test that concurrent logging from multiple threads is thread-safe."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
def log_entries(thread_id, count):
|
||||
for i in range(count):
|
||||
logger.log(
|
||||
event_type=AuditEventType.CONTAINER_CREATE,
|
||||
severity=AuditSeverity.INFO,
|
||||
message=f"Thread {thread_id} entry {i}"
|
||||
)
|
||||
|
||||
# Create multiple threads
|
||||
threads = []
|
||||
entries_per_thread = 10
|
||||
num_threads = 5
|
||||
|
||||
for i in range(num_threads):
|
||||
t = threading.Thread(target=log_entries, args=(i, entries_per_thread))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
# Wait for all threads
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Verify all entries written
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
assert len(lines) == num_threads * entries_per_thread
|
||||
|
||||
# Verify all entries are valid JSON
|
||||
for line in lines:
|
||||
json.loads(line)
|
||||
|
||||
|
||||
def test_security_violations_logged_with_correct_severity(tmp_path):
|
||||
"""Test that security violations are logged at correct severity."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log_security_violation(
|
||||
operation="container_create",
|
||||
reason="Privileged mode attempted",
|
||||
session_id="test-session"
|
||||
)
|
||||
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
entry = json.loads(lines[0])
|
||||
|
||||
assert entry["severity"] == "critical"
|
||||
assert entry["event_type"] == "security.violation"
|
||||
|
||||
|
||||
def test_pii_is_not_logged(tmp_path):
|
||||
"""Test that PII (code content, tokens, files) is not logged."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
# Log execution request - should NOT include actual code
|
||||
logger.log(
|
||||
event_type=AuditEventType.EXECUTION_REQUEST,
|
||||
severity=AuditSeverity.INFO,
|
||||
message="Code execution requested",
|
||||
details={
|
||||
"code_hash": "abc123def456", # Hash is OK
|
||||
# "code": "print('hello')" # Should NOT be logged
|
||||
}
|
||||
)
|
||||
|
||||
content = log_file.read_text()
|
||||
# Should not contain actual code
|
||||
assert "print" not in content
|
||||
assert "hello" not in content
|
||||
# Should contain hash
|
||||
assert "abc123def456" in content
|
||||
|
||||
|
||||
def test_log_container_operation(tmp_path):
|
||||
"""Test log_container_operation convenience method."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log_container_operation(
|
||||
operation="create",
|
||||
container_id="container-123",
|
||||
image="mcp-forge/python:3.11",
|
||||
session_id="session-456"
|
||||
)
|
||||
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
entry = json.loads(lines[0])
|
||||
|
||||
assert entry["event_type"] == "container.create"
|
||||
assert entry["container_id"] == "container-123"
|
||||
assert entry["image"] == "mcp-forge/python:3.11"
|
||||
assert entry["session_id"] == "session-456"
|
||||
|
||||
|
||||
def test_log_container_operation_with_error(tmp_path):
|
||||
"""Test logging container operation with error."""
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log_container_operation(
|
||||
operation="start",
|
||||
container_id="container-123",
|
||||
image="mcp-forge/python:3.11",
|
||||
session_id="session-456",
|
||||
error="Container not found"
|
||||
)
|
||||
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
entry = json.loads(lines[0])
|
||||
|
||||
assert "error" in entry
|
||||
assert entry["error"] == "Container not found"
|
||||
assert entry["severity"] == "error"
|
||||
|
||||
|
||||
def test_log_creates_directory_if_not_exists(tmp_path):
|
||||
"""Test that logger creates log directory if it doesn't exist."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_dir = tmp_path / "nested" / "log" / "dir"
|
||||
log_file = log_dir / "audit.log"
|
||||
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log(
|
||||
event_type=AuditEventType.SESSION_CREATE,
|
||||
severity=AuditSeverity.INFO,
|
||||
message="Test"
|
||||
)
|
||||
|
||||
assert log_file.exists()
|
||||
assert log_file.parent.exists()
|
||||
|
||||
|
||||
def test_audit_event_types():
|
||||
"""Test that all required audit event types are defined."""
|
||||
from mcp_forge.security.audit import AuditEventType
|
||||
|
||||
# Required event types from todo.md
|
||||
assert hasattr(AuditEventType, "CONTAINER_CREATE")
|
||||
assert hasattr(AuditEventType, "CONTAINER_START")
|
||||
assert hasattr(AuditEventType, "CONTAINER_STOP")
|
||||
assert hasattr(AuditEventType, "CONTAINER_REMOVE")
|
||||
assert hasattr(AuditEventType, "EXECUTION_REQUEST")
|
||||
assert hasattr(AuditEventType, "SECURITY_VIOLATION")
|
||||
assert hasattr(AuditEventType, "BUILD_REQUEST")
|
||||
assert hasattr(AuditEventType, "BUILD_COMPLETE")
|
||||
assert hasattr(AuditEventType, "SESSION_CREATE")
|
||||
assert hasattr(AuditEventType, "SESSION_DESTROY")
|
||||
|
||||
|
||||
def test_audit_severity_levels():
|
||||
"""Test that all required severity levels are defined."""
|
||||
from mcp_forge.security.audit import AuditSeverity
|
||||
|
||||
assert hasattr(AuditSeverity, "INFO")
|
||||
assert hasattr(AuditSeverity, "WARNING")
|
||||
assert hasattr(AuditSeverity, "ERROR")
|
||||
assert hasattr(AuditSeverity, "CRITICAL")
|
||||
|
||||
|
||||
def test_log_with_all_optional_parameters(tmp_path):
|
||||
"""Test logging with all optional parameters provided."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log(
|
||||
event_type=AuditEventType.BUILD_COMPLETE,
|
||||
severity=AuditSeverity.INFO,
|
||||
message="Build completed successfully",
|
||||
session_id="session-123",
|
||||
user_id="user-456",
|
||||
details={"image": "custom-env", "duration": 120},
|
||||
error=None
|
||||
)
|
||||
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
entry = json.loads(lines[0])
|
||||
|
||||
assert entry["session_id"] == "session-123"
|
||||
assert entry["user_id"] == "user-456"
|
||||
assert entry["details"]["image"] == "custom-env"
|
||||
assert entry["details"]["duration"] == 120
|
||||
|
||||
|
||||
def test_multiple_log_entries_on_separate_lines(tmp_path):
|
||||
"""Test that multiple log entries are written on separate lines."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
for i in range(5):
|
||||
logger.log(
|
||||
event_type=AuditEventType.CONTAINER_CREATE,
|
||||
severity=AuditSeverity.INFO,
|
||||
message=f"Entry {i}"
|
||||
)
|
||||
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
assert len(lines) == 5
|
||||
|
||||
# Each line should be parseable
|
||||
for line in lines:
|
||||
json.loads(line)
|
||||
|
||||
|
||||
def test_log_security_violation_parameters(tmp_path):
|
||||
"""Test log_security_violation includes all necessary information."""
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log_security_violation(
|
||||
operation="volume_mount",
|
||||
reason="Attempted to mount /etc",
|
||||
session_id="session-789"
|
||||
)
|
||||
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
entry = json.loads(lines[0])
|
||||
|
||||
assert entry["event_type"] == "security.violation"
|
||||
assert entry["severity"] == "critical"
|
||||
assert entry["operation"] == "volume_mount"
|
||||
assert entry["reason"] == "Attempted to mount /etc"
|
||||
assert entry["session_id"] == "session-789"
|
||||
|
||||
|
||||
def test_details_can_be_none(tmp_path):
|
||||
"""Test that details parameter can be None."""
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
|
||||
log_file = tmp_path / "audit.log"
|
||||
logger = AuditLogger(log_file)
|
||||
|
||||
logger.log(
|
||||
event_type=AuditEventType.SESSION_CREATE,
|
||||
severity=AuditSeverity.INFO,
|
||||
message="Session created",
|
||||
details=None
|
||||
)
|
||||
|
||||
lines = log_file.read_text().strip().split("\n")
|
||||
entry = json.loads(lines[0])
|
||||
|
||||
# Should work without error
|
||||
assert "message" in entry
|
||||
276
tests/security/test_resource_limits.py
Normal file
276
tests/security/test_resource_limits.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""
|
||||
Tests for resource limits module.
|
||||
|
||||
Following TDD approach - these tests are written before implementation.
|
||||
Tests cover all parsing and validation requirements from todo.md section 1.2.1.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_parse_memory_string_megabytes():
|
||||
"""Test parsing memory string with megabytes suffix."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
result = parse_memory_string("512m")
|
||||
assert result == 536870912 # 512 * 1024 * 1024
|
||||
|
||||
|
||||
def test_parse_memory_string_gigabytes():
|
||||
"""Test parsing memory string with gigabytes suffix."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
result = parse_memory_string("2g")
|
||||
assert result == 2147483648 # 2 * 1024 * 1024 * 1024
|
||||
|
||||
|
||||
def test_parse_memory_string_kilobytes():
|
||||
"""Test parsing memory string with kilobytes suffix."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
result = parse_memory_string("1024k")
|
||||
assert result == 1048576 # 1024 * 1024
|
||||
|
||||
|
||||
def test_parse_memory_string_case_insensitive():
|
||||
"""Test that memory string parsing is case-insensitive."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
assert parse_memory_string("512M") == 536870912
|
||||
assert parse_memory_string("2G") == 2147483648
|
||||
assert parse_memory_string("1024K") == 1048576
|
||||
|
||||
|
||||
def test_parse_memory_string_invalid_format_raises_value_error():
|
||||
"""Test that invalid format raises ValueError."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_memory_string("invalid")
|
||||
assert "invalid" in str(exc_info.value).lower() or "format" in str(exc_info.value).lower()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
parse_memory_string("512x") # Invalid suffix
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
parse_memory_string("abc") # Not a number
|
||||
|
||||
|
||||
def test_parse_memory_string_negative_value_raises_value_error():
|
||||
"""Test that negative values raise ValueError."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_memory_string("-512m")
|
||||
assert "positive" in str(exc_info.value).lower() or "negative" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_parse_memory_string_zero_value_raises_value_error():
|
||||
"""Test that zero value raises ValueError."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_memory_string("0m")
|
||||
assert "positive" in str(exc_info.value).lower() or "zero" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_parse_cpu_quota_valid_value():
|
||||
"""Test that valid CPU quota values are accepted."""
|
||||
from mcp_forge.security.resource_limits import parse_cpu_quota
|
||||
|
||||
result = parse_cpu_quota(50000)
|
||||
assert result == 50000
|
||||
|
||||
result = parse_cpu_quota(100000) # 100% of one core
|
||||
assert result == 100000
|
||||
|
||||
|
||||
def test_parse_cpu_quota_max_limit():
|
||||
"""Test that CPU quota has a reasonable maximum (10 cores)."""
|
||||
from mcp_forge.security.resource_limits import parse_cpu_quota
|
||||
|
||||
# Should accept up to 1000000 (10 cores)
|
||||
result = parse_cpu_quota(1000000)
|
||||
assert result == 1000000
|
||||
|
||||
# Should reject more than 10 cores
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_cpu_quota(1000001)
|
||||
assert "1000000" in str(exc_info.value) or "maximum" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_parse_cpu_quota_negative_raises_value_error():
|
||||
"""Test that negative CPU quota raises ValueError."""
|
||||
from mcp_forge.security.resource_limits import parse_cpu_quota
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_cpu_quota(-1)
|
||||
assert "positive" in str(exc_info.value).lower() or "negative" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_parse_cpu_quota_zero_raises_value_error():
|
||||
"""Test that zero CPU quota raises ValueError."""
|
||||
from mcp_forge.security.resource_limits import parse_cpu_quota
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_cpu_quota(0)
|
||||
assert "positive" in str(exc_info.value).lower() or "zero" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_parse_storage_string_same_as_memory():
|
||||
"""Test that storage parsing works the same as memory parsing."""
|
||||
from mcp_forge.security.resource_limits import parse_storage_string
|
||||
|
||||
assert parse_storage_string("1g") == 1073741824
|
||||
assert parse_storage_string("512m") == 536870912
|
||||
assert parse_storage_string("2048k") == 2097152
|
||||
|
||||
|
||||
def test_resource_limits_class_initialization():
|
||||
"""Test ResourceLimits class initializes correctly."""
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
limits = ResourceLimits(
|
||||
memory="512m",
|
||||
storage="1g",
|
||||
cpu_quota=50000,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
assert limits.memory_bytes == 536870912
|
||||
assert limits.storage_bytes == 1073741824
|
||||
assert limits.cpu_quota == 50000
|
||||
assert limits.timeout == 300
|
||||
|
||||
|
||||
def test_resource_limits_validates_memory():
|
||||
"""Test that ResourceLimits validates memory string."""
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ResourceLimits(
|
||||
memory="invalid",
|
||||
storage="1g",
|
||||
cpu_quota=50000,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
|
||||
def test_resource_limits_validates_storage():
|
||||
"""Test that ResourceLimits validates storage string."""
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ResourceLimits(
|
||||
memory="512m",
|
||||
storage="invalid",
|
||||
cpu_quota=50000,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
|
||||
def test_resource_limits_validates_cpu_quota():
|
||||
"""Test that ResourceLimits validates CPU quota."""
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ResourceLimits(
|
||||
memory="512m",
|
||||
storage="1g",
|
||||
cpu_quota=-1,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
|
||||
def test_resource_limits_to_podman_params():
|
||||
"""Test conversion to Podman container parameters."""
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
limits = ResourceLimits(
|
||||
memory="512m",
|
||||
storage="1g",
|
||||
cpu_quota=50000,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
params = limits.to_podman_params()
|
||||
|
||||
assert isinstance(params, dict)
|
||||
assert "mem_limit" in params
|
||||
assert params["mem_limit"] == "536870912" # Should be string for Podman
|
||||
# CPU quota is set via cpu_quota parameter
|
||||
assert "cpu_quota" in params
|
||||
assert params["cpu_quota"] == 50000
|
||||
|
||||
|
||||
def test_resource_limits_default_timeout():
|
||||
"""Test that ResourceLimits has a default timeout."""
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
limits = ResourceLimits(
|
||||
memory="512m",
|
||||
storage="1g",
|
||||
cpu_quota=50000
|
||||
)
|
||||
|
||||
assert limits.timeout == 300 # Default from signature
|
||||
|
||||
|
||||
def test_parse_memory_string_with_spaces():
|
||||
"""Test parsing memory strings that have spaces."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
# Should handle spaces gracefully (strip them)
|
||||
result = parse_memory_string(" 512m ")
|
||||
assert result == 536870912
|
||||
|
||||
|
||||
def test_parse_memory_string_bytes_suffix():
|
||||
"""Test parsing memory string with bytes suffix (no multiplier)."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
# Just a number (bytes) - should this be supported?
|
||||
# Based on architecture, we support k, m, g suffixes
|
||||
# Plain numbers should probably raise an error for safety
|
||||
with pytest.raises(ValueError):
|
||||
parse_memory_string("1024")
|
||||
|
||||
|
||||
def test_resource_limits_storage_quota_in_podman_params():
|
||||
"""Test that storage limits are included in Podman params."""
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
||||
limits = ResourceLimits(
|
||||
memory="512m",
|
||||
storage="1g",
|
||||
cpu_quota=50000,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
params = limits.to_podman_params()
|
||||
|
||||
# Storage limit might be set via storage_opt or similar
|
||||
# The exact parameter depends on Podman API
|
||||
assert "storage_bytes" in params or "storage_opt" in params
|
||||
|
||||
|
||||
def test_cpu_quota_explanation():
|
||||
"""Test that CPU quota values have clear meaning."""
|
||||
from mcp_forge.security.resource_limits import parse_cpu_quota
|
||||
|
||||
# 100000 = 100% of one CPU core
|
||||
# 50000 = 50% of one CPU core
|
||||
# 200000 = 200% = 2 CPU cores
|
||||
|
||||
assert parse_cpu_quota(50000) == 50000 # 0.5 cores
|
||||
assert parse_cpu_quota(100000) == 100000 # 1 core
|
||||
assert parse_cpu_quota(200000) == 200000 # 2 cores
|
||||
|
||||
|
||||
def test_parse_memory_with_decimal():
|
||||
"""Test parsing memory strings with decimal values."""
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
# Should handle decimals
|
||||
result = parse_memory_string("1.5g")
|
||||
assert result == 1610612736 # 1.5 * 1024 * 1024 * 1024
|
||||
266
tests/server/test_resources.py
Normal file
266
tests/server/test_resources.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"""Tests for MCP resource handlers."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from mcp_forge.server.resources import ResourceHandler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client_manager():
|
||||
"""Mock MCP client manager."""
|
||||
manager = Mock()
|
||||
manager.list_all_tools = AsyncMock(return_value=["read_file", "write_file", "calculate"])
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_manager():
|
||||
"""Mock session manager."""
|
||||
manager = Mock()
|
||||
|
||||
# Mock session state
|
||||
mock_state = Mock()
|
||||
mock_state.session_id = "test-session"
|
||||
mock_state.documented_variables = {"x": "Test variable", "result": "Calculation result"}
|
||||
mock_state.note = "Test session state"
|
||||
mock_state.all_variables = ["x", "y", "result", "np", "pd"]
|
||||
mock_state.introspection = {
|
||||
"x": {"type": "int", "size": 28},
|
||||
"result": {"type": "float", "size": 24}
|
||||
}
|
||||
mock_state.to_dict = Mock(return_value={
|
||||
"session_id": "test-session",
|
||||
"documented_variables": {"x": "Test variable", "result": "Calculation result"},
|
||||
"note": "Test session state",
|
||||
"all_variables": ["x", "y", "result", "np", "pd"],
|
||||
"introspection": {
|
||||
"x": {"type": "int", "size": 28},
|
||||
"result": {"type": "float", "size": 24}
|
||||
},
|
||||
"last_updated": "2026-02-06T12:00:00Z"
|
||||
})
|
||||
|
||||
manager.get_session_state = Mock(return_value=mock_state)
|
||||
manager.list_sessions = Mock(return_value=[
|
||||
{"session_id": "test-session", "created_at": "2026-02-06T12:00:00Z"}
|
||||
])
|
||||
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_environment_builder():
|
||||
"""Mock environment builder."""
|
||||
builder = Mock()
|
||||
builder.list_templates = Mock(return_value={
|
||||
"datascience": {
|
||||
"description": "Data science environment with numpy, pandas, matplotlib",
|
||||
"packages": ["numpy>=1.24", "pandas>=2.0", "matplotlib>=3.7"]
|
||||
},
|
||||
"ml": {
|
||||
"description": "Machine learning environment",
|
||||
"packages": ["scikit-learn>=1.3", "tensorflow>=2.13"]
|
||||
}
|
||||
})
|
||||
return builder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Mock forge configuration."""
|
||||
config = Mock()
|
||||
config.execution = Mock()
|
||||
config.execution.default_backend = "simple"
|
||||
config.execution.default_timeout = 300
|
||||
config.sessions = Mock()
|
||||
config.sessions.max_concurrent = 10
|
||||
# Add environment-related config for environment/info resource
|
||||
config.max_packages = 50
|
||||
config.max_build_time = 300
|
||||
config.base_images = {"python:3.11": {}, "python:3.12": {}}
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_handler(mock_client_manager, mock_session_manager, mock_environment_builder, mock_config):
|
||||
"""Create ResourceHandler instance with mocked dependencies."""
|
||||
return ResourceHandler(
|
||||
client_manager=mock_client_manager,
|
||||
session_manager=mock_session_manager,
|
||||
environment_builder=mock_environment_builder,
|
||||
config=mock_config
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_tools_available(resource_handler, mock_client_manager):
|
||||
"""Test handling tools/available resource returns list of available tools."""
|
||||
result = await resource_handler.handle_resource("mcp://forge/tools/available")
|
||||
|
||||
assert str(result.uri) == "mcp://forge/tools/available"
|
||||
assert result.mimeType == "application/json"
|
||||
|
||||
# Parse JSON content
|
||||
tools = json.loads(result.text)
|
||||
assert tools == {"tools": ["read_file", "write_file", "calculate"]}
|
||||
|
||||
# Verify client manager was called
|
||||
mock_client_manager.list_all_tools.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_session_state(resource_handler, mock_session_manager):
|
||||
"""Test handling session state resource returns documented state."""
|
||||
session_id = "test-session"
|
||||
result = await resource_handler.handle_resource(f"mcp://forge/sessions/{session_id}/state")
|
||||
|
||||
assert str(result.uri) == f"mcp://forge/sessions/{session_id}/state"
|
||||
assert result.mimeType == "application/json"
|
||||
|
||||
# Parse JSON content
|
||||
state = json.loads(result.text)
|
||||
assert state["session_id"] == session_id
|
||||
assert state["documented_variables"] == {"x": "Test variable", "result": "Calculation result"}
|
||||
assert state["note"] == "Test session state"
|
||||
assert "last_updated" in state
|
||||
|
||||
# Verify session manager was called
|
||||
mock_session_manager.get_session_state.assert_called_once_with(session_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_session_variables(resource_handler, mock_session_manager):
|
||||
"""Test handling session variables resource returns list of variables."""
|
||||
session_id = "test-session"
|
||||
result = await resource_handler.handle_resource(f"mcp://forge/sessions/{session_id}/variables")
|
||||
|
||||
assert str(result.uri) == f"mcp://forge/sessions/{session_id}/variables"
|
||||
assert result.mimeType == "application/json"
|
||||
|
||||
# Parse JSON content
|
||||
data = json.loads(result.text)
|
||||
assert data["variables"] == ["x", "y", "result", "np", "pd"]
|
||||
|
||||
# Verify session manager was called
|
||||
mock_session_manager.get_session_state.assert_called_once_with(session_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_environments_list(resource_handler, mock_environment_builder):
|
||||
"""Test handling environments/list resource returns templates and built environments."""
|
||||
result = await resource_handler.handle_resource("mcp://forge/environments/list")
|
||||
|
||||
assert str(result.uri) == "mcp://forge/environments/list"
|
||||
assert result.mimeType == "application/json"
|
||||
|
||||
# Parse JSON content
|
||||
environments = json.loads(result.text)
|
||||
assert "templates" in environments
|
||||
assert "datascience" in environments["templates"]
|
||||
assert "ml" in environments["templates"]
|
||||
assert environments["templates"]["datascience"]["description"] == "Data science environment with numpy, pandas, matplotlib"
|
||||
|
||||
# Verify environment builder was called
|
||||
mock_environment_builder.list_templates.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_environment_info(resource_handler, mock_config):
|
||||
"""Test handling environment info resource returns configuration info."""
|
||||
result = await resource_handler.handle_resource("mcp://forge/environment/info")
|
||||
|
||||
assert str(result.uri) == "mcp://forge/environment/info"
|
||||
assert result.mimeType == "application/json"
|
||||
|
||||
# Parse JSON content
|
||||
info = json.loads(result.text)
|
||||
assert "base_images" in info
|
||||
assert "python:3.11" in info["base_images"]
|
||||
assert "python:3.12" in info["base_images"]
|
||||
assert info["max_packages"] == 50
|
||||
assert info["max_build_time"] == 300
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_sessions_list(resource_handler, mock_session_manager):
|
||||
"""Test handling sessions/list resource returns list of active sessions."""
|
||||
result = await resource_handler.handle_resource("mcp://forge/sessions/list")
|
||||
|
||||
assert str(result.uri) == "mcp://forge/sessions/list"
|
||||
assert result.mimeType == "application/json"
|
||||
|
||||
# Parse JSON content
|
||||
data = json.loads(result.text)
|
||||
assert len(data["sessions"]) == 1
|
||||
assert data["sessions"][0]["session_id"] == "test-session"
|
||||
|
||||
# Verify session manager was called
|
||||
mock_session_manager.list_sessions.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_unknown_resource(resource_handler):
|
||||
"""Test handling unknown resource raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Unknown resource URI"):
|
||||
await resource_handler.handle_resource("mcp://forge/unknown/resource")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_session_not_found(resource_handler, mock_session_manager):
|
||||
"""Test handling session resource when session doesn't exist raises KeyError."""
|
||||
mock_session_manager.get_session_state.side_effect = KeyError("Session not found")
|
||||
|
||||
with pytest.raises(KeyError, match="Session not found"):
|
||||
await resource_handler.handle_resource("mcp://forge/sessions/nonexistent/state")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_uri_tools_available(resource_handler):
|
||||
"""Test URI parsing for tools/available resource."""
|
||||
resource_type, params = resource_handler._parse_uri("mcp://forge/tools/available")
|
||||
assert resource_type == "tools_available"
|
||||
assert params == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_uri_session_state(resource_handler):
|
||||
"""Test URI parsing for session state resource."""
|
||||
resource_type, params = resource_handler._parse_uri("mcp://forge/sessions/test-123/state")
|
||||
assert resource_type == "session_state"
|
||||
assert params == {"session_id": "test-123"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_uri_session_variables(resource_handler):
|
||||
"""Test URI parsing for session variables resource."""
|
||||
resource_type, params = resource_handler._parse_uri("mcp://forge/sessions/test-123/variables")
|
||||
assert resource_type == "session_variables"
|
||||
assert params == {"session_id": "test-123"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_uri_invalid_format(resource_handler):
|
||||
"""Test URI parsing with invalid format raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid URI format"):
|
||||
resource_handler._parse_uri("invalid://uri")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_serialization_valid(resource_handler):
|
||||
"""Test that all returned JSON content is valid and can be parsed."""
|
||||
# Test all resource types return valid JSON
|
||||
resources = [
|
||||
"mcp://forge/tools/available",
|
||||
"mcp://forge/sessions/test-session/state",
|
||||
"mcp://forge/sessions/test-session/variables",
|
||||
"mcp://forge/environments/list",
|
||||
"mcp://forge/environment/info",
|
||||
"mcp://forge/sessions/list"
|
||||
]
|
||||
|
||||
for uri in resources:
|
||||
result = await resource_handler.handle_resource(uri)
|
||||
# Should not raise exception
|
||||
parsed = json.loads(result.text)
|
||||
assert parsed is not None
|
||||
203
tests/server/test_server.py
Normal file
203
tests/server/test_server.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""Tests for MCP Forge Server."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_forge.server.server import ForgeServer
|
||||
from mcp_forge.config.schema import ForgeConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(tmp_path):
|
||||
"""Mock forge configuration."""
|
||||
config = Mock(spec=ForgeConfig)
|
||||
|
||||
# Server config
|
||||
config.server = Mock()
|
||||
config.server.host = "localhost"
|
||||
config.server.port = 3000
|
||||
config.server.podman_socket = Path("/run/user/1000/podman/podman.sock")
|
||||
|
||||
# Security config
|
||||
config.security = Mock()
|
||||
config.security.audit_log = tmp_path / "audit.log"
|
||||
config.security.max_memory = "2g"
|
||||
config.security.max_timeout = 1800
|
||||
|
||||
# Execution config
|
||||
config.execution = Mock()
|
||||
config.execution.default_backend = "simple"
|
||||
config.execution.default_timeout = 300
|
||||
config.execution.max_timeout = 1800
|
||||
config.execution.default_memory = "512m"
|
||||
config.execution.max_memory = "2g"
|
||||
|
||||
# Sessions config
|
||||
config.sessions = Mock()
|
||||
config.sessions.max_concurrent = 10
|
||||
config.sessions.idle_timeout = 3600
|
||||
|
||||
# Environment builder config
|
||||
config.environment_builder = Mock()
|
||||
config.environment_builder.uv_cache_path = tmp_path / "cache"
|
||||
config.environment_builder.max_build_time = 600
|
||||
config.environment_builder.package_validation = Mock()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_initialization(mock_config):
|
||||
"""Test that server initializes all components."""
|
||||
with patch('mcp_forge.server.server.AuditLogger'), \
|
||||
patch('mcp_forge.server.server.OperationValidator'), \
|
||||
patch('mcp_forge.server.server.PodmanClient'), \
|
||||
patch('mcp_forge.server.server.SecureContainerManager'), \
|
||||
patch('mcp_forge.server.server.MCPClientManager'), \
|
||||
patch('mcp_forge.server.server.ToolBridgeServer'), \
|
||||
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
|
||||
patch('mcp_forge.server.server.SimpleBackend'), \
|
||||
patch('mcp_forge.server.server.JupyterKernelManager'), \
|
||||
patch('mcp_forge.server.server.JupyterBackend'), \
|
||||
patch('mcp_forge.server.server.SessionManager'), \
|
||||
patch('mcp_forge.server.server.EnvironmentBuilder'):
|
||||
|
||||
server = ForgeServer(config=mock_config)
|
||||
|
||||
# Verify server was created
|
||||
assert server is not None
|
||||
assert server.config == mock_config
|
||||
|
||||
# Verify components were initialized
|
||||
assert hasattr(server, 'audit_logger')
|
||||
assert hasattr(server, 'operation_validator')
|
||||
assert hasattr(server, 'podman_client')
|
||||
assert hasattr(server, 'container_manager')
|
||||
assert hasattr(server, 'client_manager')
|
||||
assert hasattr(server, 'bridge_server')
|
||||
assert hasattr(server, 'simple_backend')
|
||||
assert hasattr(server, 'jupyter_backend')
|
||||
assert hasattr(server, 'environment_builder')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_registration(mock_config):
|
||||
"""Test that tools are registered with the server."""
|
||||
with patch('mcp_forge.server.server.AuditLogger'), \
|
||||
patch('mcp_forge.server.server.OperationValidator'), \
|
||||
patch('mcp_forge.server.server.PodmanClient'), \
|
||||
patch('mcp_forge.server.server.SecureContainerManager'), \
|
||||
patch('mcp_forge.server.server.MCPClientManager'), \
|
||||
patch('mcp_forge.server.server.ToolBridgeServer'), \
|
||||
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
|
||||
patch('mcp_forge.server.server.SimpleBackend'), \
|
||||
patch('mcp_forge.server.server.JupyterKernelManager'), \
|
||||
patch('mcp_forge.server.server.JupyterBackend'), \
|
||||
patch('mcp_forge.server.server.SessionManager'), \
|
||||
patch('mcp_forge.server.server.EnvironmentBuilder'):
|
||||
|
||||
server = ForgeServer(config=mock_config)
|
||||
|
||||
# Verify tool instances were created
|
||||
assert hasattr(server, 'execute_python_tool')
|
||||
assert hasattr(server, 'document_state_tool')
|
||||
assert hasattr(server, 'build_environment_tool')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resources_registration(mock_config):
|
||||
"""Test that resources are registered with the server."""
|
||||
with patch('mcp_forge.server.server.AuditLogger'), \
|
||||
patch('mcp_forge.server.server.OperationValidator'), \
|
||||
patch('mcp_forge.server.server.PodmanClient'), \
|
||||
patch('mcp_forge.server.server.SecureContainerManager'), \
|
||||
patch('mcp_forge.server.server.MCPClientManager'), \
|
||||
patch('mcp_forge.server.server.ToolBridgeServer'), \
|
||||
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
|
||||
patch('mcp_forge.server.server.SimpleBackend'), \
|
||||
patch('mcp_forge.server.server.JupyterKernelManager'), \
|
||||
patch('mcp_forge.server.server.JupyterBackend'), \
|
||||
patch('mcp_forge.server.server.SessionManager'), \
|
||||
patch('mcp_forge.server.server.EnvironmentBuilder'):
|
||||
|
||||
server = ForgeServer(config=mock_config)
|
||||
|
||||
# Verify resource handler was created
|
||||
assert hasattr(server, 'resource_handler')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_shutdown(mock_config):
|
||||
"""Test that server shuts down gracefully."""
|
||||
with patch('mcp_forge.server.server.AuditLogger') as mock_audit, \
|
||||
patch('mcp_forge.server.server.OperationValidator'), \
|
||||
patch('mcp_forge.server.server.PodmanClient'), \
|
||||
patch('mcp_forge.server.server.SecureContainerManager'), \
|
||||
patch('mcp_forge.server.server.MCPClientManager') as mock_client_mgr, \
|
||||
patch('mcp_forge.server.server.ToolBridgeServer') as mock_bridge, \
|
||||
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
|
||||
patch('mcp_forge.server.server.SimpleBackend'), \
|
||||
patch('mcp_forge.server.server.JupyterKernelManager'), \
|
||||
patch('mcp_forge.server.server.JupyterBackend'), \
|
||||
patch('mcp_forge.server.server.SessionManager'), \
|
||||
patch('mcp_forge.server.server.EnvironmentBuilder'):
|
||||
|
||||
# Setup mocks
|
||||
mock_client_mgr.return_value.shutdown = AsyncMock()
|
||||
mock_bridge_instance = Mock()
|
||||
mock_bridge_instance.stop = Mock() # Not async
|
||||
mock_bridge.return_value = mock_bridge_instance
|
||||
mock_audit_instance = Mock()
|
||||
mock_audit.return_value = mock_audit_instance
|
||||
|
||||
server = ForgeServer(config=mock_config)
|
||||
|
||||
# Shutdown server
|
||||
await server.shutdown()
|
||||
|
||||
# Verify cleanup was called
|
||||
server.client_manager.shutdown.assert_called_once()
|
||||
server.bridge_server.stop.assert_called_once()
|
||||
server.audit_logger.log.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_component_initialization_order(mock_config):
|
||||
"""Test that components are initialized in correct order."""
|
||||
init_order = []
|
||||
|
||||
def track_init(name):
|
||||
def decorator(cls):
|
||||
original_init = cls.__init__
|
||||
def new_init(self, *args, **kwargs):
|
||||
init_order.append(name)
|
||||
return original_init(self, *args, **kwargs)
|
||||
cls.__init__ = new_init
|
||||
return cls
|
||||
return decorator
|
||||
|
||||
with patch('mcp_forge.server.server.AuditLogger') as mock_audit, \
|
||||
patch('mcp_forge.server.server.OperationValidator') as mock_validator, \
|
||||
patch('mcp_forge.server.server.PodmanClient') as mock_podman, \
|
||||
patch('mcp_forge.server.server.SecureContainerManager') as mock_container, \
|
||||
patch('mcp_forge.server.server.MCPClientManager'), \
|
||||
patch('mcp_forge.server.server.ToolBridgeServer'), \
|
||||
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
|
||||
patch('mcp_forge.server.server.SimpleBackend'), \
|
||||
patch('mcp_forge.server.server.JupyterKernelManager'), \
|
||||
patch('mcp_forge.server.server.JupyterBackend'), \
|
||||
patch('mcp_forge.server.server.SessionManager'), \
|
||||
patch('mcp_forge.server.server.EnvironmentBuilder'):
|
||||
|
||||
mock_audit.side_effect = lambda *args, **kwargs: init_order.append('audit_logger') or Mock()
|
||||
mock_validator.side_effect = lambda *args, **kwargs: init_order.append('operation_validator') or Mock()
|
||||
mock_podman.side_effect = lambda *args, **kwargs: init_order.append('podman_client') or Mock()
|
||||
mock_container.side_effect = lambda *args, **kwargs: init_order.append('container_manager') or Mock()
|
||||
|
||||
server = ForgeServer(config=mock_config)
|
||||
|
||||
# Verify security components are initialized first
|
||||
assert init_order.index('audit_logger') < init_order.index('podman_client')
|
||||
assert init_order.index('operation_validator') < init_order.index('podman_client')
|
||||
assert init_order.index('podman_client') < init_order.index('container_manager')
|
||||
86
tests/server/test_server_integration.py
Normal file
86
tests/server/test_server_integration.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Integration tests for MCP Forge Server - tests real component initialization."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from mcp_forge.server.server import ForgeServer
|
||||
from mcp_forge.config.schema import (
|
||||
ForgeConfig, ServerConfig, SecurityConfig, ExecutionConfig, SessionConfig,
|
||||
ImageConfig, VolumeConfig, EnvironmentBuilderConfig, PackageValidationConfig
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_config(tmp_path):
|
||||
"""Real configuration with all required fields."""
|
||||
# Create required files
|
||||
(tmp_path / "allowlist.txt").write_text("requests\npandas\nnumpy\n")
|
||||
(tmp_path / "blocklist.txt").write_text("")
|
||||
|
||||
config = ForgeConfig(
|
||||
server=ServerConfig(
|
||||
host="localhost",
|
||||
port=3000,
|
||||
podman_socket=Path("/run/user/1000/podman/podman.sock")
|
||||
),
|
||||
security=SecurityConfig(
|
||||
audit_log=tmp_path / "audit.log",
|
||||
enforce_resource_limits=True,
|
||||
allow_network=False
|
||||
),
|
||||
execution=ExecutionConfig(
|
||||
default_backend="simple",
|
||||
default_timeout=300,
|
||||
max_timeout=1800,
|
||||
default_memory="512m",
|
||||
max_memory="2g"
|
||||
),
|
||||
images=ImageConfig(
|
||||
allowed_python_versions=["3.11", "3.12"],
|
||||
default_base_image="python:3.11"
|
||||
),
|
||||
sessions=SessionConfig(
|
||||
max_concurrent=10,
|
||||
idle_timeout=3600
|
||||
),
|
||||
volumes=VolumeConfig(
|
||||
base_path=tmp_path / "volumes"
|
||||
),
|
||||
environment_builder=EnvironmentBuilderConfig(
|
||||
uv_cache_path=tmp_path / "cache",
|
||||
build_rate_limit={"requests": 5, "period": 60},
|
||||
package_validation=PackageValidationConfig(
|
||||
allowlist_path=tmp_path / "allowlist.txt",
|
||||
blocklist_path=tmp_path / "blocklist.txt"
|
||||
)
|
||||
),
|
||||
mcp_tools={}
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_can_initialize_with_minimal_mocking(real_config):
|
||||
"""Test that ForgeServer can initialize with real component instances."""
|
||||
# Only mock the podman library since we don't have a real Podman socket
|
||||
with patch('mcp_forge.podman.client.BasePodmanClient'):
|
||||
# This should succeed if all parameter mismatches are fixed
|
||||
server = ForgeServer(config=real_config)
|
||||
|
||||
# Verify all components were created
|
||||
assert server.audit_logger is not None
|
||||
assert server.operation_validator is not None
|
||||
assert server.podman_client is not None
|
||||
assert server.container_manager is not None
|
||||
assert server.client_manager is not None
|
||||
assert server.bridge_server is not None
|
||||
assert server.simple_backend is not None
|
||||
assert server.kernel_manager is not None
|
||||
assert server.session_manager is not None
|
||||
assert server.jupyter_backend is not None
|
||||
assert server.environment_builder is not None
|
||||
# Sub-components created by EnvironmentBuilder
|
||||
assert server.package_validator is not None
|
||||
assert server.uv_installer is not None
|
||||
assert server.image_builder is not None
|
||||
250
tests/server/tools/test_build_environment.py
Normal file
250
tests/server/tools/test_build_environment.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""Tests for Build Custom Environment Tool."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from mcp.types import Tool, TextContent
|
||||
import json
|
||||
|
||||
from mcp_forge.server.tools.build_environment import BuildEnvironmentTool
|
||||
from mcp_forge.builder.environment_builder import BuildResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_environment_builder():
|
||||
"""Mock environment builder."""
|
||||
builder = Mock()
|
||||
builder.build_environment = AsyncMock(return_value=BuildResult(
|
||||
success=True,
|
||||
image_name="mcp-forge/custom:test-env",
|
||||
image_id="sha256:abc123",
|
||||
build_time=45.2,
|
||||
size_bytes=524288000, # 500MB
|
||||
installed_packages=["numpy==1.24.0", "pandas==2.0.0"],
|
||||
cache_hit=False
|
||||
))
|
||||
return builder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_audit_logger():
|
||||
"""Mock audit logger."""
|
||||
logger = Mock()
|
||||
logger.log_environment_build = Mock()
|
||||
return logger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_environment_tool(mock_environment_builder, mock_audit_logger):
|
||||
"""Create BuildEnvironmentTool instance with mocked dependencies."""
|
||||
return BuildEnvironmentTool(
|
||||
environment_builder=mock_environment_builder,
|
||||
audit_logger=mock_audit_logger
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_definition(build_environment_tool):
|
||||
"""Test that tool definition matches MCP spec."""
|
||||
definition = build_environment_tool.get_tool_definition()
|
||||
|
||||
assert isinstance(definition, Tool)
|
||||
assert definition.name == "build_custom_environment"
|
||||
assert definition.description is not None
|
||||
assert "build" in definition.description.lower()
|
||||
|
||||
# Verify required schema properties
|
||||
schema = definition.inputSchema
|
||||
assert schema["type"] == "object"
|
||||
assert "name" in schema["properties"]
|
||||
assert "packages" in schema["properties"]
|
||||
assert "base_image" in schema["properties"]
|
||||
assert "python_version" in schema["properties"]
|
||||
assert "description" in schema["properties"]
|
||||
assert "name" in schema["required"]
|
||||
assert "packages" in schema["required"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_with_valid_packages(
|
||||
build_environment_tool,
|
||||
mock_environment_builder,
|
||||
mock_audit_logger
|
||||
):
|
||||
"""Test building environment with valid packages."""
|
||||
arguments = {
|
||||
"name": "test-ml-env",
|
||||
"packages": ["numpy>=1.24.0", "pandas>=2.0.0"]
|
||||
}
|
||||
|
||||
result = await build_environment_tool.execute(arguments)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], TextContent)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
assert response["image_name"] == "mcp-forge/custom:test-env"
|
||||
assert response["build_time"] == 45.2
|
||||
assert len(response["installed_packages"]) == 2
|
||||
|
||||
# Verify builder was called
|
||||
mock_environment_builder.build_environment.assert_called_once()
|
||||
|
||||
# Verify audit log was called
|
||||
mock_audit_logger.log_environment_build.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_with_base_image(
|
||||
build_environment_tool,
|
||||
mock_environment_builder
|
||||
):
|
||||
"""Test building with custom base image."""
|
||||
arguments = {
|
||||
"name": "test-env",
|
||||
"packages": ["requests"],
|
||||
"base_image": "python:3.12"
|
||||
}
|
||||
|
||||
result = await build_environment_tool.execute(arguments)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
|
||||
# Verify base_image was passed to builder
|
||||
call_args = mock_environment_builder.build_environment.call_args
|
||||
assert call_args.kwargs["base_image"] == "python:3.12"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_with_python_version(
|
||||
build_environment_tool,
|
||||
mock_environment_builder
|
||||
):
|
||||
"""Test building with specific Python version."""
|
||||
arguments = {
|
||||
"name": "test-env",
|
||||
"packages": ["numpy"],
|
||||
"python_version": "3.12"
|
||||
}
|
||||
|
||||
result = await build_environment_tool.execute(arguments)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
|
||||
# Verify python_version was passed to builder
|
||||
call_args = mock_environment_builder.build_environment.call_args
|
||||
assert call_args.kwargs["python_version"] == "3.12"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_with_description(
|
||||
build_environment_tool,
|
||||
mock_environment_builder
|
||||
):
|
||||
"""Test building with description."""
|
||||
arguments = {
|
||||
"name": "test-env",
|
||||
"packages": ["pandas"],
|
||||
"description": "Environment for data analysis"
|
||||
}
|
||||
|
||||
result = await build_environment_tool.execute(arguments)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_missing_name(build_environment_tool):
|
||||
"""Test that validation fails when name is missing."""
|
||||
arguments = {
|
||||
"packages": ["numpy"]
|
||||
}
|
||||
|
||||
with pytest.raises((ValueError, KeyError)):
|
||||
await build_environment_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_missing_packages(build_environment_tool):
|
||||
"""Test that validation fails when packages is missing."""
|
||||
arguments = {
|
||||
"name": "test-env"
|
||||
}
|
||||
|
||||
with pytest.raises((ValueError, KeyError)):
|
||||
await build_environment_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_invalid_name(build_environment_tool):
|
||||
"""Test that validation fails for invalid environment name."""
|
||||
arguments = {
|
||||
"name": "invalid name with spaces",
|
||||
"packages": ["numpy"]
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
await build_environment_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_invalid_packages_type(build_environment_tool):
|
||||
"""Test that validation fails when packages is not a list."""
|
||||
arguments = {
|
||||
"name": "test-env",
|
||||
"packages": "not-a-list"
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="packages"):
|
||||
await build_environment_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_empty_packages(build_environment_tool):
|
||||
"""Test that validation fails when packages list is empty."""
|
||||
arguments = {
|
||||
"name": "test-env",
|
||||
"packages": []
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="packages"):
|
||||
await build_environment_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_error_handling(
|
||||
build_environment_tool,
|
||||
mock_environment_builder
|
||||
):
|
||||
"""Test that build errors are handled gracefully."""
|
||||
mock_environment_builder.build_environment = AsyncMock(return_value=BuildResult(
|
||||
success=False,
|
||||
image_name="",
|
||||
image_id="",
|
||||
build_time=5.0,
|
||||
size_bytes=0,
|
||||
installed_packages=[],
|
||||
cache_hit=False,
|
||||
error="Package 'invalid-pkg' not found"
|
||||
))
|
||||
|
||||
arguments = {
|
||||
"name": "test-env",
|
||||
"packages": ["invalid-pkg"]
|
||||
}
|
||||
|
||||
result = await build_environment_tool.execute(arguments)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is False
|
||||
assert "error" in response
|
||||
assert "invalid-pkg" in response["error"]
|
||||
188
tests/server/tools/test_document_state.py
Normal file
188
tests/server/tools/test_document_state.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""Tests for Document State Tool."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from mcp.types import Tool, TextContent
|
||||
import json
|
||||
|
||||
from mcp_forge.server.tools.document_state import DocumentStateTool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_manager():
|
||||
"""Mock session manager."""
|
||||
manager = Mock()
|
||||
# Mock get_session_state to return a session with state
|
||||
mock_session = Mock()
|
||||
mock_session.state = Mock()
|
||||
mock_session.state.to_dict = Mock(return_value={
|
||||
"variables": {"x": 1, "y": 2},
|
||||
"documented_variables": {},
|
||||
"note": None
|
||||
})
|
||||
manager.get_session_state = Mock(return_value=mock_session)
|
||||
manager.document_variables = AsyncMock(return_value={"success": True, "documented_count": 2})
|
||||
manager.session_exists = Mock(return_value=True)
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def document_state_tool(mock_session_manager):
|
||||
"""Create DocumentStateTool instance with mocked dependencies."""
|
||||
return DocumentStateTool(session_manager=mock_session_manager)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_definition(document_state_tool):
|
||||
"""Test that tool definition matches MCP spec."""
|
||||
definition = document_state_tool.get_tool_definition()
|
||||
|
||||
assert isinstance(definition, Tool)
|
||||
assert definition.name == "document_state"
|
||||
assert definition.description is not None
|
||||
assert "document" in definition.description.lower()
|
||||
|
||||
# Verify required schema properties
|
||||
schema = definition.inputSchema
|
||||
assert schema["type"] == "object"
|
||||
assert "session_id" in schema["properties"]
|
||||
assert "variables" in schema["properties"]
|
||||
assert "note" in schema["properties"]
|
||||
assert "clear" in schema["properties"]
|
||||
assert "session_id" in schema["required"]
|
||||
assert "variables" in schema["required"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_variables(
|
||||
document_state_tool,
|
||||
mock_session_manager
|
||||
):
|
||||
"""Test documenting variables in a session."""
|
||||
arguments = {
|
||||
"session_id": "test-session",
|
||||
"variables": {
|
||||
"df": "Customer data with 1000 rows",
|
||||
"model": "Trained RandomForest classifier"
|
||||
}
|
||||
}
|
||||
|
||||
result = await document_state_tool.execute(arguments)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], TextContent)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
assert response["documented_count"] == 2
|
||||
assert response["session_id"] == "test-session"
|
||||
|
||||
# Verify session manager was called
|
||||
mock_session_manager.document_variables.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_with_note(
|
||||
document_state_tool,
|
||||
mock_session_manager
|
||||
):
|
||||
"""Test documenting with a note."""
|
||||
arguments = {
|
||||
"session_id": "test-session",
|
||||
"variables": {
|
||||
"result": "Final analysis output"
|
||||
},
|
||||
"note": "Analysis complete, ready for reporting"
|
||||
}
|
||||
|
||||
result = await document_state_tool.execute(arguments)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
|
||||
# Verify note was passed
|
||||
call_args = mock_session_manager.document_variables.call_args
|
||||
assert call_args is not None
|
||||
assert call_args.kwargs["note"] == "Analysis complete, ready for reporting"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_existing_documentation(
|
||||
document_state_tool,
|
||||
mock_session_manager
|
||||
):
|
||||
"""Test clearing existing documentation."""
|
||||
arguments = {
|
||||
"session_id": "test-session",
|
||||
"variables": {
|
||||
"new_var": "New variable"
|
||||
},
|
||||
"clear": True
|
||||
}
|
||||
|
||||
result = await document_state_tool.execute(arguments)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
|
||||
# Verify clear flag was passed
|
||||
call_args = mock_session_manager.document_variables.call_args
|
||||
assert call_args is not None
|
||||
assert call_args.kwargs["clear"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_validation(
|
||||
document_state_tool,
|
||||
mock_session_manager
|
||||
):
|
||||
"""Test that validation fails for non-existent session."""
|
||||
mock_session_manager.session_exists = Mock(return_value=False)
|
||||
|
||||
arguments = {
|
||||
"session_id": "nonexistent",
|
||||
"variables": {
|
||||
"x": "test"
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Session"):
|
||||
await document_state_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_missing_session_id(document_state_tool):
|
||||
"""Test that validation fails when session_id is missing."""
|
||||
arguments = {
|
||||
"variables": {"x": "test"}
|
||||
}
|
||||
|
||||
with pytest.raises((ValueError, KeyError)):
|
||||
await document_state_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_missing_variables(document_state_tool):
|
||||
"""Test that validation fails when variables is missing."""
|
||||
arguments = {
|
||||
"session_id": "test-session"
|
||||
}
|
||||
|
||||
with pytest.raises((ValueError, KeyError)):
|
||||
await document_state_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_invalid_variables_type(document_state_tool):
|
||||
"""Test that validation fails when variables is not a dict."""
|
||||
arguments = {
|
||||
"session_id": "test-session",
|
||||
"variables": ["not", "a", "dict"]
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="variables"):
|
||||
await document_state_tool.execute(arguments)
|
||||
389
tests/server/tools/test_execute_python.py
Normal file
389
tests/server/tools/test_execute_python.py
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
"""Tests for Execute Python Tool."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from mcp.types import Tool, TextContent
|
||||
import json
|
||||
|
||||
from mcp_forge.server.tools.execute_python import ExecutePythonTool
|
||||
from mcp_forge.execution.simple.backend import ExecutionResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_simple_backend():
|
||||
"""Mock simple backend."""
|
||||
backend = Mock()
|
||||
backend.execute = AsyncMock(return_value=ExecutionResult(
|
||||
success=True,
|
||||
stdout="Hello World",
|
||||
stderr="",
|
||||
result="42",
|
||||
execution_time=0.5,
|
||||
exit_code=0
|
||||
))
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jupyter_backend():
|
||||
"""Mock jupyter backend."""
|
||||
backend = Mock()
|
||||
backend.execute = AsyncMock(return_value=ExecutionResult(
|
||||
success=True,
|
||||
stdout="Stateful execution",
|
||||
stderr="",
|
||||
result="session-123",
|
||||
execution_time=1.2,
|
||||
exit_code=0
|
||||
))
|
||||
backend.execute_in_session = AsyncMock(return_value=ExecutionResult(
|
||||
success=True,
|
||||
stdout="Using existing session",
|
||||
stderr="",
|
||||
result="[1, 2, 3]",
|
||||
execution_time=0.3,
|
||||
exit_code=0
|
||||
))
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client_manager():
|
||||
"""Mock MCP client manager."""
|
||||
manager = Mock()
|
||||
manager.list_all_tools = AsyncMock(return_value=[
|
||||
{"name": "github_search_repos", "description": "Search GitHub repositories"},
|
||||
{"name": "filesystem_read", "description": "Read file contents"}
|
||||
])
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bridge_server():
|
||||
"""Mock tool bridge server."""
|
||||
server = Mock()
|
||||
server.socket_path = "/tmp/mcp-bridge.sock"
|
||||
server.is_running = Mock(return_value=True)
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_injection_generator():
|
||||
"""Mock tool injection generator."""
|
||||
generator = Mock()
|
||||
generator.generate_injection_code = Mock(return_value="""
|
||||
# MCP Tool Injection
|
||||
def github_search_repos(**kwargs):
|
||||
import socket
|
||||
# ... tool implementation
|
||||
pass
|
||||
""")
|
||||
return generator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Mock forge configuration."""
|
||||
config = Mock()
|
||||
config.execution = Mock()
|
||||
config.execution.default_backend = "simple"
|
||||
config.execution.default_timeout = 300
|
||||
config.execution.max_timeout = 1800
|
||||
config.execution.default_memory = "512m"
|
||||
config.execution.max_memory = "2g"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def execute_python_tool(
|
||||
mock_simple_backend,
|
||||
mock_jupyter_backend,
|
||||
mock_client_manager,
|
||||
mock_bridge_server,
|
||||
mock_injection_generator,
|
||||
mock_config
|
||||
):
|
||||
"""Create ExecutePythonTool instance with mocked dependencies."""
|
||||
return ExecutePythonTool(
|
||||
simple_backend=mock_simple_backend,
|
||||
jupyter_backend=mock_jupyter_backend,
|
||||
client_manager=mock_client_manager,
|
||||
bridge_server=mock_bridge_server,
|
||||
injection_generator=mock_injection_generator,
|
||||
config=mock_config
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_definition(execute_python_tool):
|
||||
"""Test that tool definition matches MCP spec."""
|
||||
definition = execute_python_tool.get_tool_definition()
|
||||
|
||||
assert isinstance(definition, Tool)
|
||||
assert definition.name == "execute_python"
|
||||
assert definition.description is not None
|
||||
assert "Execute Python code" in definition.description
|
||||
|
||||
# Verify required schema properties
|
||||
schema = definition.inputSchema
|
||||
assert schema["type"] == "object"
|
||||
assert "code" in schema["properties"]
|
||||
assert "mcp_tools" in schema["properties"]
|
||||
assert "session_id" in schema["properties"]
|
||||
assert "backend" in schema["properties"]
|
||||
assert "timeout" in schema["properties"]
|
||||
assert "custom_image" in schema["properties"]
|
||||
assert "environment" in schema["properties"]
|
||||
assert schema["required"] == ["code"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_simple_backend_stateless(
|
||||
execute_python_tool,
|
||||
mock_simple_backend
|
||||
):
|
||||
"""Test execution with simple backend (stateless)."""
|
||||
arguments = {
|
||||
"code": "print('Hello World'); 42"
|
||||
}
|
||||
|
||||
result = await execute_python_tool.execute(arguments)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], TextContent)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
assert response["stdout"] == "Hello World"
|
||||
assert response["result"] == "42"
|
||||
assert response["execution_time"] == 0.5
|
||||
assert "session_id" not in response or response["session_id"] is None
|
||||
|
||||
# Verify simple backend was called
|
||||
mock_simple_backend.execute.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_jupyter_backend_stateful(
|
||||
execute_python_tool,
|
||||
mock_jupyter_backend
|
||||
):
|
||||
"""Test execution with jupyter backend (stateful)."""
|
||||
arguments = {
|
||||
"code": "x = 42; print('Stateful')",
|
||||
"session_id": "test-session",
|
||||
"backend": "jupyter"
|
||||
}
|
||||
|
||||
result = await execute_python_tool.execute(arguments)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
assert "session_id" in response
|
||||
|
||||
# Verify jupyter backend was called
|
||||
mock_jupyter_backend.execute_in_session.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_mcp_tools(
|
||||
execute_python_tool,
|
||||
mock_simple_backend,
|
||||
mock_injection_generator,
|
||||
mock_bridge_server
|
||||
):
|
||||
"""Test execution with MCP tool injection."""
|
||||
arguments = {
|
||||
"code": "repos = github_search_repos(query='test', max_results=10)",
|
||||
"mcp_tools": ["github_search_repos"]
|
||||
}
|
||||
|
||||
result = await execute_python_tool.execute(arguments)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is True
|
||||
assert "available_tools" in response
|
||||
assert "github_search_repos" in response["available_tools"]
|
||||
|
||||
# Verify injection generator was called
|
||||
mock_injection_generator.generate_injection_code.assert_called_once_with(
|
||||
tool_names=["github_search_repos"],
|
||||
socket_path="/tmp/mcp-bridge.sock"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_custom_image(
|
||||
execute_python_tool,
|
||||
mock_simple_backend
|
||||
):
|
||||
"""Test execution with custom image."""
|
||||
arguments = {
|
||||
"code": "import pandas as pd; pd.DataFrame()",
|
||||
"custom_image": "mcp-forge/custom:my-ml-env"
|
||||
}
|
||||
|
||||
_result = await execute_python_tool.execute(arguments)
|
||||
|
||||
# Verify backend was called with custom image
|
||||
call_args = mock_simple_backend.execute.call_args
|
||||
assert call_args is not None
|
||||
# Check that custom_image was passed in execution options
|
||||
assert "image" in call_args.kwargs or "custom_image" in call_args.kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_environment_template(
|
||||
execute_python_tool,
|
||||
mock_simple_backend
|
||||
):
|
||||
"""Test execution with environment template."""
|
||||
arguments = {
|
||||
"code": "import numpy as np; np.array([1,2,3])",
|
||||
"environment": "datascience"
|
||||
}
|
||||
|
||||
_result = await execute_python_tool.execute(arguments)
|
||||
|
||||
# Verify backend was called with environment specification
|
||||
mock_simple_backend.execute.assert_called_once()
|
||||
call_args = mock_simple_backend.execute.call_args
|
||||
assert call_args is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_timeout(
|
||||
execute_python_tool,
|
||||
mock_simple_backend
|
||||
):
|
||||
"""Test execution with custom timeout."""
|
||||
arguments = {
|
||||
"code": "import time; time.sleep(10)",
|
||||
"timeout": 5
|
||||
}
|
||||
|
||||
_result = await execute_python_tool.execute(arguments)
|
||||
|
||||
# Verify timeout was passed to backend
|
||||
call_args = mock_simple_backend.execute.call_args
|
||||
assert call_args is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_missing_code(execute_python_tool):
|
||||
"""Test that validation fails when code is missing."""
|
||||
arguments = {}
|
||||
|
||||
with pytest.raises((ValueError, KeyError)):
|
||||
await execute_python_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_invalid_backend(execute_python_tool):
|
||||
"""Test that validation fails for invalid backend."""
|
||||
arguments = {
|
||||
"code": "print('test')",
|
||||
"backend": "invalid"
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="backend"):
|
||||
await execute_python_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_arguments_invalid_timeout(
|
||||
execute_python_tool,
|
||||
mock_config
|
||||
):
|
||||
"""Test that validation fails for timeout exceeding max."""
|
||||
arguments = {
|
||||
"code": "print('test')",
|
||||
"timeout": 3600 # Exceeds max_timeout of 1800
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Timeout"):
|
||||
await execute_python_tool.execute(arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_selection_with_session_id(execute_python_tool):
|
||||
"""Test that jupyter backend is selected when session_id provided."""
|
||||
backend = execute_python_tool._select_backend(
|
||||
session_id="test-session",
|
||||
backend=None
|
||||
)
|
||||
|
||||
assert backend == "jupyter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_selection_default(
|
||||
execute_python_tool,
|
||||
mock_config
|
||||
):
|
||||
"""Test that default backend is used when no session_id."""
|
||||
backend = execute_python_tool._select_backend(
|
||||
session_id=None,
|
||||
backend=None
|
||||
)
|
||||
|
||||
assert backend == "simple"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_selection_explicit(execute_python_tool):
|
||||
"""Test that explicit backend is respected."""
|
||||
backend = execute_python_tool._select_backend(
|
||||
session_id=None,
|
||||
backend="jupyter"
|
||||
)
|
||||
|
||||
assert backend == "jupyter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_error_handling(
|
||||
execute_python_tool,
|
||||
mock_simple_backend
|
||||
):
|
||||
"""Test that execution errors are handled gracefully."""
|
||||
mock_simple_backend.execute = AsyncMock(return_value=ExecutionResult(
|
||||
success=False,
|
||||
stdout="",
|
||||
stderr="NameError: name 'undefined_variable' is not defined",
|
||||
result=None,
|
||||
execution_time=0.1,
|
||||
exit_code=1
|
||||
))
|
||||
|
||||
arguments = {
|
||||
"code": "print(undefined_variable)"
|
||||
}
|
||||
|
||||
result = await execute_python_tool.execute(arguments)
|
||||
|
||||
# Parse JSON response
|
||||
response = json.loads(result[0].text)
|
||||
assert response["success"] is False
|
||||
assert "NameError" in response["stderr"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_server_not_running(
|
||||
execute_python_tool,
|
||||
mock_bridge_server
|
||||
):
|
||||
"""Test that error is raised if bridge server not running."""
|
||||
mock_bridge_server.is_running = Mock(return_value=False)
|
||||
|
||||
arguments = {
|
||||
"code": "repos = github_search_repos(query='test')",
|
||||
"mcp_tools": ["github_search_repos"]
|
||||
}
|
||||
|
||||
with pytest.raises(RuntimeError, match="bridge server"):
|
||||
await execute_python_tool.execute(arguments)
|
||||
Loading…
Add table
Add a link
Reference in a new issue