Low-level SDK server built from the catalog plus gitea_api_request, with TOOLSETS/READ_ONLY/DISABLED_TOOLS filters, schema validation without value echo, one audit event per call, static bearer verifier, identity resolver hook, /health, DNS-rebinding protection, rate limiting and fail-closed startup checks.
451 lines
16 KiB
Python
451 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,
|
|
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, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# The limiter reads the process-wide settings.
|
|
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "2")
|
|
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(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "1")
|
|
settings = http_settings(rate_limit_per_minute=1)
|
|
with TestClient(build_http_app(settings), base_url=BASE) as c:
|
|
first = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
|
|
|
|
assert first.status_code == 200
|
|
from aegis_gitea_mcp.rate_limit import get_rate_limiter
|
|
|
|
limiter = get_rate_limiter()
|
|
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)
|
|
|
|
|
|
# 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
|