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
+138
View File
@@ -0,0 +1,138 @@
"""Phase 33 E2E (Playwright): cache busting — what the browser actually
receives and requests.
Story: ``.agent/user_stories/cache-busting.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_cache_busting.py -v --no-cov
The assertions are against the wire: the HTML document responses carry
``Cache-Control: no-cache``; every asset request URL the browser actually
makes carries ``?v=<token>`` (one token per process — the git short SHA of
this checkout, i.e. the deploy); the asset responses are immutable for a
year; and the API — the SSE chat stream in particular — is untouched. The
mock LLM keeps the SSE check deterministic (no live aipi).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import httpx
from playwright.sync_api import Page
REPO = Path(__file__).resolve().parents[2]
CHAT_QUESTION = "How is my Kubernetes cluster set up?"
def _expected_token() -> str:
"""The token the app process appends to its asset URLs.
Computed exactly the way the app does (``asset_version`` over the same
static dir): the git short SHA of this checkout in a git repo (a commit
is a deploy), so the browser's asset requests must carry it.
"""
from app.core.caching import asset_version
return asset_version(str(REPO / "frontend"))
def _version_token(url: str) -> str:
"""Extract the ``?v=`` token from a versioned asset URL (asserts one)."""
assert "?v=" in url, f"asset request is not versioned: {url}"
return url.rsplit("?v=", 1)[1]
def _stream_chat_frames(app_url: str, message: str) -> list[dict[str, Any]]:
"""Minimal SSE chat request (same pattern as ``test_chat_rag.py``):
POST /api/chat and collect the ``data:`` frames until the stream ends."""
frames: list[dict[str, Any]] = []
with httpx.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=60.0
) as r:
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/event-stream")
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
if frame.strip().startswith("data:"):
frames.append(
json.loads(frame.strip().removeprefix("data:").strip())
)
return frames
def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None:
"""`/`: the document revalidates (no-cache); the CSS/JS request URLs
the browser actually makes carry the process token; the asset
responses are immutable for a year; the served HTML carries no
unversioned asset references."""
token = _expected_token()
assert token, "the version token must be non-empty"
with (
page.expect_response(lambda r: "/assets/styles.css" in r.url) as css_info,
page.expect_response(lambda r: "/assets/app.js" in r.url) as js_info,
):
doc = page.goto(app_url)
# The document: always revalidated, never served from cache unchecked.
assert doc is not None
assert doc.headers["cache-control"] == "no-cache"
# CSS: versioned request URL + immutable-for-a-year response.
css = css_info.value
assert _version_token(css.url) == token
css_cc = css.headers["cache-control"]
assert "immutable" in css_cc
assert "max-age=31536000" in css_cc
# JS: the SAME token (one per process — the URL identifies the
# content, which is what makes the 1-year cache safe).
assert _version_token(js_info.value.url) == _version_token(css.url)
# The served HTML carries the versioned reference and no unversioned
# one (the "sticky" reference is gone from the page the browser sees).
html = page.content()
assert f'href="/assets/styles.css?v={token}"' in html
assert '/assets/styles.css"' not in html
def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
"""/sources.html and /login.html: each document revalidates, and both
pages' stylesheet requests carry the same process token."""
token = _expected_token()
assert token
def navigate(path: str) -> str:
with page.expect_response(
lambda r: "/assets/styles.css" in r.url
) as css_info:
doc = page.goto(f"{app_url}{path}")
assert doc is not None
assert doc.headers["cache-control"] == "no-cache"
return _version_token(css_info.value.url)
sources_token = navigate("/sources.html")
login_token = navigate("/login.html")
assert sources_token == login_token == token
def test_api_responses_unaffected(page: Page, app_url: str, db_ready: None) -> None:
"""`/api/*` passes through untouched: no injected Cache-Control on the
health endpoint, and the SSE chat stream still streams to done."""
r = page.request.get(f"{app_url}/api/health")
assert r.status == 200
# Baseline (pre-middleware) behavior: FastAPI's JSON responses ship no
# Cache-Control header — the middleware must not inject one.
assert "cache-control" not in r.headers
# The SSE contract (PLAN §4) survives the middleware: deltas, then a
# final done — the stream is neither read nor rewritten by it.
frames = _stream_chat_frames(app_url, CHAT_QUESTION)
assert frames, "the SSE stream must deliver events"
assert any(f["type"] == "delta" for f in frames), "answer must be streamed"
assert frames[-1]["type"] == "done", "the stream must complete with done"
+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
+362
View File
@@ -0,0 +1,362 @@
"""Unit tests: the frontend asset version token (phase 33, app/core/caching.py).
Covers the four token paths:
* git repo -> ``git rev-parse --short HEAD`` (stable, cached).
* fallback -> 12-hex content hash; stable for an unchanged tree, flips on
a size or mtime change once the per-process cache is cleared.
* failure -> a ``.git`` present but git broken (missing / timeout /
non-zero exit) falls back to the content hash without raising.
* empty -> a missing or empty static dir yields ``"dev"``.
Plus the asset-reference rewrite (``rewrite_asset_refs``) and the
CachingMiddleware fallback branches (task 02).
"""
from __future__ import annotations
import asyncio
import os
import re
import subprocess
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.testclient import TestClient
import app.core.caching as caching
from app.config import Settings
from app.core.caching import asset_version, rewrite_asset_refs
TOKEN = "abc123"
@pytest.fixture(autouse=True)
def _clear_token_cache() -> Iterator[None]:
# The token is lru_cached per process — every case starts from a clean
# slate and never leaks its entry into the next case.
asset_version.cache_clear()
yield
asset_version.cache_clear()
def _git(repo: Path, *args: str) -> None:
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
def _make_git_repo(base: Path) -> tuple[Path, str]:
"""A repo with a committed ``frontend/`` file; returns (frontend, short SHA)."""
repo = base / "proj"
frontend = repo / "frontend"
frontend.mkdir(parents=True)
(frontend / "styles.css").write_text("body { margin: 0 }\n")
_git(repo, "init", "-q")
_git(repo, "add", "frontend")
_git(
repo,
"-c",
"user.name=test",
"-c",
"user.email=test@example.com",
"commit",
"-q",
"-m",
"init",
)
proc = subprocess.run(
["git", "-C", str(repo), "rev-parse", "--short", "HEAD"],
check=True,
capture_output=True,
text=True,
)
return frontend, proc.stdout.strip()
def _make_plain_frontend(tmp_path: Path) -> Path:
frontend = tmp_path / "frontend"
frontend.mkdir()
(frontend / "app.js").write_text("console.log('hi')\n")
(frontend / "styles.css").write_text("body { margin: 0 }\n")
return frontend
def test_git_repo_token_matches_short_sha_and_is_cached(tmp_path) -> None:
frontend, short_sha = _make_git_repo(tmp_path)
assert asset_version(str(frontend)) == short_sha
# Second call: same value, served from the per-process cache.
assert asset_version(str(frontend)) == short_sha
assert asset_version.cache_info().hits >= 1
def test_default_static_dir_comes_from_settings(tmp_path, monkeypatch) -> None:
frontend, short_sha = _make_git_repo(tmp_path)
monkeypatch.setattr(
caching, "get_settings", lambda: Settings(static_dir=str(frontend))
)
assert asset_version() == short_sha # no argument -> settings default
def test_fallback_token_is_12_hex_and_stable_for_unchanged_tree(tmp_path) -> None:
frontend = _make_plain_frontend(tmp_path)
token = asset_version(str(frontend))
assert re.fullmatch(r"[0-9a-f]{12}", token)
# Unchanged tree: same token even after the cache entry is dropped
# (i.e. the hash itself is deterministic, not just the cache).
asset_version.cache_clear()
assert asset_version(str(frontend)) == token
def test_fallback_token_changes_on_size_change(tmp_path) -> None:
frontend = _make_plain_frontend(tmp_path)
before = asset_version(str(frontend))
(frontend / "app.js").write_text("console.log('a longer payload')\n")
asset_version.cache_clear()
assert asset_version(str(frontend)) != before
def test_fallback_token_changes_on_mtime_only(tmp_path) -> None:
frontend = _make_plain_frontend(tmp_path)
before = asset_version(str(frontend))
path = frontend / "app.js"
now = os.stat(path).st_mtime_ns
os.utime(path, ns=(now + 5_000, now + 5_000)) # mtime change, same size
asset_version.cache_clear()
assert asset_version(str(frontend)) != before
@pytest.mark.parametrize(
("error",),
[
(FileNotFoundError("git not found"),),
(subprocess.TimeoutExpired(cmd="git", timeout=5),),
(subprocess.CalledProcessError(returncode=128, cmd="git"),),
],
ids=["git-missing", "git-timeout", "git-nonzero-exit"],
)
def test_git_failure_falls_back_to_content_hash(tmp_path, monkeypatch, error: Exception) -> None:
# A ``.git`` exists, but git itself is broken -> content hash, no raise.
repo = tmp_path / "proj"
frontend = repo / "frontend"
frontend.mkdir(parents=True)
(repo / ".git").mkdir()
(frontend / "styles.css").write_text("body { margin: 0 }\n")
def _boom(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
raise error
monkeypatch.setattr(caching.subprocess, "run", _boom)
token = asset_version(str(frontend))
assert re.fullmatch(r"[0-9a-f]{12}", token)
def test_git_success_with_empty_output_falls_back(tmp_path, monkeypatch) -> None:
# git exits 0 but prints nothing (defensive guard) -> content hash.
repo = tmp_path / "proj"
frontend = repo / "frontend"
frontend.mkdir(parents=True)
(repo / ".git").mkdir()
(frontend / "styles.css").write_text("body { margin: 0 }\n")
monkeypatch.setattr(
caching.subprocess,
"run",
lambda *a, **k: subprocess.CompletedProcess(
args=["git"], returncode=0, stdout=" \n", stderr=""
),
)
token = asset_version(str(frontend))
assert re.fullmatch(r"[0-9a-f]{12}", token)
def test_missing_static_dir_is_dev(tmp_path) -> None:
assert asset_version(str(tmp_path / "does-not-exist")) == "dev"
def test_empty_static_dir_is_dev(tmp_path) -> None:
empty = tmp_path / "frontend"
empty.mkdir()
assert asset_version(str(empty)) == "dev"
# ---------------------------------------------------------------------------
# rewrite_asset_refs (task 02)
# ---------------------------------------------------------------------------
def test_rewrite_versions_href_with_leading_slash() -> None:
html = '<link rel="stylesheet" href="/assets/styles.css">'
assert rewrite_asset_refs(html, TOKEN) == (
'<link rel="stylesheet" href="/assets/styles.css?v=abc123">'
)
def test_rewrite_versions_src_without_leading_slash() -> None:
html = '<script src="assets/markdown.js"></script>'
assert rewrite_asset_refs(html, TOKEN) == (
'<script src="assets/markdown.js?v=abc123"></script>'
)
def test_rewrite_versions_module_script_src() -> None:
html = '<script type="module" src="/assets/app.js"></script>'
assert rewrite_asset_refs(html, TOKEN) == (
'<script type="module" src="/assets/app.js?v=abc123"></script>'
)
def test_rewrite_versions_every_ref_in_one_pass() -> None:
html = (
'<link rel="stylesheet" href="/assets/styles.css">'
"<script src=\"assets/markdown.js\"></script>"
'<script type="module" src="/assets/app.js"></script>'
)
assert rewrite_asset_refs(html, TOKEN) == (
'<link rel="stylesheet" href="/assets/styles.css?v=abc123">'
'<script src="assets/markdown.js?v=abc123"></script>'
'<script type="module" src="/assets/app.js?v=abc123"></script>'
)
def test_rewrite_is_idempotent_for_already_versioned_refs() -> None:
html = '<link rel="stylesheet" href="/assets/styles.css?v=abc123">'
assert rewrite_asset_refs(html, "deadbeef") == html
def test_rewrite_leaves_query_and_fragment_refs_alone() -> None:
html = '<script src="/assets/app.js?x=1"></script><img src="/assets/logo.svg#frag">'
assert rewrite_asset_refs(html, TOKEN) == html
def test_rewrite_leaves_non_asset_refs_untouched() -> None:
html = (
'<a href="/sources.html">sources</a>'
'<img src="data:image/png;base64,AAA">'
'<a href="/login.html?next=/sources.html">login</a>'
)
assert rewrite_asset_refs(html, TOKEN) == html
def test_rewrite_returns_html_unchanged_when_no_asset_refs() -> None:
html = "<p>no assets here</p>"
assert rewrite_asset_refs(html, TOKEN) == html
# ---------------------------------------------------------------------------
# CachingMiddleware fallback branches (task 02)
# ---------------------------------------------------------------------------
def _page_app() -> FastAPI:
"""A bare app with a ``text/html`` route at ``/`` + the middleware."""
app = FastAPI()
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return '<html><head><link rel="stylesheet" href="/assets/styles.css"></head></html>'
@app.get("/api/health")
def health() -> JSONResponse:
return JSONResponse({"status": "ok"})
caching.configure_caching(app)
return app
def test_middleware_success_path_rewrites_page_and_sets_no_cache() -> None:
client = TestClient(_page_app())
r = client.get("/")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
token = caching.asset_version()
assert f'href="/assets/styles.css?v={token}"' in r.text
assert 'href="/assets/styles.css">' not in r.text
def test_middleware_buffer_failure_keeps_body_and_sets_no_cache(monkeypatch) -> None:
"""If buffering/token resolution fails, the ORIGINAL streaming body is
served unmodified — with ``no-cache`` — never an empty page."""
def _boom() -> str:
raise RuntimeError("token blew up")
monkeypatch.setattr(caching, "asset_version", _boom)
client = TestClient(_page_app())
r = client.get("/")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert r.text == (
"<html><head><link rel=\"stylesheet\" href=\"/assets/styles.css\"></head></html>"
)
def test_middleware_non_html_body_on_page_path_gets_no_cache_only() -> None:
"""A page path whose response is not ``text/html`` (e.g. the 404 JSON
when the static dir is missing) is revalidated but never rewritten."""
app = FastAPI()
@app.get("/")
def not_found() -> JSONResponse:
return JSONResponse({"detail": "Not Found"}, status_code=404)
caching.configure_caching(app)
client = TestClient(app)
r = client.get("/")
assert r.status_code == 404
assert r.headers["cache-control"] == "no-cache"
assert r.json() == {"detail": "Not Found"} # body untouched
def test_middleware_rewrite_failure_reserves_original_bytes(monkeypatch) -> None:
"""If the body buffered fine but the rewrite itself fails, the ORIGINAL
bytes are re-served (never an empty page) with ``no-cache``."""
def _boom(html: str, token: str) -> str:
raise ValueError("rewrite blew up")
monkeypatch.setattr(caching, "rewrite_asset_refs", _boom)
client = TestClient(_page_app())
r = client.get("/")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert r.text == (
"<html><head><link rel=\"stylesheet\" href=\"/assets/styles.css\"></head></html>"
)
def test_middleware_leaves_api_responses_byte_identical() -> None:
"""``/api/*`` gets no injected headers at all (no cache-control)."""
client = TestClient(_page_app())
r = client.get("/api/health")
assert r.status_code == 200
assert "cache-control" not in r.headers
assert r.json() == {"status": "ok"}
def test_read_body_buffers_plain_response_bytes() -> None:
from starlette.responses import Response
resp = Response(content=b"<html>hi</html>", media_type="text/html")
assert asyncio.run(caching._read_body(resp)) == b"<html>hi</html>"
def test_read_body_copies_plain_response_memoryview() -> None:
from starlette.responses import Response
resp = Response(content=memoryview(b"<html>hi</html>"), media_type="text/html")
assert asyncio.run(caching._read_body(resp)) == b"<html>hi</html>"
def test_read_body_drains_streaming_response() -> None:
from starlette.responses import StreamingResponse
async def gen() -> AsyncIterator[bytes]:
yield b"<a>"
yield b"</a>"
resp = StreamingResponse(content=gen(), media_type="text/html")
assert asyncio.run(caching._read_body(resp)) == b"<a></a>"
+11
View File
@@ -19,3 +19,14 @@ def test_create_app_warns_and_serves_api_only_without_static_dir(
assert client.get("/api/health").status_code == 200
# …but the static mount is absent (no index page).
assert client.get("/").status_code == 404
def test_create_app_wires_caching_middleware() -> None:
"""Phase 33: the app factory always attaches the cache-busting
middleware (by name) — even when the static dir is missing, so the
/api/* no-touch guarantee holds in every environment."""
app2 = main_mod.create_app()
# ``mw.cls`` is starlette's opaque ``_MiddlewareFactory`` protocol —
# reach for ``__name__`` the same way starlette's own __repr__ does.
middleware_names = [getattr(mw.cls, "__name__", "") for mw in app2.user_middleware]
assert "CachingMiddleware" in middleware_names