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