"""Tests for configuration management.""" from __future__ import annotations import pytest from pydantic import ValidationError from aegis_gitea_mcp.config import ( DEFAULT_OAUTH_SCOPES, GITEA_SCOPES, Settings, get_settings, is_loopback_host, reset_settings, ) def make(monkeypatch: pytest.MonkeyPatch, **env: str) -> Settings: """Build fresh settings from the given environment.""" for key, value in env.items(): monkeypatch.setenv(key, value) reset_settings() return get_settings() def test_defaults(mock_env: None, tmp_path) -> None: settings = get_settings() assert settings.gitea_url == "https://gitea.example.com" assert settings.gitea_api_url == "https://gitea.example.com/api/v1" assert settings.gitea_token is not None assert settings.gitea_token.get_secret_value() == "test-token-12345" assert settings.mcp_transport == "stdio" assert settings.mcp_host == "127.0.0.1" assert settings.mcp_port == 8080 assert settings.mcp_path == "/mcp" assert settings.mcp_bearer_token is None assert settings.public_base_url is None assert settings.allowed_hosts == [] assert settings.oauth_enabled is False assert settings.read_only is False assert settings.toolsets == ["all"] assert settings.disabled_tools == [] assert settings.request_timeout_seconds == 30 assert settings.max_tool_response_items == 200 assert settings.max_tool_response_chars == 60000 assert settings.max_binary_bytes == 5_242_880 assert settings.secret_detection_mode == "mask" assert settings.log_level == "INFO" assert settings.expose_error_details is False assert settings.rate_limit_per_minute == 120 assert ",".join(settings.oauth_scopes) == DEFAULT_OAUTH_SCOPES def test_data_dir_defaults_and_audit_path(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("DATA_DIR") monkeypatch.delenv("AUDIT_LOG_PATH") settings = make(monkeypatch, GITEA_URL="https://g.example.com") assert str(settings.data_dir).endswith(".local/state/aegis-gitea-mcp") assert "~" not in str(settings.data_dir) assert settings.resolved_audit_log_path == settings.data_dir / "audit.log" def test_audit_path_follows_data_dir(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: monkeypatch.delenv("AUDIT_LOG_PATH") settings = make(monkeypatch, GITEA_URL="https://g.example.com", DATA_DIR=str(tmp_path / "d")) assert settings.resolved_audit_log_path == tmp_path / "d" / "audit.log" def test_blank_values_are_unset(monkeypatch: pytest.MonkeyPatch) -> None: settings = make( monkeypatch, GITEA_URL="https://g.example.com", GITEA_TOKEN="", MCP_BEARER_TOKEN=" ", PUBLIC_BASE_URL="", AUDIT_LOG_PATH="", ) assert settings.gitea_token is None assert settings.mcp_bearer_token is None assert settings.public_base_url is None assert settings.audit_log_path is None def test_env_names_are_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None: settings = make(monkeypatch, gitea_url="https://g.example.com", mcp_port="9000") assert settings.mcp_port == 9000 def test_reads_dotenv_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: dotenv = tmp_path / ".env" dotenv.write_text("GITEA_URL=https://dotenv.example.com\nREAD_ONLY=true\n") dotenv.chmod(0o600) # an implicit ./.env is only trusted with mode 0600 (SEC-04) reset_settings() settings = get_settings() assert settings.gitea_url == "https://dotenv.example.com" assert settings.read_only is True def test_missing_gitea_url_is_rejected() -> None: with pytest.raises(ValidationError): get_settings() def test_settings_singleton_and_reset(mock_env: None) -> None: first = get_settings() assert get_settings() is first reset_settings() assert get_settings() is not first @pytest.mark.parametrize( ("raw", "expected"), [ ("https://gitea.example.com", "https://gitea.example.com"), ("https://gitea.example.com/", "https://gitea.example.com"), ("http://localhost:3000", "http://localhost:3000"), ("https://gitea.example.com/api/v1", "https://gitea.example.com"), ("https://gitea.example.com/api/v1/", "https://gitea.example.com"), ("https://example.com/gitea", "https://example.com/gitea"), ("https://example.com/gitea/api/v1", "https://example.com/gitea"), ], ) def test_gitea_url_normalised(monkeypatch: pytest.MonkeyPatch, raw: str, expected: str) -> None: assert make(monkeypatch, GITEA_URL=raw).gitea_url == expected @pytest.mark.parametrize( "raw", [ "ftp://gitea.example.com", "gitea.example.com", "https://", "https://user:pw@gitea.example.com", "https://user@gitea.example.com", "https://gitea.example.com?x=1", "https://gitea.example.com/#frag", "https://gitea.example.com:99999", "file:///etc/passwd", ], ) def test_gitea_url_rejected(monkeypatch: pytest.MonkeyPatch, raw: str) -> None: monkeypatch.setenv("GITEA_URL", raw) with pytest.raises(ValidationError): get_settings() def test_bearer_token_minimum_length(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") monkeypatch.setenv("MCP_BEARER_TOKEN", "short") with pytest.raises(ValidationError, match="at least 32"): get_settings() settings = make(monkeypatch, MCP_BEARER_TOKEN="x" * 32) assert settings.mcp_bearer_token is not None def test_encryption_key_minimum_length(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") monkeypatch.setenv("TOKEN_ENCRYPTION_KEY", "short") with pytest.raises(ValidationError, match="TOKEN_ENCRYPTION_KEY"): get_settings() @pytest.mark.parametrize( "raw", [ "https://mcp.example.com", "https://mcp.example.com/", "http://localhost:8080", "http://127.0.0.1:8080", "http://[::1]:8080", ], ) def test_public_base_url_accepted(monkeypatch: pytest.MonkeyPatch, raw: str) -> None: settings = make(monkeypatch, GITEA_URL="https://g.example.com", PUBLIC_BASE_URL=raw) assert settings.public_base_url == raw.rstrip("/") @pytest.mark.parametrize( "raw", [ "http://mcp.example.com", "mcp.example.com", "https://mcp.example.com?a=b", "https://u:p@mcp.example.com", ], ) def test_public_base_url_rejected(monkeypatch: pytest.MonkeyPatch, raw: str) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") monkeypatch.setenv("PUBLIC_BASE_URL", raw) with pytest.raises(ValidationError): get_settings() def test_mcp_path_validation(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") assert make(monkeypatch, MCP_PATH="/api/mcp/").mcp_path == "/api/mcp" for bad in ("mcp", "//evil", "/a?b", "/", "/a b"): monkeypatch.setenv("MCP_PATH", bad) reset_settings() with pytest.raises(ValidationError): get_settings() def test_port_and_transport_validation(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") monkeypatch.setenv("MCP_PORT", "70000") with pytest.raises(ValidationError): get_settings() monkeypatch.setenv("MCP_PORT", "8080") monkeypatch.setenv("MCP_TRANSPORT", "sse") with pytest.raises(ValidationError): get_settings() assert make(monkeypatch, MCP_TRANSPORT="http").mcp_transport == "http" def test_comma_separated_lists(monkeypatch: pytest.MonkeyPatch) -> None: settings = make( monkeypatch, GITEA_URL="https://g.example.com", ALLOWED_HOSTS="mcp.example.com, other.example.com:8443,mcp.example.com", TOOLSETS="Repo, issue", DISABLED_TOOLS="Gitea_Repo_Delete, gitea_admin_delete_user", ) assert settings.allowed_hosts == ["mcp.example.com", "other.example.com:8443"] assert settings.toolsets == ["repo", "issue"] assert settings.disabled_tools == ["gitea_repo_delete", "gitea_admin_delete_user"] def test_unknown_toolset_rejected(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") monkeypatch.setenv("TOOLSETS", "repo,bogus") with pytest.raises(ValidationError, match="bogus"): get_settings() def test_empty_toolsets_rejected(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") monkeypatch.setenv("TOOLSETS", " , ") with pytest.raises(ValidationError): get_settings() def test_default_oauth_scopes_are_valid() -> None: assert set(DEFAULT_OAUTH_SCOPES.split(",")) <= GITEA_SCOPES assert len(GITEA_SCOPES) == 18 def test_oauth_scopes_custom_and_dedup(monkeypatch: pytest.MonkeyPatch) -> None: settings = make( monkeypatch, GITEA_URL="https://g.example.com", OAUTH_SCOPES="read:repository write:repository,read:repository", ) assert settings.oauth_scopes == ["read:repository", "write:repository"] @pytest.mark.parametrize( "scope", ["all", "repo", "read:bogus", "write:repository,admin", "public-only"] ) def test_unknown_oauth_scope_rejected(monkeypatch: pytest.MonkeyPatch, scope: str) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") monkeypatch.setenv("OAUTH_SCOPES", scope) with pytest.raises(ValidationError, match="unknown scope"): get_settings() def test_empty_oauth_scopes_rejected(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") monkeypatch.setenv("OAUTH_SCOPES", ",") with pytest.raises(ValidationError, match="must not be empty"): get_settings() def test_log_level_normalised_and_validated(monkeypatch: pytest.MonkeyPatch) -> None: assert ( make(monkeypatch, GITEA_URL="https://g.example.com", LOG_LEVEL="debug").log_level == "DEBUG" ) monkeypatch.setenv("LOG_LEVEL", "loud") reset_settings() with pytest.raises(ValidationError): get_settings() def test_secret_detection_mode_validated(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITEA_URL", "https://g.example.com") monkeypatch.setenv("SECRET_DETECTION_MODE", "block") assert get_settings().secret_detection_mode == "block" reset_settings() monkeypatch.setenv("SECRET_DETECTION_MODE", "weird") with pytest.raises(ValidationError): get_settings() @pytest.mark.parametrize( ("host", "expected"), [ ("127.0.0.1", True), ("127.5.6.7", True), ("::1", True), ("[::1]", True), ("localhost", True), ("LOCALHOST", True), ("0.0.0.0", False), # noqa: S104 ("::", False), ("192.168.1.5", False), ("gitea.example.com", False), ], ) def test_is_loopback(monkeypatch: pytest.MonkeyPatch, host: str, expected: bool) -> None: assert is_loopback_host(host) is expected settings = make(monkeypatch, GITEA_URL="https://g.example.com", MCP_HOST=host) assert settings.is_loopback_bind() is expected def test_oauth_config_errors(monkeypatch: pytest.MonkeyPatch) -> None: settings = make(monkeypatch, GITEA_URL="https://g.example.com") assert settings.oauth_config_errors() == [] settings = make(monkeypatch, OAUTH_ENABLED="true") errors = settings.oauth_config_errors() assert len(errors) == 4 assert any("GITEA_OAUTH_CLIENT_ID" in e for e in errors) assert any("GITEA_OAUTH_CLIENT_SECRET" in e for e in errors) assert any("TOKEN_ENCRYPTION_KEY" in e for e in errors) assert any("PUBLIC_BASE_URL" in e for e in errors) settings = make( monkeypatch, GITEA_OAUTH_CLIENT_ID="cid", GITEA_OAUTH_CLIENT_SECRET="csecret", TOKEN_ENCRYPTION_KEY="k" * 32, PUBLIC_BASE_URL="https://mcp.example.com", ) assert settings.oauth_config_errors() == [] def test_http_auth_configured(monkeypatch: pytest.MonkeyPatch) -> None: assert make(monkeypatch, GITEA_URL="https://g.example.com").http_auth_configured() is False assert make(monkeypatch, MCP_BEARER_TOKEN="b" * 32).http_auth_configured() is True monkeypatch.delenv("MCP_BEARER_TOKEN") assert make(monkeypatch, OAUTH_ENABLED="true").http_auth_configured() is False settings = make( monkeypatch, GITEA_OAUTH_CLIENT_ID="cid", GITEA_OAUTH_CLIENT_SECRET="csecret", TOKEN_ENCRYPTION_KEY="k" * 32, PUBLIC_BASE_URL="https://mcp.example.com", ) assert settings.http_auth_configured() is True def test_secrets_never_appear_in_repr_or_dump(monkeypatch: pytest.MonkeyPatch) -> None: secrets = { "GITEA_TOKEN": "gitea-token-SECRET-aaaaaaaaaaaa", "MCP_BEARER_TOKEN": "bearer-SECRET-bbbbbbbbbbbbbbbbbbbbbbbbbb", "GITEA_OAUTH_CLIENT_SECRET": "client-SECRET-cccccccccccc", "TOKEN_ENCRYPTION_KEY": "encryption-SECRET-dddddddddddddddddddd", } settings = make(monkeypatch, GITEA_URL="https://g.example.com", **secrets) rendered = [ repr(settings), str(settings), str(settings.model_dump()), settings.model_dump_json(), str(settings.model_dump(mode="json")), ] for text in rendered: for secret in secrets.values(): assert secret not in text assert settings.gitea_token is not None assert settings.gitea_token.get_secret_value() == secrets["GITEA_TOKEN"] def test_env_example_is_valid() -> None: """The documented example file must load as-is (blank values mean unset).""" from pathlib import Path example = Path(__file__).parent.parent / ".env.example" settings = Settings(_env_file=example) # type: ignore[call-arg] assert settings.gitea_url == "https://gitea.example.com" assert settings.gitea_token is None assert settings.toolsets == ["all"] assert ",".join(settings.oauth_scopes) == DEFAULT_OAUTH_SCOPES