394 lines
14 KiB
Python
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
|