Adds the pydantic-settings Settings with validated GITEA_URL, OAuth scopes and toolsets, the argparse CLI skeleton, catalog.model, identity and GiteaAPIError. Test fixtures now isolate env, cwd and state paths in tmp.
392 lines
14 KiB
Python
392 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:
|
|
(tmp_path / ".env").write_text("GITEA_URL=https://dotenv.example.com\nREAD_ONLY=true\n")
|
|
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
|