Files
AegisGitea-MCP/tests/test_transports_startup.py
T

444 lines
15 KiB
Python

"""Tests for the transport runners: fail-closed startup, uvicorn wiring, stdio, serve CLI."""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
import pytest
from mcp import Client
from mcp.client.stdio import StdioServerParameters, stdio_client
from aegis_gitea_mcp import cli, transports
from aegis_gitea_mcp.cli import main
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.oauth import OAuthSetupError
from aegis_gitea_mcp.transports import (
HttpAuthExtras,
http_startup_errors,
load_oauth_components,
run_http,
run_stdio,
)
from tests.test_server_helpers import ANY_HOST, BEARER_TOKEN, GITEA_TOKEN, server_settings
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""Settings objects here are built explicitly; the audit logger still needs GITEA_URL."""
class UvicornSpy:
"""Replaces ``uvicorn.run`` and records its arguments."""
def __init__(self) -> None:
self.calls: list[tuple[Any, dict[str, Any]]] = []
def __call__(self, app: Any, **kwargs: Any) -> None:
self.calls.append((app, kwargs))
@pytest.fixture
def uvicorn_spy(monkeypatch: pytest.MonkeyPatch) -> UvicornSpy:
spy = UvicornSpy()
monkeypatch.setattr(transports.uvicorn, "run", spy)
return spy
def oauth_settings(**overrides: Any) -> Settings:
values: dict[str, Any] = {
"oauth_enabled": True,
"gitea_oauth_client_id": "client",
"gitea_oauth_client_secret": "s" * 32,
"token_encryption_key": "k" * 32,
"public_base_url": "https://mcp.example.com",
}
values.update(overrides)
return server_settings(**values)
# HTTP startup checks
def test_non_loopback_bind_without_auth_is_refused(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(server_settings(mcp_host=ANY_HOST)) == 2
captured = capsys.readouterr()
assert uvicorn_spy.calls == []
assert "without HTTP authentication" in captured.err
assert "MCP_BEARER_TOKEN" in captured.err
assert captured.out == ""
def test_non_loopback_bind_with_bearer_is_allowed(uvicorn_spy: UvicornSpy) -> None:
settings = server_settings(mcp_host=ANY_HOST, mcp_port=9000, mcp_bearer_token=BEARER_TOKEN)
assert run_http(settings) == 0
((app, kwargs),) = uvicorn_spy.calls
assert callable(app)
assert kwargs["host"] == ANY_HOST
assert kwargs["port"] == 9000
assert kwargs["proxy_headers"] is True
assert kwargs["forwarded_allow_ips"] == "127.0.0.1"
assert kwargs["log_level"] == "info"
assert kwargs["server_header"] is False
def test_loopback_without_auth_runs(uvicorn_spy: UvicornSpy) -> None:
assert run_http(server_settings(log_level="DEBUG")) == 0
assert uvicorn_spy.calls[0][1]["host"] == "127.0.0.1"
assert uvicorn_spy.calls[0][1]["log_level"] == "debug"
def test_forwarded_allow_ips_setting_is_passed_to_uvicorn(uvicorn_spy: UvicornSpy) -> None:
assert run_http(server_settings(forwarded_allow_ips="10.0.0.0/8, 172.18.0.2")) == 0
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "10.0.0.0/8,172.18.0.2"
def test_forwarded_allow_ips_wildcard_needs_an_explicit_value_and_warns(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert server_settings().forwarded_allow_ips == "127.0.0.1"
assert run_http(server_settings(forwarded_allow_ips="*")) == 0
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "*"
assert "FORWARDED_ALLOW_IPS=*" in capsys.readouterr().err
def test_non_loopback_bind_without_public_host_warns_which_variables_to_set(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(server_settings(mcp_host=ANY_HOST, mcp_bearer_token=BEARER_TOKEN)) == 0
err = capsys.readouterr().err
assert "PUBLIC_BASE_URL" in err and "ALLOWED_HOSTS" in err and "421" in err
@pytest.mark.parametrize(
"extra",
[{"public_base_url": "https://mcp.example.com"}, {"allowed_hosts": ["mcp.example.com"]}],
)
def test_no_host_warning_when_a_public_host_is_configured(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str], extra: dict[str, Any]
) -> None:
settings = server_settings(mcp_host=ANY_HOST, mcp_bearer_token=BEARER_TOKEN, **extra)
assert run_http(settings) == 0
assert "421" not in capsys.readouterr().err
def test_no_host_warning_on_loopback(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(server_settings()) == 0
assert capsys.readouterr().err == ""
def test_oauth_enabled_with_a_bearer_warns_that_oauth_is_unavailable(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0
assert "per-user OAuth is not available" in capsys.readouterr().err
@pytest.mark.parametrize("value", ["*,10.0.0.1", "not-an-ip", "10.0.0.1/99"])
def test_invalid_forwarded_allow_ips_is_rejected(value: str) -> None:
with pytest.raises(ValueError, match="FORWARDED_ALLOW_IPS"):
server_settings(forwarded_allow_ips=value)
def test_blank_forwarded_allow_ips_means_the_default() -> None:
assert server_settings(forwarded_allow_ips=" ").forwarded_allow_ips == "127.0.0.1"
# Audit log at startup
@pytest.fixture
def unwritable_audit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""An audit path whose parent is a regular file, so it can never be opened."""
blocker = tmp_path / "blocker"
blocker.write_text("x", encoding="utf-8")
path = blocker / "audit.log"
monkeypatch.setenv("AUDIT_LOG_PATH", str(path))
return path
def test_http_exits_2_when_the_audit_log_cannot_be_opened(
uvicorn_spy: UvicornSpy, unwritable_audit: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(server_settings(audit_log_path=unwritable_audit)) == 2
captured = capsys.readouterr()
assert uvicorn_spy.calls == []
assert "audit log" in captured.err and "AUDIT_LOG_PATH" in captured.err
assert str(unwritable_audit) in captured.err
assert captured.out == ""
def test_stdio_exits_2_when_the_audit_log_cannot_be_opened(
unwritable_audit: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(transports.anyio, "run", lambda *a: pytest.fail("must not serve"))
assert run_stdio(server_settings(audit_log_path=unwritable_audit)) == 2
captured = capsys.readouterr()
assert "AUDIT_LOG_PATH" in captured.err
assert captured.out == ""
def test_oauth_misconfiguration_lists_every_missing_setting(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
settings = server_settings(oauth_enabled=True, mcp_bearer_token=BEARER_TOKEN)
assert run_http(settings) == 2
err = capsys.readouterr().err
for name in (
"GITEA_OAUTH_CLIENT_ID",
"GITEA_OAUTH_CLIENT_SECRET",
"TOKEN_ENCRYPTION_KEY",
"PUBLIC_BASE_URL",
):
assert name in err
assert uvicorn_spy.calls == []
def test_oauth_enabled_but_unavailable_does_not_start(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
def broken(settings: Settings) -> HttpAuthExtras:
raise OAuthSetupError("token store unreadable")
monkeypatch.setattr(transports, "load_oauth_components", broken)
assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 2
assert "per-user OAuth cannot start: token store unreadable" in capsys.readouterr().err
assert uvicorn_spy.calls == []
def test_oauth_enabled_with_a_bearer_serves_both(uvicorn_spy: UvicornSpy) -> None:
assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0
assert len(uvicorn_spy.calls) == 1
def test_oauth_components_are_used_when_available(
uvicorn_spy: UvicornSpy, monkeypatch: pytest.MonkeyPatch
) -> None:
class Verifier:
async def verify_token(self, token: str) -> None:
return None
extras = HttpAuthExtras(token_verifier=Verifier())
monkeypatch.setattr(transports, "load_oauth_components", lambda settings: extras)
settings = oauth_settings(mcp_host=ANY_HOST, gitea_token=None)
assert run_http(settings) == 0
assert len(uvicorn_spy.calls) == 1
def test_default_oauth_components_hook_builds_the_oauth_components() -> None:
extras = load_oauth_components(oauth_settings())
assert extras.auth_server_provider is not None
assert extras.token_verifier is not None and extras.resolver is not None
def test_startup_errors_without_oauth_components() -> None:
errors = http_startup_errors(oauth_settings(mcp_bearer_token=BEARER_TOKEN), None)
assert errors == ["OAUTH_ENABLED=true but per-user OAuth could not be set up"]
def test_bearer_without_gitea_token_is_refused(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(server_settings(mcp_bearer_token=BEARER_TOKEN, gitea_token=None)) == 2
assert "GITEA_TOKEN" in capsys.readouterr().err
def test_http_startup_errors_is_empty_for_a_valid_setup() -> None:
assert http_startup_errors(server_settings(mcp_bearer_token=BEARER_TOKEN), None) == []
def test_bad_disabled_tools_stop_http_startup(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(server_settings(disabled_tools=["gitea_nope"])) == 2
assert "gitea_nope" in capsys.readouterr().err
assert uvicorn_spy.calls == []
# stdio
def test_stdio_without_a_token_exits_2(capsys: pytest.CaptureFixture[str]) -> None:
assert run_stdio(server_settings(gitea_token=None)) == 2
captured = capsys.readouterr()
assert "GITEA_TOKEN" in captured.err
assert captured.out == ""
def test_stdio_with_bad_disabled_tools_exits_2(capsys: pytest.CaptureFixture[str]) -> None:
assert run_stdio(server_settings(disabled_tools=["nope"])) == 2
assert "nope" in capsys.readouterr().err
def test_stdio_serves_the_built_server(monkeypatch: pytest.MonkeyPatch) -> None:
served: list[Any] = []
def fake_run(function: Any, server: Any) -> None:
served.append((function, server))
monkeypatch.setattr(transports.anyio, "run", fake_run)
assert run_stdio(server_settings()) == 0
function, server = served[0]
assert function is transports._serve_stdio
assert server.tool_count == 538
async def test_real_stdio_subprocess_serves_the_catalog(tmp_path: Path) -> None:
env = {
"GITEA_URL": "http://127.0.0.1:9",
"GITEA_TOKEN": GITEA_TOKEN,
"DATA_DIR": str(tmp_path / "state"),
"AUDIT_LOG_PATH": str(tmp_path / "state" / "audit.log"),
"REQUEST_TIMEOUT_SECONDS": "2",
}
params = StdioServerParameters(
command=sys.executable,
args=["-m", "aegis_gitea_mcp.cli", "serve", "--transport", "stdio"],
env=env,
cwd=str(tmp_path),
)
async with Client(stdio_client(params)) as client:
tools = (await client.list_tools()).tools
result = await client.call_tool("gitea_repo_get", {"owner": "o", "repo": "r"})
assert len(tools) == 538
# Gitea is unreachable: the call fails as an error result, stdout stayed valid JSON-RPC.
assert result.is_error
payload = json.loads(result.content[0].text) # type: ignore[union-attr]
assert "error" in payload and GITEA_TOKEN not in json.dumps(payload)
audit = (tmp_path / "state" / "audit.log").read_text(encoding="utf-8").splitlines()
assert len(audit) == 1
assert GITEA_TOKEN not in audit[0]
# serve command
def test_serve_command_applies_flags_and_runs_http(monkeypatch: pytest.MonkeyPatch) -> None:
# Set first so monkeypatch restores whatever ``serve`` writes into the environment.
monkeypatch.setenv("MCP_TRANSPORT", "stdio")
monkeypatch.setenv("MCP_HOST", "127.0.0.1")
monkeypatch.setenv("MCP_PORT", "8080")
seen: list[Settings] = []
monkeypatch.setattr(transports, "run_http", lambda settings: seen.append(settings) or 0)
monkeypatch.setattr(transports, "run_stdio", lambda settings: pytest.fail("wrong transport"))
code = main(["serve", "--transport", "http", "--host", "127.0.0.2", "--port", "9123"])
assert code == 0
assert (seen[0].mcp_transport, seen[0].mcp_host, seen[0].mcp_port) == (
"http",
"127.0.0.2",
9123,
)
def test_serve_defaults_to_the_configured_transport(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MCP_TRANSPORT", "http")
seen: list[str] = []
monkeypatch.setattr(transports, "run_http", lambda settings: seen.append("http") or 0)
monkeypatch.setattr(transports, "run_stdio", lambda settings: seen.append("stdio") or 0)
assert main([]) == 0
assert seen == ["http"]
def test_serve_defaults_to_stdio(monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[str] = []
monkeypatch.setattr(transports, "run_stdio", lambda settings: seen.append("stdio") or 0)
assert main(["serve"]) == 0
assert seen == ["stdio"]
def test_serve_with_invalid_configuration_never_echoes_values(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv("MCP_BEARER_TOKEN", "too-short-secret-VALUE")
monkeypatch.setattr(transports, "run_stdio", lambda settings: pytest.fail("must not run"))
assert main(["serve"]) == 2
captured = capsys.readouterr()
assert "MCP_BEARER_TOKEN" in captured.err
assert "too-short-secret-VALUE" not in captured.err + captured.out
def test_serve_without_gitea_url_exits_2(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.delenv("GITEA_URL")
assert main(["serve"]) == 2
assert "GITEA_URL" in capsys.readouterr().err
def test_serve_rejects_an_out_of_range_port() -> None:
with pytest.raises(SystemExit):
cli.build_parser().parse_args(["serve", "--port", "70000"])
async def test_serve_stdio_runs_the_server_over_the_stdio_streams(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``_serve_stdio`` hands the stdio streams to the server (streams replaced by memory)."""
from contextlib import asynccontextmanager
import anyio
from mcp.client.session import ClientSession
from mcp.shared.memory import create_client_server_memory_streams
from aegis_gitea_mcp.server import build_server
async with create_client_server_memory_streams() as (client_streams, server_streams):
@asynccontextmanager
async def fake_stdio() -> Any:
yield server_streams
monkeypatch.setattr(transports, "stdio_server", fake_stdio)
server = build_server(server_settings())
async with anyio.create_task_group() as group:
group.start_soon(transports._serve_stdio, server)
async with ClientSession(*client_streams) as session:
await session.initialize()
tools = await session.list_tools()
group.cancel_scope.cancel()
assert len(tools.tools) == 538