thermograph/frontend/tests/unit/test_rendering.py
Emi Griffith 72e141afd3 Emit https origin for the public host in canonical/og/sitemap URLs
The SSR frontend's _origin() (canonical, og:url, og:image, robots, sitemap
<loc>) and the backend content API's _origin() (the jsonld url folded into
each payload) both trusted x-forwarded-proto / request.url.scheme. Behind
Caddy — which terminates TLS and reverse-proxies plain HTTP — those read
"http", so on the HTTPS-only public site every absolute URL emitted http://,
which 308-redirects to https://: canonicals self-conflict and the sitemap
lists redirecting URLs.

Take the scheme from the configured public origin (THERMOGRAPH_BASE_URL, set
per-host in the deploy env) when the request arrives on that host, so prod and
beta emit https. localhost and LAN dev never match the public host and keep the
observed scheme, so plain-HTTP development is unchanged; the frontend keeps its
X-Forwarded-Host precedence for the proxy-fallback path.

Tests assert canonical/og:url/og:image, robots Sitemap and every sitemap <loc>
are https for the public host (and via X-Forwarded-Host), that a LAN host stays
http, and that the backend payload's jsonld url is likewise https.
2026-07-24 16:04:42 -07:00

139 lines
5.6 KiB
Python

"""Unit tier: the SSR rendering layer (content.py + format.py) fed committed backend
fixtures via the hermetic `client` fixture (tests/conftest.py). Asserts the rendering
*contract* — pages render and carry the expected structure — not exact climate numbers
(those are the backend's responsibility). Ported from the old backend-coupled
test_content.py; refresh the fixtures with scripts/capture-fixtures.sh.
"""
import re
import pytest
B = "/thermograph" # matches THERMOGRAPH_BASE in tests/conftest.py
SLUG = "london-england-gb"
MONTH = "july"
def test_robots_txt(client):
r = client.get(f"{B}/robots.txt")
assert r.status_code == 200
assert "Sitemap:" in r.text and "/sitemap.xml" in r.text
assert f"Disallow: {B}/api/" in r.text
def test_sitemap_lists_city_urls(client):
r = client.get(f"{B}/sitemap.xml")
assert r.status_code == 200
assert "<urlset" in r.text
assert f"/climate/{SLUG}</loc>" in r.text
assert f"/climate/{SLUG}/{MONTH}</loc>" in r.text
assert f"/climate/{SLUG}/records</loc>" in r.text
# The public site is HTTPS-only behind Caddy, which terminates TLS and proxies
# plain HTTP -- so x-forwarded-proto / request.url.scheme read "http". A request
# arriving on the configured public host (THERMOGRAPH_BASE_URL defaults to
# https://thermograph.org in the test env) must still emit https:// in every
# frontend-built absolute URL, or canonicals self-conflict and the sitemap lists
# redirecting URLs.
_PUBLIC = {"host": "thermograph.org", "x-forwarded-proto": "http"}
def test_public_host_canonical_and_og_are_https(client):
b = client.get(f"{B}/climate/{SLUG}", headers=_PUBLIC).text
assert f'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
assert f'<meta property="og:url" content="https://thermograph.org{B}/climate/{SLUG}"' in b
assert 'property="og:image" content="https://thermograph.org' in b
def test_public_host_sitemap_locs_are_https(client):
body = client.get(f"{B}/sitemap.xml", headers=_PUBLIC).text
assert f"<loc>https://thermograph.org{B}/climate/{SLUG}</loc>" in body
assert "<loc>http://thermograph.org" not in body
def test_public_host_robots_sitemap_is_https(client):
body = client.get(f"{B}/robots.txt", headers=_PUBLIC).text
assert f"Sitemap: https://thermograph.org{B}/sitemap.xml" in body
def test_non_public_host_keeps_forwarded_scheme(client):
# A LAN/dev host never matches the configured public host, so the observed
# (plain-http) scheme is preserved -- http development is unaffected.
b = client.get(f"{B}/climate/{SLUG}",
headers={"host": "192.168.1.10:8000", "x-forwarded-proto": "http"}).text
assert f'<link rel="canonical" href="http://192.168.1.10:8000{B}/climate/{SLUG}"' in b
def test_proxied_forwarded_host_still_https(client):
# Reached via backend's proxy fallback: Host is the internal hop, the real
# browser host arrives in X-Forwarded-Host -- which still resolves to the
# public host and must go https.
b = client.get(f"{B}/climate/{SLUG}",
headers={"host": "frontend:8080", "x-forwarded-host": "thermograph.org",
"x-forwarded-proto": "http"}).text
assert f'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
def test_city_page_renders_structure(client):
r = client.get(f"{B}/climate/{SLUG}")
assert r.status_code == 200
b = r.text
assert "London" in b and "climate" in b.lower()
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
assert '"@type":"Dataset"' in b # JSON-LD dataset block
assert "average temperatures by month" in b.lower()
assert re.search(r"-?\d+°C</span>", b) # GB city -> Celsius
@pytest.mark.parametrize("path", ["", f"/{MONTH}", "/records"])
def test_city_subpages_render(client, path):
r = client.get(f"{B}/climate/{SLUG}{path}")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "__ORIGIN__" not in r.text # origin placeholder filled in
def test_homepage_renders(client):
r = client.get(f"{B}/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_hub_renders(client):
r = client.get(f"{B}/climate")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_unknown_city_is_404(client):
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
def test_unknown_month_is_404(client):
assert client.get(f"{B}/climate/{SLUG}/nonemonth").status_code == 404
# --- backend-down resilience (migrated from main's hardening of the old
# tests/test_content.py, which the unit-tier restructure deleted) ------------
def _unreachable(*_a, **_k):
import httpx
req = httpx.Request("GET", "http://backend.invalid/x")
raise httpx.ConnectError("connection refused", request=req)
@pytest.mark.parametrize("path, attr", [
(f"/climate/{SLUG}", "city"),
(f"/climate/{SLUG}/july", "city_month"),
(f"/climate/{SLUG}/records", "city_records"),
("/climate", "hub"),
("/", "home"),
("/sitemap.xml", "sitemap"),
])
def test_backend_unreachable_maps_to_503(client, monkeypatch, path, attr):
"""A backend that's down/timing out (httpx.ConnectError, TimeoutException,
...) never raises httpx.HTTPStatusError -- it doesn't even get a response
-- so it must be caught separately from the 404/503 status-code passthrough
or it escapes as a raw framework 500 instead of a clean 503."""
import api_client
monkeypatch.setattr(api_client, attr, _unreachable)
assert client.get(f"{B}{path}").status_code == 503