diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..6683da4 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from gateway_framework.app import create_app +from gateway_framework.config import load_gateway_config + + +@pytest.mark.anyio +async def test_management_endpoints_and_external_openapi(tmp_path) -> None: + external_openapi = tmp_path / "external-openapi.json" + external_openapi.write_text( + json.dumps({"openapi": "3.1.0", "info": {"title": "Upstream", "version": "1.0.0"}}), + encoding="utf-8", + ) + + config_file = tmp_path / "gateway.yaml" + config_file.write_text( + f""" +settings: + title: Test Gateway + version: 9.9.9 + description: test gateway + external_openapi_file: {external_openapi} +upstreams: + demo: + base_url: https://example.com/ +routes: + - path: /api/v1/demo + methods: [GET] + upstream: demo + upstream_path: /api/v1/demo +""".strip(), + encoding="utf-8", + ) + + app = create_app(str(config_file)) + app.state.gateway_config = load_gateway_config(config_file) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + health = await client.get("/healthz") + ready = await client.get("/readyz") + routes = await client.get("/admin/routes") + external = await client.get("/openapi/external.json") + + assert health.status_code == 200 + assert health.json() == {"status": "ok"} + assert ready.status_code == 200 + assert ready.json() == {"status": "ready"} + assert routes.status_code == 200 + assert routes.json()[0]["path"] == "/api/v1/demo" + assert external.status_code == 200 + assert external.json()["info"]["title"] == "Upstream" + + +@pytest.mark.anyio +async def test_external_openapi_not_configured(tmp_path) -> None: + config_file = tmp_path / "gateway.yaml" + config_file.write_text( + """ +upstreams: + demo: + base_url: https://example.com/ +routes: + - path: /api/v1/demo + methods: [GET] + upstream: demo +""".strip(), + encoding="utf-8", + ) + + app = create_app(str(config_file)) + app.state.gateway_config = load_gateway_config(config_file) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + response = await client.get("/openapi/external.json") + + assert response.status_code == 404 + assert response.json()["error"] == "external_openapi_not_configured" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..295308e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import pytest + +from gateway_framework.config import load_gateway_config + + +def test_load_gateway_config_success(tmp_path) -> None: + config_file = tmp_path / "gateway.yaml" + config_file.write_text( + """ +settings: + title: Test Gateway +upstreams: + demo: + base_url: https://example.com/ +routes: + - path: /api/v1/demo + methods: [GET] + upstream: demo +""".strip(), + encoding="utf-8", + ) + + config = load_gateway_config(config_file) + + assert config.settings.title == "Test Gateway" + assert "demo" in config.upstreams + assert config.routes[0].path == "/api/v1/demo" + + +def test_load_gateway_config_unknown_upstream(tmp_path) -> None: + config_file = tmp_path / "gateway.yaml" + config_file.write_text( + """ +upstreams: + known: + base_url: https://example.com/ +routes: + - path: /api/v1/demo + methods: [GET] + upstream: unknown +""".strip(), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="unknown upstreams"): + load_gateway_config(config_file) + + +def test_load_gateway_config_invalid_route_path(tmp_path) -> None: + config_file = tmp_path / "gateway.yaml" + config_file.write_text( + """ +upstreams: + demo: + base_url: https://example.com/ +routes: + - path: api/v1/demo + methods: [GET] + upstream: demo +""".strip(), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="route path must start"): + load_gateway_config(config_file) diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..7bbc56c --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import httpx +import pytest +from starlette.requests import Request + +from gateway_framework.config import GatewayConfig, RouteConfig, UpstreamConfig +from gateway_framework.proxy import build_target_path, proxy_request + + +def _make_request(path: str, query: str = "", headers: list[tuple[str, str]] | None = None) -> Request: + encoded_headers = [ + (key.lower().encode("utf-8"), value.encode("utf-8")) for key, value in (headers or []) + ] + scope = { + "type": "http", + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "query_string": query.encode("utf-8"), + "headers": encoded_headers, + "client": ("127.0.0.1", 51234), + "server": ("gateway.local", 8000), + } + + async def receive() -> dict[str, object]: + return {"type": "http.request", "body": b"", "more_body": False} + + return Request(scope, receive) + + +def test_build_target_path_uses_upstream_path_override() -> None: + route = RouteConfig(path="/api/v1/bus", methods=["GET"], upstream="demo", upstream_path="/v2/bus") + request = _make_request("/api/v1/bus") + + assert build_target_path(request, route) == "/v2/bus" + + +def test_build_target_path_applies_strip_prefix() -> None: + route = RouteConfig(path="/api/v1/bus", methods=["GET"], upstream="demo", strip_prefix="/api") + request = _make_request("/api/v1/bus") + + assert build_target_path(request, route) == "/v1/bus" + + +@pytest.mark.anyio +async def test_proxy_request_forwards_to_upstream() -> None: + seen: dict[str, object] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen["url"] = str(req.url) + seen["x_forwarded_proto"] = req.headers.get("x-forwarded-proto") + seen["x_forwarded_host"] = req.headers.get("x-forwarded-host") + seen["connection"] = req.headers.get("connection") + return httpx.Response( + status_code=200, + content=b'{"ok":true}', + headers={"content-type": "application/json", "connection": "close"}, + ) + + config = GatewayConfig( + upstreams={"demo": UpstreamConfig(base_url="https://example.com/", timeout_seconds=5)}, + routes=[], + ) + route = RouteConfig(path="/api/v1/bus", methods=["GET"], upstream="demo") + request = _make_request("/api/v1/bus", query="a=1", headers=[("Connection", "keep-alive")]) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + response = await proxy_request(request=request, client=client, config=config, route=route) + + assert response.status_code == 200 + assert response.body == b'{"ok":true}' + assert response.headers.get("connection") is None + assert seen["url"] == "https://example.com/api/v1/bus?a=1" + assert seen["x_forwarded_proto"] == "http" + assert seen["x_forwarded_host"] == "gateway.local" + + +@pytest.mark.anyio +async def test_proxy_request_returns_502_when_upstream_unreachable() -> None: + def handler(req: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection failed", request=req) + + config = GatewayConfig( + upstreams={"demo": UpstreamConfig(base_url="https://example.com/")}, + routes=[], + ) + route = RouteConfig(path="/api/v1/bus", methods=["GET"], upstream="demo") + request = _make_request("/api/v1/bus") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + response = await proxy_request(request=request, client=client, config=config, route=route) + + assert response.status_code == 502 + assert b"upstream_unreachable" in response.body