Files
AegisGitea-MCP/tests/test_transports_startup.py
T
Latte cf3d2e17d3 fix: fail closed on an unwritable audit log, bound the rate limiter and harden startup
The audit logger is opened at startup (exit 2 on failure) and obtained before
every call, which is refused with audit_unavailable otherwise. Audit records only
take validated path and sudo values and use the request id as correlation id. The
rate limiter evicts idle keys, caps tracked keys, audits the first excess per key
and window and uses the settings passed to the app. FORWARDED_ALLOW_IPS is now a
setting, and startup warns about a missing host allow-list and unavailable OAuth.
2026-10-14 16:24:51 +02:00

429 lines
14 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.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_without_a_bearer(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(oauth_settings()) == 2
assert "not available in this build" in capsys.readouterr().err
assert uvicorn_spy.calls == []
def test_oauth_enabled_with_a_bearer_falls_back_to_the_bearer(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_returns_none() -> None:
assert load_oauth_components(oauth_settings()) is None
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