Files
AegisGitea-MCP/tests/test_config.py
T

394 lines
14 KiB
Python

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