Files
AegisGitea-MCP/tests/test_transports.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

458 lines
16 KiB
Python

"""Tests for the streamable HTTP app: health, auth, DNS-rebinding, rate limiting, MCP e2e."""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager
from typing import Any
import httpx
import httpx2
import pytest
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server.auth.provider import AccessToken
from pytest_httpx import HTTPXMock
from starlette.routing import Route
from starlette.testclient import TestClient
from aegis_gitea_mcp import __version__
from aegis_gitea_mcp.auth import STATIC_CLIENT_ID
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.gitea import RAW_TOOL_NAME
from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.transports import (
GiteaProbe,
RateLimitMiddleware,
build_http_app,
transport_security,
)
from tests.test_server_helpers import (
ANY_HOST,
API,
BEARER_TOKEN,
GITEA_TOKEN,
mock_user,
server_settings,
)
BASE = "http://127.0.0.1:8080"
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0"},
},
}
MCP_HEADERS = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger and rate limiter read GITEA_URL from the environment."""
def http_settings(**overrides: Any) -> Settings:
"""Settings with a bearer token on port 8080."""
values: dict[str, Any] = {"mcp_bearer_token": BEARER_TOKEN, "mcp_port": 8080}
values.update(overrides)
return server_settings(**values)
@pytest.fixture
def client(request: pytest.FixtureRequest) -> Iterator[TestClient]:
settings = http_settings()
with TestClient(build_http_app(settings), base_url=BASE) as test_client:
yield test_client
def auth(token: str = BEARER_TOKEN) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# Health and index
def test_health_ok_uses_the_admin_token_and_is_cached(
client: TestClient, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(url=f"{API}/version", json={"version": "1.22"})
first = client.get("/health")
second = client.get("/health")
assert first.status_code == second.status_code == 200
assert first.json() == {
"status": "ok",
"version": __version__,
"tools": 538,
"gitea": "reachable",
}
requests = httpx_mock.get_requests()
assert len(requests) == 1 # 10 second cache
assert requests[0].headers["Authorization"] == f"token {GITEA_TOKEN}"
@pytest.mark.parametrize(
"outcome",
[
httpx.Response(500, text="gitea exploded at https://gitea.example.com " + GITEA_TOKEN),
httpx.Response(302, headers={"Location": "https://elsewhere.example/"}),
httpx.ConnectError("connection refused to https://gitea.example.com"),
httpx.ReadTimeout("slow"),
],
)
def test_health_degraded_leaks_nothing(
client: TestClient, httpx_mock: HTTPXMock, outcome: httpx.Response | Exception
) -> None:
if isinstance(outcome, Exception):
httpx_mock.add_exception(outcome)
else:
httpx_mock.add_response(
status_code=outcome.status_code, headers=outcome.headers, content=outcome.content
)
response = client.get("/health")
assert response.status_code == 503
assert response.json() == {"status": "degraded", "gitea": "unreachable"}
assert "gitea.example.com" not in response.text and GITEA_TOKEN not in response.text
def test_health_probe_without_a_token_is_anonymous(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/version", json={})
settings = http_settings(gitea_token=None)
with TestClient(build_http_app(settings), base_url=BASE) as test_client:
assert test_client.get("/health").status_code == 200
assert "Authorization" not in httpx_mock.get_requests()[0].headers
def test_health_recovers_after_the_cache_expires(
client: TestClient, httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
) -> None:
from aegis_gitea_mcp import transports
clock = [100.0]
monkeypatch.setattr(transports.time, "monotonic", lambda: clock[0])
httpx_mock.add_exception(httpx.ConnectError("down"))
httpx_mock.add_response(url=f"{API}/version", json={})
assert client.get("/health").status_code == 503
assert client.get("/health").status_code == 503 # still cached
clock[0] += 11
assert client.get("/health").status_code == 200
def test_health_and_index_need_no_token(client: TestClient, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/version", json={})
assert client.get("/").json() == {
"name": "aegis-gitea-mcp",
"version": __version__,
"mcp_endpoint": "/mcp",
}
assert client.get("/health").status_code == 200
async def test_probe_closes_only_its_own_client() -> None:
settings = http_settings()
own = GiteaProbe(settings)
await own.aclose()
assert own._client.is_closed
shared = httpx.AsyncClient()
borrowed = GiteaProbe(settings, shared)
await borrowed.aclose()
assert not shared.is_closed
await shared.aclose()
# Authentication
def test_mcp_without_token_is_401_with_resource_metadata(client: TestClient) -> None:
response = client.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert response.status_code == 401
challenge = response.headers["WWW-Authenticate"]
assert challenge.startswith("Bearer ")
assert (
'resource_metadata="http://127.0.0.1:8080/.well-known/oauth-protected-resource/mcp"'
in challenge
)
@pytest.mark.parametrize("token", ["wrong", BEARER_TOKEN + "x", ""])
def test_mcp_with_wrong_token_is_401(client: TestClient, token: str) -> None:
response = client.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth(token)})
assert response.status_code == 401
assert "resource_metadata" in response.headers["WWW-Authenticate"]
def test_mcp_initialize_with_the_right_token(client: TestClient) -> None:
response = client.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
assert response.status_code == 200
body = response.text
assert "aegis-gitea-mcp" in body
assert BEARER_TOKEN not in body
def test_protected_resource_metadata_names_the_mcp_endpoint(client: TestClient) -> None:
response = client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
assert response.json()["resource"] == "http://127.0.0.1:8080/mcp"
@asynccontextmanager
async def sdk_client(app: Any, token: str | None = BEARER_TOKEN) -> AsyncIterator[Client]:
"""The SDK client talking to ``app`` over an in-process ASGI transport."""
headers = {"Authorization": f"Bearer {token}"} if token else {}
async with (
app.router.lifespan_context(app),
httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app), headers=headers, base_url=BASE
) as http_client,
Client(streamable_http_client(f"{BASE}/mcp", http_client=http_client)) as c,
):
yield c
async def test_sdk_client_lists_and_calls_tools_as_the_admin(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock, "admin")
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
app = build_http_app(http_settings())
async with sdk_client(app) as c:
assert len((await c.list_tools()).tools) == 538
result = await c.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
assert not result.is_error
payload = json.loads(result.content[0].text) # type: ignore[union-attr]
assert payload["identity"] == {"login": "admin", "mode": "admin-token", "sudo": None}
assert payload["data"] == {"full_name": "octo/demo"}
async def test_access_token_reaches_the_identity_resolver() -> None:
seen: list[AccessToken | None] = []
class Spy:
async def resolve(self, access: AccessToken | None) -> Identity:
seen.append(access)
return Identity(login="spy", mode="admin-token", token=GITEA_TOKEN)
from aegis_gitea_mcp.server import build_server
settings = http_settings()
app = build_http_app(settings, server=build_server(settings, resolver=Spy()))
async with sdk_client(app) as c:
await c.call_tool("gitea_repo_get", {"owner": "bad owner", "repo": "x"}) # invalid
await c.call_tool(RAW_TOOL_NAME, {"method": "GET", "path": "/version"})
assert len(seen) == 1 and seen[0] is not None
assert seen[0].client_id == STATIC_CLIENT_ID
assert BEARER_TOKEN not in repr(seen[0])
# DNS rebinding and origin
def test_bad_host_header_is_rejected(httpx_mock: HTTPXMock) -> None:
with TestClient(build_http_app(http_settings()), base_url="http://evil.example:8080") as c:
response = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
assert response.status_code == 421
def test_bad_origin_is_rejected(client: TestClient) -> None:
response = client.post(
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth(), "Origin": "https://evil.example"}
)
assert response.status_code == 403
def test_public_host_is_allowed_when_configured() -> None:
settings = http_settings(public_base_url="https://mcp.example.com")
with TestClient(build_http_app(settings), base_url="https://mcp.example.com") as c:
ok = c.post(
"/mcp",
json=INITIALIZE,
headers={**MCP_HEADERS, **auth(), "Origin": "https://mcp.example.com"},
)
metadata = c.get("/.well-known/oauth-protected-resource/mcp")
challenge = c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert ok.status_code == 200
assert metadata.json()["resource"] == "https://mcp.example.com/mcp"
assert (
"https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
in (challenge.headers["WWW-Authenticate"])
)
def test_transport_security_hosts_and_origins() -> None:
settings = http_settings(
mcp_host="192.168.1.5",
public_base_url="https://mcp.example.com:8443/",
allowed_hosts=["proxy.internal:9000"],
)
security = transport_security(settings)
assert security.enable_dns_rebinding_protection is True
assert security.allowed_hosts == [
"127.0.0.1:8080",
"localhost:8080",
"[::1]:8080",
"192.168.1.5:8080",
"mcp.example.com:8443",
"mcp.example.com",
"proxy.internal:9000",
]
assert "https://mcp.example.com:8443" in security.allowed_origins
assert "https://proxy.internal:9000" in security.allowed_origins
assert "http://127.0.0.1:8080" in security.allowed_origins
def test_transport_security_wildcard_bind_adds_no_bind_host() -> None:
security = transport_security(http_settings(mcp_host=ANY_HOST))
assert security.allowed_hosts == ["127.0.0.1:8080", "localhost:8080", "[::1]:8080"]
# Rate limiting
def test_rate_limit_returns_429_on_the_mcp_path_only(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/version", json={}, is_reusable=True)
settings = http_settings(rate_limit_per_minute=2)
with TestClient(build_http_app(settings), base_url=BASE) as c:
statuses = [
c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code for _ in range(3)
]
limited = c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
health = c.get("/health")
assert statuses == [401, 401, 429]
assert limited.status_code == 429
assert limited.json()["error"]["kind"] == "rate_limited"
assert limited.headers["Retry-After"] == "60"
assert health.status_code == 200
def test_rate_limit_is_keyed_per_token_hash() -> None:
settings = http_settings(rate_limit_per_minute=1)
app = build_http_app(settings)
with TestClient(app, base_url=BASE) as c:
first = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
limiter = next(m for m in app.user_middleware if m.cls is RateLimitMiddleware).kwargs["limiter"]
assert first.status_code == 200
assert limiter._token_limiter.max_requests == 1
# The raw header is never used as a key, only its hash.
keys = list(limiter._token_limiter._events)
assert keys and all(BEARER_TOKEN not in key for key in keys)
def test_rate_limit_uses_the_settings_passed_to_the_app(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "500") # the process-wide value must not matter
app = build_http_app(http_settings(rate_limit_per_minute=1))
with TestClient(app, base_url=BASE) as c:
statuses = [
c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()}).status_code
for _ in range(2)
]
assert statuses == [200, 429]
# Auth wiring
def test_no_auth_on_loopback_works_and_warns(caplog: pytest.LogCaptureFixture) -> None:
settings = http_settings(mcp_bearer_token=None)
with caplog.at_level(logging.WARNING):
app = build_http_app(settings)
with TestClient(app, base_url=BASE) as c:
response = c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
metadata = c.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
assert metadata.status_code == 404
assert "WITHOUT authentication" in caplog.text
def test_extra_verifier_is_combined_with_the_static_bearer() -> None:
class Other:
async def verify_token(self, token: str) -> AccessToken | None:
if token == "oauth-token":
return AccessToken(token="t", client_id="oauth-client", scopes=[])
return None
app = build_http_app(http_settings(), token_verifier=Other())
with TestClient(app, base_url=BASE) as c:
static = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
oauth = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth("oauth-token")})
denied = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth("nope")})
assert (static.status_code, oauth.status_code, denied.status_code) == (200, 200, 401)
def test_only_an_extra_verifier_protects_the_endpoint() -> None:
class Other:
async def verify_token(self, token: str) -> AccessToken | None:
return None
app = build_http_app(http_settings(mcp_bearer_token=None), token_verifier=Other())
with TestClient(app, base_url=BASE) as c:
assert c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code == 401
def test_auth_provider_without_a_verifier_is_refused() -> None:
with pytest.raises(ValueError, match="token verifier"):
build_http_app(
http_settings(mcp_bearer_token=None),
auth_server_provider=object(), # type: ignore[arg-type]
)
def test_extra_routes_are_served_without_mcp_auth() -> None:
from starlette.responses import PlainTextResponse
async def callback(request: Any) -> PlainTextResponse:
return PlainTextResponse("ok")
app = build_http_app(http_settings(), extra_routes=[Route("/oauth/callback", callback)])
with TestClient(app, base_url=BASE) as c:
assert c.get("/oauth/callback").text == "ok"
def test_request_body_cap_admits_a_full_size_upload() -> None:
settings = http_settings(max_binary_bytes=5_242_880)
app = build_http_app(settings)
with TestClient(app, base_url=BASE) as c:
padding = "x" * (6 * 1024 * 1024)
body = {**INITIALIZE, "params": {**INITIALIZE["params"], "padding": padding}}
response = c.post("/mcp", json=body, headers={**MCP_HEADERS, **auth()})
assert response.status_code != 413
small = build_http_app(http_settings(max_binary_bytes=1))
with TestClient(small, base_url=BASE) as c:
rejected = c.post("/mcp", json=body, headers={**MCP_HEADERS, **auth()})
assert rejected.status_code == 413