diff --git a/backend/tests/nginx_helpers.py b/backend/tests/nginx_helpers.py new file mode 100644 index 00000000..26c95b2f --- /dev/null +++ b/backend/tests/nginx_helpers.py @@ -0,0 +1,48 @@ +"""Render deploy/nginx/nginx.conf.template the way start.sh does, for tests +that assert on the result rather than on the template. + +The include markers and flag blocks only exist before expansion, so anything +about location order or a directive reaching a location through a snippet is +only observable on the rendered text. +""" + +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +_RENDERER = REPO / "deploy" / "render-nginx-config.py" +_TEMPLATE = REPO / "deploy" / "nginx" / "nginx.conf.template" + +# Any deployed environment renders the same directives; only names differ. +VALUES = { + "HOST_MAIN": "towers.example.com", + "HOST_API": "api.example.com", + "HOST_MAP": "map.example.com", + "HOST_DASH": "dash.example.com", + "HOST_ADMIN": "admin.example.com", + "HOST_DATA": "data.example.com", + "HOST_TESTMAP": "testmap.example.com", + "HOST_LEGACY_REDIRECT": "tower-finder.example.com", + "CSP_CONNECT_SRC": "https://api.example.com", +} + + +def render() -> str: + spec = importlib.util.spec_from_file_location("render_nginx_config", _RENDERER) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + flags = module.resolve_flags(VALUES) + text = module.expand_includes(_TEMPLATE, _TEMPLATE.parent, flags) + return module.substitute(text, VALUES) + + +def locations(text: str) -> list[tuple[str, str]]: + """(header, body) for every `location ... { ... }`, innermost braces only. + + The template nests no locations, so a non-greedy match to the first closing + brace is the whole body. + """ + return [(m.group(1), m.group(2)) for m in re.finditer(r"(location[^\n{]*)\{([^{}]*)\}", text)] diff --git a/backend/tests/test_nginx_rewrite_ordering.py b/backend/tests/test_nginx_rewrite_ordering.py index 2967ca77..350c8e5a 100644 --- a/backend/tests/test_nginx_rewrite_ordering.py +++ b/backend/tests/test_nginx_rewrite_ordering.py @@ -15,47 +15,17 @@ from __future__ import annotations -import importlib.util import re -from pathlib import Path import pytest -_REPO = Path(__file__).resolve().parents[2] -_RENDERER = _REPO / "deploy" / "render-nginx-config.py" -_TEMPLATE = _REPO / "deploy" / "nginx" / "nginx.conf.template" - -# Any deployed environment renders the same directives; only names differ. -_VALUES = { - "HOST_MAIN": "towers.example.com", - "HOST_API": "api.example.com", - "HOST_MAP": "map.example.com", - "HOST_DASH": "dash.example.com", - "HOST_ADMIN": "admin.example.com", - "HOST_DATA": "data.example.com", - "HOST_TESTMAP": "testmap.example.com", - "HOST_LEGACY_REDIRECT": "tower-finder.example.com", - "CSP_CONNECT_SRC": "https://api.example.com", -} +from tests.nginx_helpers import locations as _locations +from tests.nginx_helpers import render @pytest.fixture(scope="module") def rendered() -> str: - spec = importlib.util.spec_from_file_location("render_nginx_config", _RENDERER) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - flags = module.resolve_flags(_VALUES) - text = module.expand_includes(_TEMPLATE, _TEMPLATE.parent, flags) - return module.substitute(text, _VALUES) - - -def _locations(text: str) -> list[tuple[str, str]]: - """(header, body) for every `location ... { ... }`, innermost braces only. - - The template nests no locations, so a non-greedy match to the first closing - brace is the whole body. - """ - return [(m.group(1), m.group(2)) for m in re.finditer(r"(location[^\n{]*)\{([^{}]*)\}", text)] + return render() def test_the_template_still_has_locations_to_check(rendered): diff --git a/backend/tests/test_nginx_static_cache.py b/backend/tests/test_nginx_static_cache.py new file mode 100644 index 00000000..c8686f29 --- /dev/null +++ b/backend/tests/test_nginx_static_cache.py @@ -0,0 +1,44 @@ +"""The week-long immutable cache is confined to Vite's hashed /assets/ tree. + +Cloudflare keeps a `public, immutable` response for the whole `expires` +window, so the policy is only safe on a name that carries a content hash. +Every other static file is served `no-cache` and revalidated, or a deploy that +changes it stays invisible at the edge until the window ends, under an +index.html that is never cached and already expects the new file. + +Asserted on the RENDERED config: the locations arrive through spa.conf, and +nginx takes the first regex location that matches, so the order the include +lays them out in is the behaviour. +""" + +from __future__ import annotations + +import pytest + +from tests.nginx_helpers import locations, render + +# The extension list both static-file locations share. +_STATIC = r"\.(js|css|" + + +@pytest.fixture(scope="module") +def rendered() -> str: + return render() + + +def test_immutable_is_confined_to_hashed_assets(rendered): + for header, body in locations(rendered): + if "immutable" in body: + assert "^/assets/" in header, f"{header.strip()} is immutable but its names carry no hash" + + +def test_every_other_static_file_is_revalidated(rendered): + statics = [(h, b) for h, b in locations(rendered) if _STATIC in h] + assert statics, "no static-file locations rendered" + # In pairs, one per SPA vhost: the /assets/ location first, then the + # catch-all. A catch-all that came first would take /assets/ too and drop + # the week. + assert len(statics) % 2 == 0, [h.strip() for h, _ in statics] + for assets, rest in zip(statics[0::2], statics[1::2], strict=True): + assert "^/assets/" in assets[0] and "immutable" in assets[1], assets[0].strip() + assert "^/assets/" not in rest[0] and "no-cache" in rest[1], rest[0].strip() diff --git a/data-explorer/README.md b/data-explorer/README.md index 602541a9..e7adae05 100644 --- a/data-explorer/README.md +++ b/data-explorer/README.md @@ -16,7 +16,11 @@ data-explorer/ ``` Edit the files and redeploy; there is nothing to compile. `Dockerfile` copies the -directory verbatim, so a change here ships with any image build. +directory verbatim, so a change here ships with any image build. Because none of +these names carries a content hash, nginx serves them `Cache-Control: no-cache` +(`deploy/nginx/snippets/spa.conf`): browsers revalidate and get a 304 when +nothing changed, and the edge never keeps a copy past a deploy. Only Vite's +hashed `/assets/` trees get the week-long immutable policy. ## Why the libraries are vendored diff --git a/deploy/nginx/snippets/spa.conf b/deploy/nginx/snippets/spa.conf index 70970663..f83e3d6a 100644 --- a/deploy/nginx/snippets/spa.conf +++ b/deploy/nginx/snippets/spa.conf @@ -2,7 +2,7 @@ # frontends live in different directories (/app/frontend/dist for the tower # finder and live map, /app/dashboard/dist for the user dashboard). # -# NOTE: the two locations below declare their own add_header, which by nginx's +# NOTE: the locations below declare their own add_header, which by nginx's # rules means they do NOT inherit the server-level security headers. That is # pre-existing production behaviour and is preserved here deliberately — this # refactor is meant to make staging match production, not to change what @@ -16,8 +16,18 @@ location = /index.html { add_header Cache-Control "no-store, no-cache"; } -# Cache static assets -location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { +# Only Vite's output carries a content hash in its name, and all of it lands +# under /assets/, so that is the only tree the edge may keep for a week: on a +# file whose name survives a deploy (the dashboard's theme-boot.js, everything +# the data explorer ships) the same policy leaves Cloudflare serving the old +# copy under the new index.html until the week is up. +location ~* ^/assets/.+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 7d; add_header Cache-Control "public, immutable"; } + +# Every other static file is revalidated on each request. nginx answers a +# conditional request with 304, so a browser still pays only for what changed. +location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + add_header Cache-Control "no-cache"; +} diff --git a/deploy/staging-smoke-test.sh b/deploy/staging-smoke-test.sh index ebc32397..495322db 100644 --- a/deploy/staging-smoke-test.sh +++ b/deploy/staging-smoke-test.sh @@ -169,6 +169,28 @@ check_header() { fi } +# As check_header, but the header must also carry the given value. +check_header_value() { + local name="$1" url="$2" header="$3" value="$4" + printf " %-40s " "$name" + HEADERS=$($CURL -o /dev/null -D - "$url" 2>/dev/null) || { echo "FAIL (connection error)"; FAIL=$((FAIL+1)); return; } + + if echo "$HEADERS" | tr 'A-Z' 'a-z' | grep "^${header}:" | grep -qF "$value"; then + echo "OK" + PASS=$((PASS+1)) + else + echo "FAIL (${header} does not say ${value})" + FAIL=$((FAIL+1)) + fi +} + +check_header_value_if_dns() { + local name="$1" url="$2" header="$3" value="$4" host + host="${url#https://}"; host="${host%%/*}" + if handle_unresolvable "$host" "$name"; then return; fi + check_header_value "$name" "$url" "$header" "$value" +} + check_rate_limit() { local name="$1" url="$2" tries="$3" printf " %-40s " "$name" @@ -289,6 +311,27 @@ check_header "CSP on dashboard vhost" "${DASH_URL}/api/health" "content-se check_header_if_dns "CSP on data explorer vhost" "${DATA_URL}/api/health" "content-security-policy" check_header "CSP on frontend vhost" "${MAP_URL}/api/health" "content-security-policy" check_header "HSTS on api subdomain" "${API_URL}/api/health" "strict-transport-security" +# Edge caching follows what nginx says, and Cloudflare keeps a `public, +# immutable` response for the whole `expires` window, so that policy is safe +# only on a name that carries a content hash (Vite's /assets/). A file whose +# name survives a deploy must be revalidated instead, or the edge serves last +# week's copy under the new index.html, which every other check here still +# reads as a healthy 200. +# +# Probed with a never-seen query string: the header under test is nginx's, and +# a copy the edge already holds answers with the headers it was stored with. +# The query string is part of the cache key, so a fresh one is a guaranteed +# miss, and nginx matches its locations on the path alone. +BUST="smoke=$(date +%s)$RANDOM" +check_header_value "dash theme-boot.js revalidates" "${DASH_URL}/theme-boot.js?${BUST}" "cache-control" "no-cache" +check_header_value_if_dns "data app.css revalidates" "${DATA_URL}/app.css?${BUST}" "cache-control" "no-cache" +MAP_ASSET=$($CURL "${MAP_URL}/" 2>/dev/null | grep -o '/assets/index-[^"]*\.js' | head -n1 || true) +if [ -n "$MAP_ASSET" ]; then + check_header_value "hashed /assets/ file is immutable" "${MAP_URL}${MAP_ASSET}?${BUST}" "cache-control" "immutable" +else + printf " %-40s FAIL (no /assets/index-*.js referenced by the page)\n" "hashed /assets/ file is immutable" + FAIL=$((FAIL+1)) +fi # Two zones, two checks. The credential surface carries the tight limit that # actually resists brute force; the session reads a page load spends on every # visit carry a looser one. Testing only /api/auth/me would leave the