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.
458 lines
16 KiB
Python
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
|