446 lines
15 KiB
Python
446 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_starts_without_warnings(
|
|
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0
|
|
|
|
assert 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()
|
|
events = [json.loads(line)["event_type"] for line in audit]
|
|
# A write-ahead entry, then the one tool_invocation entry with the result.
|
|
assert events == ["tool_call_started", "tool_invocation"]
|
|
assert all(GITEA_TOKEN not in line for line in audit)
|
|
|
|
|
|
# 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
|