fix(web): never 304 a rewritten page — pages drop conditional validators, assets keep them

This commit is contained in:
2026-08-31 01:35:28 -04:00
parent 9518d9d5d1
commit c564e317ed
6 changed files with 742 additions and 81 deletions
+266 -2
View File
@@ -9,7 +9,10 @@ Covers the four token paths:
* empty -> a missing or empty static dir yields ``"dev"``.
Plus the asset-reference rewrite (``rewrite_asset_refs``) and the
CachingMiddleware fallback branches (task 02).
CachingMiddleware fallback branches (task 02), and the phase-54
conditional-request pins on a ``StaticFiles(html=True)``-backed app
(known pages 200 on conditional requests and publish no validators;
``/assets/*`` and ``/api/*`` keep their conditional behavior).
"""
from __future__ import annotations
@@ -22,9 +25,11 @@ from collections.abc import AsyncIterator, Iterator
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.testclient import TestClient
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
import app.core.caching as caching
from app.config import Settings
@@ -452,3 +457,262 @@ def test_middleware_leaves_unknown_paths_untouched() -> None:
assert r.status_code == 200
assert "cache-control" not in r.headers
assert r.json() == {"ok": True}
# ---------------------------------------------------------------------------
# Phase 54: conditional requests on page paths (StaticFiles-backed app)
# ---------------------------------------------------------------------------
#
# The 304 hole: the validators published for an HTML page describe the
# static FILE, but the bytes the browser receives are the REWRITTEN body
# this process built from its own token. Starlette's ``StaticFiles``
# honours ``If-None-Match`` / ``If-Modified-Since`` *before* the middleware
# can see a response, so a matching conditional request used to 304 out of
# the rewrite — the browser kept HTML whose ``?v=`` pinned the previous
# commit's immutable assets. The middleware now strips those headers on
# page paths (inbound) and drops the validators (outbound); ``/assets/*``
# and ``/api/*`` keep their conditional behavior.
def _file_validators(path: Path) -> dict[str, str]:
"""The ``etag`` / ``last-modified`` the StaticFiles mount will publish
for ``path`` — derived straight from the file. The post-fix page
response publishes no validators to capture, so the browser-side etag
is reconstructed the way the mount computes it (starlette 1.x's
``FileResponse`` defers the stat to ``__call__`` unless
``stat_result`` is passed)."""
file_response = FileResponse(path, stat_result=os.stat(path))
return {
"etag": file_response.headers["etag"],
"last-modified": file_response.headers["last-modified"],
}
def _static_page_app(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> tuple[TestClient, Path]:
"""A ``StaticFiles(html=True)``-backed app + the middleware — the same
mounting shape as ``app/main.py`` (routes + the catch-all static mount)
— so conditional requests are exercised against REAL
``FileResponse``/``StaticFiles`` validators, the exact layer that
produced the pre-fix 304. Returns ``(TestClient(app), frontend root)``.
The version token is pinned to ``"unit-token"`` so the assertions are
exact: ``asset_version`` is the module-level call the middleware makes,
and its ``functools.cache``d result can only be a git short SHA, a
12-hex content hash, or ``"dev"`` — so it is monkeypatched directly.
The ``get_settings`` stub (mirroring
``test_default_static_dir_comes_from_settings``) steers any unpatched
token computation to the tmp tree instead of the checkout.
"""
frontend = tmp_path / "frontend"
assets = frontend / "assets"
assets.mkdir(parents=True)
(frontend / "index.html").write_text(
"<html><head>"
'<link rel="stylesheet" href="/assets/app.js">'
'<script src="/assets/app.js"></script>'
"</head><body>static index</body></html>"
)
(assets / "app.js").write_text("x")
(assets / "app.css").write_text("y")
monkeypatch.setattr(
caching, "get_settings", lambda: Settings(static_dir=str(frontend))
)
monkeypatch.setattr(caching, "asset_version", lambda: "unit-token")
app = FastAPI()
@app.get("/api/ping")
def ping(request: Request) -> Response:
# Honours a conditional header, exactly like StaticFiles does —
# proves the inbound strip does NOT widen to /api/*.
if "if-none-match" in request.headers:
return Response(status_code=304)
return JSONResponse({"status": "ok"})
@app.get("/shared/{token}")
def shared_page(request: Request, token: str) -> Response:
# Like the phase-51 route: serves text/html AND honours a
# conditional header — without the inbound strip this route would
# 304 a conditional GET, so the strip is observable here.
if "if-none-match" in request.headers:
return Response(status_code=304)
return HTMLResponse(
"<html><head>"
'<link rel="stylesheet" href="/assets/app.css">'
"</head><body>shared</body></html>"
)
app.mount("/", StaticFiles(directory=frontend, html=True), name="static")
caching.configure_caching(app)
return TestClient(app), frontend
def test_conditional_get_page_returns_200_with_rewrite(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""THE phase-54 regression: a conditional GET / that matches the
static file's etag used to 304 out of the rewrite (StaticFiles
honours If-None-Match before the middleware can see a body), leaving
the browser on HTML whose ``?v=`` pinned the previous commit's
immutable assets. Post-fix the same request gets a full 200 with the
current ``?v=<token>`` refs — and the page publishes no validators.
(Fails with a 304 on the pre-fix code — the defect reproduction.)"""
client, index_html = _static_page_app(tmp_path, monkeypatch)
first = client.get("/")
assert first.status_code == 200
assert "?v=unit-token" in first.text
assert "etag" not in first.headers # the fix drops it
# Derive the *file* validator the way a browser would have captured
# it — the post-fix page response publishes no etag to capture.
etag = _file_validators(index_html)["etag"]
r = client.get("/", headers={"if-none-match": etag})
assert r.status_code == 200 # never 304 on a page path
assert 'href="/assets/app.js?v=unit-token"' in r.text
assert 'src="/assets/app.js?v=unit-token"' in r.text
assert r.headers["cache-control"] == "no-cache"
assert "etag" not in r.headers
assert "last-modified" not in r.headers
def test_conditional_get_page_with_if_modified_since_returns_200(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Same pin via If-Modified-Since: round-tripping the mount's
last-modified used to 304 (pre-fix); the inbound strip now guarantees
the full rewritten 200."""
client, index_html = _static_page_app(tmp_path, monkeypatch)
last_modified = _file_validators(index_html)["last-modified"]
r = client.get("/", headers={"if-modified-since": last_modified})
assert r.status_code == 200 # never 304 on a page path
assert 'href="/assets/app.js?v=unit-token"' in r.text
assert r.headers["cache-control"] == "no-cache"
assert "etag" not in r.headers
assert "last-modified" not in r.headers
def test_page_response_publishes_no_validators(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Every known page (the static mount's ``/`` and the dynamic
``/shared/<token>``) publishes NO validators — combined with
``Cache-Control: no-cache`` the browser can never revalidate a page
against a validator this process published."""
client, _ = _static_page_app(tmp_path, monkeypatch)
for path in ("/", "/shared/abc123"):
r = client.get(path)
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert "etag" not in r.headers
assert "last-modified" not in r.headers
@pytest.mark.parametrize("status_code", [304, 204], ids=["304", "204"])
def test_downstream_bodiless_status_on_page_is_passed_bodiless(status_code: int) -> None:
"""A downstream that still returns a bodiless status on a page path is
passed through BODIESS — starlette forbids a body on 204/304, and the
pre-guard rewrite path would build exactly that
(``Response(content=…, status_code=304)``). Belt-and-braces: after the
inbound strip StaticFiles cannot 304 page paths, but a future route or
proxy could — the passthrough keeps no-cache and no validators."""
app = FastAPI()
@app.get("/")
def index(request: Request) -> Response:
# The inbound strip already removed if-none-match /
# if-modified-since from the scope, so a conditional route can no
# longer 304 a page path — trigger the bodiless status with a
# header the strip does not touch, and publish validators to pin
# the outbound drop.
if request.headers.get("x-bodiless") == "1":
return Response(
status_code=status_code,
headers={
"etag": '"stale"',
"last-modified": "Wed, 01 Jan 2024 00:00:00 GMT",
},
)
return HTMLResponse(
"<html><head>"
'<link rel="stylesheet" href="/assets/styles.css">'
"</head></html>"
)
caching.configure_caching(app)
client = TestClient(app)
# The plain path still gets the full rewritten page (the guard only
# kicks in for bodiless statuses).
full = client.get("/")
assert full.status_code == 200
assert 'href="/assets/styles.css?v=' in full.text
assert full.headers["cache-control"] == "no-cache"
r = client.get("/", headers={"x-bodiless": "1", "if-none-match": "whatever"})
assert r.status_code == status_code
assert r.content == b"" # never a body on a bodiless status
assert r.headers["cache-control"] == "no-cache"
assert "etag" not in r.headers
assert "last-modified" not in r.headers
def test_shared_page_path_ignores_conditional_headers(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The dynamic /shared/<token> page (phase 51) gets the same inbound
strip: the conditional route in ``_static_page_app`` would 304 if it
saw if-none-match — the strip hides it, so the page always 200s with
the current ``?v=<token>`` refs."""
client, _ = _static_page_app(tmp_path, monkeypatch)
r = client.get("/shared/abc123", headers={"if-none-match": "whatever"})
assert r.status_code == 200 # the route would have 304'd pre-strip
assert 'href="/assets/app.css?v=unit-token"' in r.text
assert r.headers["cache-control"] == "no-cache"
assert "etag" not in r.headers
def test_api_path_keeps_conditional_headers(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The strip must NOT widen to /api/*: /api/ping still sees
if-none-match (it 304s on it, like any conditional route), and the
middleware passes that 304 through byte-identical — no cache-control
injected."""
client, _ = _static_page_app(tmp_path, monkeypatch)
plain = client.get("/api/ping")
assert plain.status_code == 200
assert plain.json() == {"status": "ok"}
assert "cache-control" not in plain.headers
r = client.get("/api/ping", headers={"if-none-match": "whatever"})
assert r.status_code == 304 # the route saw the header — no strip
assert r.content == b""
assert "cache-control" not in r.headers
def test_assets_path_keeps_validators_and_304(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""/assets/* keeps its conditional behavior completely intact: the
immutable caching is header-only, the validators stay, and a 304 on a
versioned asset URL is SAFE (the URL itself carries ``?v=<token>``)."""
client, _ = _static_page_app(tmp_path, monkeypatch)
r = client.get("/assets/app.css?v=unit-token")
assert r.status_code == 200
assert r.headers["cache-control"] == caching.ASSET_CACHE_CONTROL
assert "etag" in r.headers
assert "last-modified" in r.headers
etag = r.headers["etag"]
r304 = client.get("/assets/app.css?v=unit-token", headers={"if-none-match": etag})
assert r304.status_code == 304 # versioned-URL 304s stay safe
assert r304.content == b""
assert r304.headers["cache-control"] == caching.ASSET_CACHE_CONTROL