Skip to content
Merged
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
48 changes: 48 additions & 0 deletions backend/tests/nginx_helpers.py
Original file line number Diff line number Diff line change
@@ -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)]
36 changes: 3 additions & 33 deletions backend/tests/test_nginx_rewrite_ordering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
44 changes: 44 additions & 0 deletions backend/tests/test_nginx_static_cache.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 5 additions & 1 deletion data-explorer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 13 additions & 3 deletions deploy/nginx/snippets/spa.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
}
43 changes: 43 additions & 0 deletions deploy/staging-smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading