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.
139 lines
5.6 KiB
Python
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
|