720 lines
27 KiB
Python
720 lines
27 KiB
Python
"""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), 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
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import uuid
|
|
from collections.abc import AsyncIterator, Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
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
|
|
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",
|
|
"-c",
|
|
"commit.gpgsign=false", # the fixture commit never signs (env gpg)
|
|
"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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTML_PAGES registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_html_pages_include_history() -> None:
|
|
"""Phase 50: the History page is registered in HTML_PAGES — without
|
|
this entry it would serve unversioned asset refs, which the
|
|
immutable-for-a-year asset caching would pin to stale CSS after a
|
|
deploy (the phase-35 git-sources lesson). The entry is ADDED —
|
|
every pre-phase-50 page stays registered."""
|
|
for path in (
|
|
"/",
|
|
"/index.html",
|
|
"/sources.html",
|
|
"/document.html",
|
|
"/login.html",
|
|
"/tuning.html",
|
|
"/git-sources.html",
|
|
"/history.html",
|
|
"/shared.html", # phase 51: the shared page's static path
|
|
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
|
|
):
|
|
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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>"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 51: the dynamic share page — the ``/shared/`` prefix contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _shared_page_app() -> FastAPI:
|
|
"""A bare app with the phase-51 route pair + the middleware:
|
|
``/shared/<token>`` (``text/html`` — the page the route serves) and
|
|
``/api/shared/<token>`` (the JSON read), plus an unknown path."""
|
|
app = FastAPI()
|
|
|
|
@app.get("/shared/{token}", response_class=HTMLResponse)
|
|
def shared_page(token: str) -> str:
|
|
return (
|
|
"<html><head>"
|
|
'<link rel="stylesheet" href="/assets/styles.css">'
|
|
"</head><body>shared</body></html>"
|
|
)
|
|
|
|
@app.get("/api/shared/{token}")
|
|
def shared_api(token: str) -> JSONResponse:
|
|
return JSONResponse({"title": "Shared", "messages": []})
|
|
|
|
@app.get("/some/unknown/path")
|
|
def unknown() -> JSONResponse:
|
|
return JSONResponse({"ok": True})
|
|
|
|
caching.configure_caching(app)
|
|
return app
|
|
|
|
|
|
def test_middleware_treats_shared_page_path_as_known_html_page() -> None:
|
|
"""Phase 51: ``/shared/<uuid>`` joins the HTML_PAGES contract —
|
|
``no-cache`` + ``?v=`` asset rewrite on the ``text/html`` body (the
|
|
FileResponse body is drained by the existing ``_read_body`` path)."""
|
|
client = TestClient(_shared_page_app())
|
|
r = client.get(f"/shared/{uuid.uuid4()}")
|
|
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_leaves_api_shared_read_untouched() -> None:
|
|
"""``/api/shared/<uuid>`` — the JSON read — starts with ``/api/``,
|
|
not ``/shared/``: byte-identical pass-through, no injected headers."""
|
|
client = TestClient(_shared_page_app())
|
|
r = client.get(f"/api/shared/{uuid.uuid4()}")
|
|
assert r.status_code == 200
|
|
assert "cache-control" not in r.headers
|
|
assert r.json() == {"title": "Shared", "messages": []}
|
|
|
|
|
|
def test_middleware_leaves_unknown_paths_untouched() -> None:
|
|
"""A path that is neither a known page, ``/shared/*``, nor
|
|
``/assets/*`` passes through byte-identical, no headers."""
|
|
client = TestClient(_shared_page_app())
|
|
r = client.get("/some/unknown/path")
|
|
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
|