honcho/tests/test_openapi.py

107 lines
4.3 KiB
Python

"""OpenAPI schema policy tests.
Regression coverage for #875: the app declared a hardcoded ``servers`` list
(``https://api.honcho.dev`` + ``http://localhost:8000``), which is baked into the
schema at code-definition time and drives the "Servers" dropdown in Swagger UI.
Any self-hosted deployment not reachable at exactly ``localhost:8000`` -- a
remapped host port, a LAN hostname, a reverse proxy -- got a dropdown where no
entry matched reality, so "Try it out" sent requests to the wrong origin.
Omitting ``servers`` entirely lets Swagger UI fall back to the origin the ``/docs``
page was served from, which is correct for every deployment topology. FastAPI still
advertises ``root_path`` automatically for prefix-stripping proxies.
These tests drive ``GET /openapi.json`` -- the same endpoint the browser fetches to
populate the dropdown -- rather than calling ``app.openapi()`` directly, so they also
cover the ``root_path`` server entry, which is injected per-request by the route
handler and is invisible to ``app.openapi()``.
"""
from typing import Any
from urllib.parse import urlparse
import pytest
from fastapi.testclient import TestClient
from src.main import app
# Deployment topologies a self-hoster actually runs. `root_path` is what the app is
# told about a proxy prefix; `origin` is where the browser reaches the API.
DEPLOYMENT_TOPOLOGIES: list[tuple[str, str, str]] = [
# (test id, root_path, browser origin)
("port_remap_8070", "", "http://192.168.1.50:8070"), # the #875 repro
("lan_hostname", "", "http://honcho.lan:9000"),
(
"localhost_default",
"",
"http://localhost:8000",
), # the only case that ever worked
("proxy_prefix", "/api", "https://honcho.example.com"),
("proxy_prefix_trailing_slash", "/api/", "https://honcho.example.com"),
("proxy_root_slash", "/", "https://honcho.example.com"), # rstrip("/") -> no entry
]
def _get_schema(root_path: str, origin: str) -> dict[str, Any]:
"""Fetch /openapi.json the way a browser at `origin` would.
Deliberately not used as a context manager: serving the schema needs no
database or cache, and running the lifespan handler would drag both in.
"""
client = TestClient(app, base_url=origin, root_path=root_path)
response = client.get("/openapi.json")
assert response.status_code == 200
return response.json()
@pytest.mark.parametrize(
("root_path", "origin"),
[pytest.param(rp, origin, id=tid) for tid, rp, origin in DEPLOYMENT_TOPOLOGIES],
)
def test_openapi_advertises_no_foreign_origin(root_path: str, origin: str) -> None:
"""No server entry may name an absolute origin.
An absolute URL is a guess about where the API lives; it is wrong for every
deployment that isn't the one guessed. Relative entries (or none at all) resolve
against the origin the schema was fetched from, which is always right.
"""
servers: list[dict[str, str]] = _get_schema(root_path, origin).get("servers", [])
for server in servers:
url = server.get("url", "")
parsed = urlparse(url)
assert not parsed.scheme and not parsed.netloc, (
f"schema fetched from {origin} advertises absolute origin {url!r}; "
f"a client at {origin} would send requests there instead of to "
f"{origin} (#875)"
)
def test_openapi_omits_servers_without_root_path() -> None:
"""Plain deployment: no `servers` key, so Swagger UI uses the docs page origin."""
schema = _get_schema(root_path="", origin="http://192.168.1.50:8070")
assert "servers" not in schema
@pytest.mark.parametrize(
("root_path", "expected"),
[
pytest.param("/api", [{"url": "/api"}], id="prefix"),
pytest.param("/api/", [{"url": "/api"}], id="prefix_trailing_slash_stripped"),
pytest.param("/", None, id="root_slash_is_not_a_prefix"),
pytest.param("", None, id="no_prefix"),
],
)
def test_openapi_advertises_root_path_for_proxies(
root_path: str, expected: list[dict[str, str]] | None
) -> None:
"""Behind a prefix-stripping proxy, FastAPI advertises `root_path` on its own.
This is what replaces the hardcoded list for proxied deployments, so it must keep
working once `servers` is gone.
"""
schema = _get_schema(root_path, origin="https://honcho.example.com")
assert schema.get("servers") == expected