mcp-forge/tests/config/test_loader.py

440 lines
11 KiB
Python
Raw Normal View History

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