fix(web): never 304 a rewritten page — pages drop conditional validators, assets keep them
This commit is contained in:
+266
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user