diff --git a/src/nc3_testing_platform/core/docs.py b/src/nc3_testing_platform/core/docs.py new file mode 100644 index 0000000..225caab --- /dev/null +++ b/src/nc3_testing_platform/core/docs.py @@ -0,0 +1,98 @@ +"""The browser-facing pages: the platform favicon and the API documentation. + +FastAPI's built-in `/docs` and `/redoc` routes write +`https://fastapi.tiangolo.com/img/favicon.png` into their HTML and the +constructor exposes no way to change it, so `main` switches the built-ins off +and this module registers them again with the platform's own icon, served from +this package rather than fetched from a third party. + +Registering the pages by hand means owning what the built-ins did for free: +prefixing every URL with `root_path`, so both pages keep working when a proxy +mounts the API under a sub-path, and the Swagger OAuth2 redirect page the +"Authorize" flow hands control back to. + +Both pages still load the Swagger UI and ReDoc bundles from cdn.jsdelivr.net — +FastAPI's default. Vendoring those is a separate change with its own trade-off. + +None of these routes reach the OpenAPI document (`include_in_schema=False`): +they are the documentation surface, not part of the API contract. +""" + +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.openapi.docs import ( + get_redoc_html, + get_swagger_ui_html, + get_swagger_ui_oauth2_redirect_html, +) +from fastapi.responses import FileResponse, HTMLResponse + +# `parents[1]` is the package root: this module sits one level down, in `core`. +FAVICON_PATH = Path(__file__).resolve().parents[1] / "static" / "favicon.ico" +FAVICON_URL = "/favicon.ico" +FAVICON_MEDIA_TYPE = "image/vnd.microsoft.icon" + +DOCS_URL = "/docs" +REDOC_URL = "/redoc" +# FastAPI's own default path, kept so a Swagger OAuth2 client registered +# against the built-in route needs no new redirect URI. +SWAGGER_OAUTH2_REDIRECT_URL = "/docs/oauth2-redirect" + +# The icon changes only with a rebrand and a stale tab icon harms nobody, so a +# day of caching spares the app one request per documentation page view. +_FAVICON_CACHE_CONTROL = "public, max-age=86400" + + +def register_docs(app: FastAPI) -> None: + """Registers the favicon and the branded documentation pages on `app`. + + The app must be constructed with `docs_url=None` and `redoc_url=None`: + FastAPI registers its own routes from the constructor, so they would + otherwise be matched first and these would never be reached. + + Args: + app: The application to register the routes on. + + Raises: + ValueError: If the app publishes no OpenAPI document, leaving the + documentation pages nothing to render. + """ + openapi_url = app.openapi_url + if not openapi_url: + raise ValueError("The documentation pages need an openapi_url to render.") + + @app.get(FAVICON_URL, include_in_schema=False) + async def favicon() -> FileResponse: + """The platform icon, for the documentation pages and for browsers that ask unprompted.""" + return FileResponse( + FAVICON_PATH, + media_type=FAVICON_MEDIA_TYPE, + headers={"Cache-Control": _FAVICON_CACHE_CONTROL}, + ) + + @app.get(DOCS_URL, include_in_schema=False) + async def swagger_ui(request: Request) -> HTMLResponse: + """Swagger UI, branded, at FastAPI's default path.""" + prefix = request.scope.get("root_path", "").rstrip("/") + return get_swagger_ui_html( + openapi_url=f"{prefix}{openapi_url}", + title=f"{app.title} - Swagger UI", + oauth2_redirect_url=f"{prefix}{SWAGGER_OAUTH2_REDIRECT_URL}", + swagger_favicon_url=f"{prefix}{FAVICON_URL}", + ) + + @app.get(SWAGGER_OAUTH2_REDIRECT_URL, include_in_schema=False) + async def swagger_ui_oauth2_redirect() -> HTMLResponse: + """Hands an authorization-code response from the provider back to Swagger UI.""" + return get_swagger_ui_oauth2_redirect_html() + + @app.get(REDOC_URL, include_in_schema=False) + async def redoc(request: Request) -> HTMLResponse: + """ReDoc, branded, at FastAPI's default path.""" + prefix = request.scope.get("root_path", "").rstrip("/") + return get_redoc_html( + openapi_url=f"{prefix}{openapi_url}", + title=f"{app.title} - ReDoc", + redoc_favicon_url=f"{prefix}{FAVICON_URL}", + ) diff --git a/src/nc3_testing_platform/main.py b/src/nc3_testing_platform/main.py index 917e7c2..30dbf93 100644 --- a/src/nc3_testing_platform/main.py +++ b/src/nc3_testing_platform/main.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, FastAPI from nc3_testing_platform.core.csrf import OriginCheckMiddleware +from nc3_testing_platform.core.docs import register_docs from nc3_testing_platform.core.errors import ( configure_openapi, register_exception_handlers, @@ -43,11 +44,16 @@ version="4.0.1", summary="v4.0 backend MVP for the NC3 Testing Platform.", openapi_url="/api/v1/openapi.json", - docs_url="/docs", + # The documentation pages are registered by hand, branded with the platform + # favicon instead of FastAPI's remotely hosted one (core/docs.py). The + # built-ins would be matched first, so they are switched off here. + docs_url=None, + redoc_url=None, ) register_exception_handlers(app) configure_openapi(app) +register_docs(app) # CSRF origin validation (IDR-010): pure ASGI, so the SSE route streams # through untouched. Inert until AUTH_PUBLIC_ORIGIN is set. diff --git a/src/nc3_testing_platform/static/favicon.ico b/src/nc3_testing_platform/static/favicon.ico new file mode 100644 index 0000000..843e87b Binary files /dev/null and b/src/nc3_testing_platform/static/favicon.ico differ diff --git a/tests/test_docs_branding.py b/tests/test_docs_branding.py new file mode 100644 index 0000000..52039a1 --- /dev/null +++ b/tests/test_docs_branding.py @@ -0,0 +1,125 @@ +"""Tests the favicon route and the self-hosted, branded documentation pages. + +The point of registering `/docs` and `/redoc` by hand is the platform icon; the +cost is owning the behaviour FastAPI's built-ins provided, so these tests pin +both — the branding, and the `root_path` prefixing and OAuth2 redirect page a +handwritten registration is free to forget. +""" + +import pytest +from fastapi.testclient import TestClient + +from nc3_testing_platform.core.docs import ( + DOCS_URL, + FAVICON_MEDIA_TYPE, + FAVICON_PATH, + FAVICON_URL, + REDOC_URL, + SWAGGER_OAUTH2_REDIRECT_URL, +) +from nc3_testing_platform.main import app + +# The default FastAPI ships in the documentation HTML. Nothing the platform +# serves may reach out to it: it is a third-party request from an authenticated +# origin, and an outage there would break the page's icon. +_FASTAPI_HOST = "fastapi.tiangolo.com" + +# The `root_path` a reverse proxy that mounts the API under a sub-path sets. +_MOUNT_PREFIX = "/testing-platform" + +_ICO_MAGIC = b"\x00\x00\x01\x00" + + +@pytest.fixture(scope="module") +def client() -> TestClient: + """The platform app, served from the origin root.""" + return TestClient(app) + + +@pytest.fixture(scope="module") +def mounted_client() -> TestClient: + """The platform app as a proxy mounting it under a sub-path presents it.""" + return TestClient(app, root_path=_MOUNT_PREFIX) + + +def test_favicon_is_the_committed_icon(client: TestClient) -> None: + """The route serves the icon shipped in the package, declared as an ICO.""" + response = client.get(FAVICON_URL) + + assert response.status_code == 200 + assert response.headers["content-type"] == FAVICON_MEDIA_TYPE + assert response.content == FAVICON_PATH.read_bytes() + + +def test_committed_icon_is_a_real_ico() -> None: + """The asset is an ICO container, not a PNG renamed. + + A renamed PNG is served happily and then ignored by the browsers that read + the declared type rather than sniffing. + """ + assert FAVICON_PATH.read_bytes().startswith(_ICO_MAGIC) + + +def test_favicon_is_cacheable(client: TestClient) -> None: + """The icon carries a cache lifetime, so it is not re-fetched per page view.""" + response = client.get(FAVICON_URL) + + assert "max-age=" in response.headers["cache-control"] + + +@pytest.mark.parametrize("url", [DOCS_URL, REDOC_URL]) +def test_documentation_page_uses_the_platform_favicon( + client: TestClient, url: str +) -> None: + """Both pages point at the local icon and at no external one.""" + response = client.get(url) + + assert response.status_code == 200 + assert f'href="{FAVICON_URL}"' in response.text + assert _FASTAPI_HOST not in response.text + + +@pytest.mark.parametrize("url", [DOCS_URL, REDOC_URL]) +def test_documentation_page_survives_being_mounted_under_a_prefix( + mounted_client: TestClient, url: str +) -> None: + """Behind a proxy, both the icon and the document are addressed through `root_path`. + + Unprefixed, the page would ask the proxy's own root for them and render + without an icon and without a specification. + """ + response = mounted_client.get(url) + + assert response.status_code == 200 + assert f'href="{_MOUNT_PREFIX}{FAVICON_URL}"' in response.text + assert f"{_MOUNT_PREFIX}{app.openapi_url}" in response.text + + +def test_swagger_oauth2_redirect_page_is_registered(client: TestClient) -> None: + """The page Swagger UI's "Authorize" flow returns through still answers. + + FastAPI registers it only alongside its own `/docs`; switching that off + drops it unless it is registered by hand, and the failure surfaces only + mid-login. + """ + response = client.get(SWAGGER_OAUTH2_REDIRECT_URL) + + assert response.status_code == 200 + assert "oauth2" in response.text + + +def test_swagger_ui_declares_the_redirect_page_it_registers( + client: TestClient, +) -> None: + """The URL the page hands to Swagger UI is the one that is served.""" + response = client.get(DOCS_URL) + + assert f"'{SWAGGER_OAUTH2_REDIRECT_URL}'" in response.text + + +def test_documentation_routes_stay_out_of_the_contract() -> None: + """No documentation route reaches the OpenAPI document.""" + paths = app.openapi()["paths"] + + for url in (FAVICON_URL, DOCS_URL, REDOC_URL, SWAGGER_OAUTH2_REDIRECT_URL): + assert url not in paths