440 lines
11 KiB
Python
440 lines
11 KiB
Python
|
|
"""
|
||
|
|
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"
|