fix(web): never 304 a rewritten page — pages drop conditional validators, assets keep them
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""Phase 54 E2E (Playwright): the browser can never be 304'd onto stale
|
||||
HTML.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_asset_cache_revalidation.py -v --no-cov
|
||||
|
||||
Phase 33's cache busting had a hole (measured 2026-08-30): the HTML page
|
||||
response carried the *static file's* ``etag`` / ``last-modified``, while
|
||||
the *served bytes* were the per-process rewritten body — so a conditional
|
||||
revalidation 304'd out of the rewrite and the browser kept HTML whose
|
||||
``?v=`` pointed at the previous commit's CSS/JS, immutable-cached for a
|
||||
year. Phase 54 closed the hole: on known page paths the middleware strips
|
||||
``if-none-match`` / ``if-modified-since`` inbound and drops the outbound
|
||||
validators — a page always 200s with the current ``?v=<token>`` refs.
|
||||
``/assets/*`` keeps its immutable + validator behavior (a 304 there is
|
||||
safe — the URL encodes the version).
|
||||
|
||||
These assertions are against a real browser: the document it receives is
|
||||
always the current one (200, current token, no validators), the CSS that
|
||||
actually renders is this tree's, and the assets stay immutable. The suite
|
||||
only loads pages — it asks no questions, so no KB seeding is needed. The
|
||||
E2E app is a uvicorn subprocess of the SAME checkout (``conftest.py`` →
|
||||
``app_server``), so the expected token is deterministic: the git short
|
||||
SHA of this checkout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import Page, Response
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _expected_token() -> str:
|
||||
"""The version token the app process appends to its asset URLs.
|
||||
|
||||
The E2E app is a subprocess of this very checkout, and in a git
|
||||
checkout ``asset_version()`` is ``git rev-parse --short HEAD`` —
|
||||
computed exactly the way the app does it, so the expected value is
|
||||
deterministic for this run.
|
||||
"""
|
||||
return subprocess.run(
|
||||
["git", "-C", str(REPO), "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _assert_current_document(resp: Response, token: str) -> None:
|
||||
"""The page-contract assertions (phase 54): 200 with the current
|
||||
``?v=<token>`` references, no validators, ``no-cache`` — so the
|
||||
browser can never revalidate its way back to a previous commit's
|
||||
HTML.
|
||||
|
||||
``resp`` is the main document's ``Response`` returned by
|
||||
``page.goto()`` — Playwright Python treats a bare string in
|
||||
``expect_response(...)`` as a URL glob, so the navigation return
|
||||
value is the assertion target for the document itself."""
|
||||
assert resp.status == 200, f"the document must 200 (never 304): {resp.status}"
|
||||
headers = resp.headers
|
||||
assert "etag" not in headers, "a page response must publish no etag"
|
||||
assert "last-modified" not in headers, (
|
||||
"a page response must publish no last-modified"
|
||||
)
|
||||
assert headers["cache-control"] == "no-cache"
|
||||
body = resp.text()
|
||||
assert f"styles.css?v={token}" in body, (
|
||||
f"the served HTML must reference the current token's CSS "
|
||||
f"(styles.css?v={token})"
|
||||
)
|
||||
|
||||
|
||||
def test_document_is_200_current_token_no_validators(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""`GET /` through a real browser: 200, no `etag`/`last-modified`,
|
||||
`no-cache`, and the body references the current commit's CSS and JS.
|
||||
On the pre-fix app a browser that has seen the page once gets a 304
|
||||
here instead — this is the suite's core assertion."""
|
||||
token = _expected_token()
|
||||
assert token, "the version token must be non-empty"
|
||||
|
||||
resp = page.goto(app_url)
|
||||
assert resp is not None
|
||||
_assert_current_document(resp, token)
|
||||
# The chat page's two local refs, both carrying the current token.
|
||||
assert f"app.js?v={token}" in resp.text()
|
||||
|
||||
|
||||
def test_second_navigation_is_still_200_not_304(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""The exact shape of the reported symptom: a plain re-navigation.
|
||||
After the first load the browser's cache holds the first document —
|
||||
the second `goto` must still receive a fresh 200 with the current
|
||||
token, not a 304 that keeps the previous commit's ``?v=``."""
|
||||
token = _expected_token()
|
||||
assert token
|
||||
|
||||
page.goto(app_url) # first visit — primes the browser's document cache
|
||||
resp = page.goto(app_url) # the browser's own revalidation
|
||||
assert resp is not None
|
||||
_assert_current_document(resp, token)
|
||||
|
||||
|
||||
def test_browser_renders_current_css_not_stale(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""End-to-end consequence of the 304 hole: the CSS the browser is
|
||||
ACTUALLY rendering is the current tree's. The phase-52 rule
|
||||
(`.messages { flex: 1 1 auto }` → computed `flex-grow: 1`) exists only
|
||||
in the current `styles.css`, so a green here proves the browser is
|
||||
not sitting on a stale, immutable-pinned asset from an earlier
|
||||
commit."""
|
||||
page.goto(app_url)
|
||||
flex_grow = page.evaluate(
|
||||
"() => getComputedStyle(document.querySelector('#messages')).flexGrow"
|
||||
)
|
||||
assert flex_grow == "1", (
|
||||
f"#messages must carry the current tree's `flex: 1 1 auto` "
|
||||
f"(flex-grow 1) — got {flex_grow!r}; the browser is on stale CSS"
|
||||
)
|
||||
|
||||
|
||||
def test_other_pages_carry_the_contract(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""/sources.html (another HTML_PAGES entry): the same document
|
||||
contract as `/` — 200, no validators, `no-cache`, current token."""
|
||||
token = _expected_token()
|
||||
assert token
|
||||
|
||||
resp = page.goto(app_url + "/sources.html")
|
||||
assert resp is not None
|
||||
_assert_current_document(resp, token)
|
||||
|
||||
|
||||
def test_assets_still_immutable_with_validators(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""The asymmetry is deliberate: `/assets/*` keeps its validators —
|
||||
a conditional `GET` on a versioned asset URL may still 304, because
|
||||
the URL already encodes the version. Only the HTML pages dropped
|
||||
them."""
|
||||
with page.expect_response("**/assets/styles.css**") as info:
|
||||
page.goto(app_url)
|
||||
resp = info.value
|
||||
assert resp.status == 200
|
||||
assert (
|
||||
resp.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
)
|
||||
assert "etag" in resp.headers, (
|
||||
"the asset keeps its validators — only pages dropped them"
|
||||
)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Integration: the phase-54 revalidation contract against the REAL app.
|
||||
|
||||
Phase 33's two cache-busting layers (``asset_version()`` +
|
||||
``CachingMiddleware``) rewrite the known HTML pages to carry
|
||||
``?v=<token>`` asset references — but the ``etag`` / ``last-modified``
|
||||
validators Starlette publishes for a page describe the STATIC FILE, not
|
||||
the rewritten body this process built from its own token. A conditional
|
||||
GET that matched those validators used to 304 out of the rewrite: the
|
||||
browser kept the HTML it already had, whose ``?v=`` pinned the PREVIOUS
|
||||
commit's CSS/JS — cached ``immutable`` for a year. This suite pins the
|
||||
fix end-to-end (real ``app.main:app``, real ``StaticFiles`` mount on the
|
||||
real ``frontend/`` tree, real ``asset_version()`` token — no mocks):
|
||||
|
||||
* every known page (``HTML_PAGES``) AND the dynamic ``/shared/<token>``
|
||||
page 200s on a conditional GET, always with the current
|
||||
``?v=<token>`` body and no validators;
|
||||
* ``/assets/*`` is untouched: ``immutable`` for a year, validators
|
||||
intact, a conditional GET on the versioned URL still 304s (that 304
|
||||
is safe — the URL itself carries the version);
|
||||
* ``/api/*`` stays byte-identical: no ``cache-control`` injected, no
|
||||
validators, conditional headers pass through (the SSE chat stream's
|
||||
pass-through is pinned by ``test_chat_api.py`` — the regression run
|
||||
below).
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.caching import (
|
||||
ASSET_CACHE_CONTROL,
|
||||
HTML_CACHE_CONTROL,
|
||||
HTML_PAGES,
|
||||
asset_version,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = REPO / "frontend"
|
||||
|
||||
|
||||
def _token_ref_re(token: str) -> re.Pattern[str]:
|
||||
"""A local ``assets/…`` href/src reference that already carries
|
||||
``?v=<token>`` (the middleware's rewrite output)."""
|
||||
return re.compile(r'(?:src|href)="(?:/)?assets/[^"?#]*\?v=' + re.escape(token) + r'"')
|
||||
|
||||
|
||||
def file_validators(page_file: Path) -> tuple[str, str]:
|
||||
"""The etag / last-modified Starlette would stamp on the underlying
|
||||
static file — exactly what a browser revalidates against.
|
||||
|
||||
``stat_result`` is passed up front: starlette 1.x's ``FileResponse``
|
||||
defers the stat to ``__call__``, so without it the headers carry no
|
||||
validators yet (same pattern as the unit suite's ``_file_validators``).
|
||||
"""
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
headers = FileResponse(page_file, stat_result=os.stat(page_file)).headers
|
||||
return headers["etag"], headers["last-modified"]
|
||||
|
||||
|
||||
def _page_file(path: str) -> Path:
|
||||
"""The static file backing a page path (``/`` → ``index.html``)."""
|
||||
name = path.lstrip("/") or "index.html"
|
||||
file = FRONTEND / name
|
||||
assert file.is_file(), f"missing page file for {path}: {file}"
|
||||
return file
|
||||
|
||||
|
||||
def _assert_page_contract(response: httpx.Response, token: str) -> None:
|
||||
"""The phase-54 page contract on any known page: a full 200 with the
|
||||
CURRENT process token on the asset refs, ``Cache-Control: no-cache``,
|
||||
and NO validators (a page must never be revalidated against a
|
||||
validator this process published)."""
|
||||
assert response.status_code == 200, (
|
||||
f"a page path must never 304 (got {response.status_code})"
|
||||
)
|
||||
assert response.headers["cache-control"] == HTML_CACHE_CONTROL
|
||||
assert "etag" not in response.headers
|
||||
assert "last-modified" not in response.headers
|
||||
assert f"?v={token}" in response.text
|
||||
assert _token_ref_re(token).search(response.text), (
|
||||
"no local assets/ ref carries ?v=<current token>"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_chats(db: Session) -> Iterator[None]:
|
||||
"""``saved_chats`` is global state — the share test writes one row
|
||||
(house pattern from ``test_chats_api.py``)."""
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_every_known_page_200s_on_conditional_get(client: TestClient) -> None:
|
||||
"""THE phase-54 regression, real app: for EVERY known page, a plain
|
||||
GET sets the contract, then a conditional GET carrying the static
|
||||
FILE's etag (what a browser captured pre-fix) must still 200 with
|
||||
the SAME rewritten body — pre-fix the loop failed on the very first
|
||||
304, pinning the browser on the previous commit's immutable assets."""
|
||||
token = asset_version()
|
||||
for path in HTML_PAGES:
|
||||
plain = client.get(path)
|
||||
_assert_page_contract(plain, token)
|
||||
|
||||
etag, _ = file_validators(_page_file(path))
|
||||
conditional = client.get(path, headers={"if-none-match": etag})
|
||||
_assert_page_contract(conditional, token)
|
||||
assert conditional.content == plain.content, (
|
||||
f"{path}: the conditional 200 must serve the same rewritten body"
|
||||
)
|
||||
|
||||
|
||||
def test_dynamic_shared_page_200s_on_conditional_get(admin_client: TestClient) -> None:
|
||||
"""The dynamic /shared/<token> page (phase 51) gets the same contract
|
||||
via the real save+share flow: a conditional GET carrying the
|
||||
``shared.html`` file's etag must still 200 with the rewritten body —
|
||||
the route's ``FileResponse`` honours conditional headers, so without
|
||||
the inbound strip this page 304'd too."""
|
||||
token = asset_version()
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{"who": "user", "text": "How did I install gitlab?"},
|
||||
{"who": "brain", "text": "You've got this!"},
|
||||
],
|
||||
"share": True,
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
share_url = created.json()["share_url"]
|
||||
|
||||
plain = admin_client.get(share_url)
|
||||
_assert_page_contract(plain, token)
|
||||
|
||||
etag, _ = file_validators(FRONTEND / "shared.html")
|
||||
conditional = admin_client.get(share_url, headers={"if-none-match": etag})
|
||||
_assert_page_contract(conditional, token)
|
||||
assert conditional.content == plain.content
|
||||
|
||||
|
||||
def test_assets_keep_immutable_validators_and_304(client: TestClient) -> None:
|
||||
"""The inbound strip must NOT have widened to /assets/*: the versioned
|
||||
asset URL keeps its validators and still 304s on a conditional GET —
|
||||
that 304 is safe because the URL itself carries ?v=<token>."""
|
||||
token = asset_version()
|
||||
url = f"/assets/styles.css?v={token}"
|
||||
|
||||
plain = client.get(url)
|
||||
assert plain.status_code == 200
|
||||
assert plain.headers["cache-control"] == ASSET_CACHE_CONTROL
|
||||
assert "etag" in plain.headers
|
||||
assert "last-modified" in plain.headers
|
||||
|
||||
conditional = client.get(url, headers={"if-none-match": plain.headers["etag"]})
|
||||
assert conditional.status_code == 304 # versioned-URL 304s stay safe
|
||||
assert conditional.content == b""
|
||||
assert conditional.headers["cache-control"] == ASSET_CACHE_CONTROL
|
||||
|
||||
|
||||
def test_api_paths_get_no_cache_headers_and_untouched_stream(client: TestClient) -> None:
|
||||
"""/api/* stays byte-identical: no cache-control injected, no
|
||||
validators published, and conditional headers pass through to the
|
||||
route untouched (the SSE chat stream's pass-through is pinned by
|
||||
``tests/integration/test_chat_api.py`` — the regression run below)."""
|
||||
plain = client.get("/api/health")
|
||||
assert plain.status_code == 200
|
||||
assert "cache-control" not in plain.headers
|
||||
assert "etag" not in plain.headers
|
||||
assert "last-modified" not in plain.headers
|
||||
|
||||
conditional = client.get("/api/health", headers={"if-none-match": "x"})
|
||||
assert conditional.status_code == 200 # pass-through — the strip is page-scoped
|
||||
assert conditional.json() == plain.json()
|
||||
assert "cache-control" not in conditional.headers
|
||||
|
||||
|
||||
def test_page_token_matches_process_token(client: TestClient) -> None:
|
||||
"""The token embedded in the served page equals ``asset_version()`` —
|
||||
the per-process ``functools.cache`` contract: one page load can never
|
||||
mix two versions (phase 54, assumption 5)."""
|
||||
token = asset_version()
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
match = re.search(r'styles\.css\?v=([^"]+)"', response.text)
|
||||
assert match is not None, "styles.css ref not found in the served page"
|
||||
assert match.group(1) == token
|
||||
+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