"""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