initial commit after one day coding agent session
This commit is contained in:
commit
372af75b90
88 changed files with 22694 additions and 0 deletions
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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue