initial commit after one day coding agent session

This commit is contained in:
Hans Aschauer 2026-02-07 07:45:57 +01:00
commit 372af75b90
88 changed files with 22694 additions and 0 deletions

View 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

View 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=[]
)

View 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)

View 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