Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/nc3_testing_platform/core/docs.py
Original file line number Diff line number Diff line change
@@ -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}",
)
8 changes: 7 additions & 1 deletion src/nc3_testing_platform/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Binary file added src/nc3_testing_platform/static/favicon.ico
Binary file not shown.
125 changes: 125 additions & 0 deletions tests/test_docs_branding.py
Original file line number Diff line number Diff line change
@@ -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