import json from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager import httpx import pytest from fastapi import FastAPI from app.gateway import MAX_REQUEST_BODY_BYTES, create_gateway_app type Handler = Callable[[httpx.Request], httpx.Response] @asynccontextmanager async def _gateway_client(handler: Handler) -> AsyncIterator[httpx.AsyncClient]: gateway = create_gateway_app(httpx.MockTransport(handler)) async with gateway.router.lifespan_context(gateway): transport = httpx.ASGITransport(app=gateway) async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client: yield client @pytest.mark.asyncio async def test_gateway_live_and_docs_are_disabled() -> None: gateway = create_gateway_app(httpx.MockTransport(lambda _: httpx.Response(500))) try: assert gateway.docs_url is None assert gateway.redoc_url is None assert gateway.openapi_url is None transport = httpx.ASGITransport(app=gateway) async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client: response = await client.get("/gateway/live") assert response.status_code == 200 assert response.json() == {"status": "ok"} finally: await _close_gateway(gateway) async def _close_gateway(gateway: FastAPI) -> None: async with gateway.router.lifespan_context(gateway): pass @pytest.mark.asyncio async def test_successful_get_preserves_path_query_and_allowed_header() -> None: seen: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(request) return httpx.Response(200, json={"result": "ok"}, headers={"x-request-id": "req-1"}) async with _gateway_client(handler) as client: response = await client.get( "/api/v1/search?q=granite%20deposit&limit=5", headers={"x-request-id": "req-1", "x-forwarded-host": "evil.invalid"}, ) assert response.status_code == 200 assert response.json() == {"result": "ok"} assert response.headers["x-request-id"] == "req-1" assert seen[0].url == "http://api:8000/api/v1/search?q=granite%20deposit&limit=5" assert seen[0].headers["host"] == "api:8000" assert "x-forwarded-host" not in seen[0].headers @pytest.mark.asyncio async def test_post_json_body_and_content_type_are_forwarded() -> None: seen: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(request) return httpx.Response(201, json={"accepted": True}) async with _gateway_client(handler) as client: response = await client.post("/api/v1/query", json={"question": "Where is gold?"}) assert response.status_code == 201 assert response.json() == {"accepted": True} assert seen[0].method == "POST" assert seen[0].headers["content-type"] == "application/json" assert json.loads(seen[0].content) == {"question": "Where is gold?"} @pytest.mark.asyncio async def test_upstream_origin_is_fixed_against_host_and_path_ssrf_attempts() -> None: seen: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(request) return httpx.Response(200) async with _gateway_client(handler) as client: response = await client.get( "//evil.invalid/http://metadata.internal/latest?target=http://attacker.invalid", headers={"host": "attacker.invalid", "forwarded": "host=attacker.invalid"}, ) assert response.status_code == 200 assert seen[0].url.scheme == "http" assert seen[0].url.host == "api" assert seen[0].url.port == 8000 assert seen[0].headers["host"] == "api:8000" assert "forwarded" not in seen[0].headers @pytest.mark.asyncio async def test_request_larger_than_one_mib_is_rejected_before_upstream() -> None: upstream_calls = 0 def handler(_: httpx.Request) -> httpx.Response: nonlocal upstream_calls upstream_calls += 1 return httpx.Response(200) async with _gateway_client(handler) as client: response = await client.post("/upload", content=b"x" * (MAX_REQUEST_BODY_BYTES + 1)) assert response.status_code == 413 assert response.json() == {"detail": "request body too large"} assert upstream_calls == 0 @pytest.mark.asyncio async def test_upstream_transport_error_returns_redacted_502() -> None: def handler(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError( "api:8000 refused connection with internal-token-value", request=request, ) async with _gateway_client(handler) as client: response = await client.get("/api/v1/health/ready") assert response.status_code == 502 assert response.json() == {"detail": "upstream unavailable"} assert "api:8000" not in response.text assert "internal-token-value" not in response.text @pytest.mark.asyncio async def test_sensitive_upstream_response_headers_are_not_forwarded() -> None: def handler(_: httpx.Request) -> httpx.Response: return httpx.Response( 200, content=b"ok", headers={ "content-type": "text/plain", "cache-control": "no-store", "set-cookie": "session=private", "server": "internal-api", "x-internal-token": "private", }, ) async with _gateway_client(handler) as client: response = await client.get("/status") assert response.headers["content-type"] == "text/plain" assert response.headers["cache-control"] == "no-store" assert "set-cookie" not in response.headers assert "server" not in response.headers assert "x-internal-token" not in response.headers