perf(ui): cache busting — HTML no-cache + versioned asset URLs (?v=) with immutable 1y asset caching

Phase 33 (story: .agent/user_stories/cache-busting.md).

- app/core/caching.py: asset_version() — git short SHA (a commit is a
  deploy), stable content-hash fallback for non-git checkouts, "dev"
  for a missing static dir; computed once per process. CachingMiddleware
  — the five HTML pages revalidate (no-cache) with ?v=<token> asset refs
  rewritten in flight; /assets/* is public, max-age=31536000, immutable;
  everything else (all /api/*, the SSE chat stream in particular) passes
  through byte-identical.
- tests/e2e/test_cache_busting.py: fresh-Chromium wire assertions —
  document no-cache, versioned CSS/JS request URLs sharing one token,
  immutable asset headers, /api/health baseline headers, SSE chat to
  done (mock LLM).
- README 'Caching / deploys' section + story file.

Also fixed two prod-image defects surfaced by this phase's podman smoke
(the full app would not boot):
- Containerfile: ship the scripts/ package — app/api/sync.py (phase 32)
  imports scripts.git_sync / scripts.import_docs at module level, so the
  container crashed on boot (ModuleNotFoundError: No module named
  'scripts').
- compose.yaml: pass BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET through to
  the app service (:- defaults keep 'podman compose up -d db' working;
  the app's own fail-loud gate still names missing admin auth).

Smoke: podman compose --profile prod up -d on a fresh image + a fresh
Chromium profile — /, /sources.html and /login.html all served
Cache-Control: no-cache; all 8 asset requests versioned with one shared
token (content-hash fallback inside the image — no .git there);
/assets/* immutable for a year.
This commit is contained in:
2026-08-25 22:42:10 -04:00
parent 52136fe307
commit 8fabb7efda
12 changed files with 988 additions and 0 deletions
+66
View File
@@ -71,6 +71,72 @@ def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> Non
assert 'href="https://' not in r.text
# Phase 33 (cache busting): the five HTML pages revalidate (no-cache) with
# ?v=<token> asset refs; /assets/* is immutable for a year; /api/* is
# untouched. The token itself is unit-tested in tests/unit/test_caching.py.
def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
"""GET / — always revalidated, and the stylesheet reference carries
the process version token (non-empty, matching asset_version())."""
from app.core.caching import asset_version
token = asset_version()
assert token # non-empty in every supported environment
r = client.get("/")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert f'href="/assets/styles.css?v={token}"' in r.text
# The unversioned reference is gone from the served body.
assert 'href="/assets/styles.css">' not in r.text
@pytest.mark.parametrize(
"path",
["/sources.html", "/document.html", "/login.html", "/tuning.html"],
)
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
"""Each of the other four pages revalidates and carries at least one
versioned asset reference."""
from app.core.caching import asset_version
r = client.get(path)
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert f"?v={asset_version()}" in r.text
def test_index_html_variant_no_cache_versioned(client) -> None:
"""/index.html is the same page as / — same caching treatment."""
from app.core.caching import asset_version
r = client.get("/index.html")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert f"?v={asset_version()}" in r.text
def test_assets_served_immutable_for_a_year(client) -> None:
r = client.get("/assets/styles.css")
assert r.status_code == 200
cc = r.headers["cache-control"]
assert "public" in cc
assert "max-age=31536000" in cc
assert "immutable" in cc
# The asset body is untouched (header-only middleware).
assert client.get("/assets/app.js?v=whichever").status_code == 200
def test_api_health_gets_no_cache_control_injected(client) -> None:
"""Baseline (pre-middleware) behavior for /api/*: FastAPI's JSON
responses ship no Cache-Control header — the middleware must not
inject one."""
r = client.get("/api/health")
assert r.status_code == 200
assert "cache-control" not in r.headers
def test_styles_and_js_served(client) -> None:
assert client.get("/assets/styles.css").status_code == 200
assert client.get("/assets/app.js").status_code == 200