"""/assets serving: gzip and long-lived caching for the frontend build (#3175).""" from __future__ import annotations import gzip import pytest from httpx import ASGITransport, AsyncClient from starlette.applications import Starlette from starlette.routing import Mount from backend.app.core import static_assets from backend.app.core.static_assets import ( IMMUTABLE_CACHE_CONTROL, REVALIDATE_CACHE_CONTROL, AssetStaticFiles, accepts_gzip, ) BUNDLE = b"export const answer = 42;\n" * 400 # ~10 KB, compresses well BUNDLE_NAME = "index-CdoB5JwO.js" @pytest.fixture def assets_dir(tmp_path): root = tmp_path / "assets" (root / "pdfjs" / "cmaps").mkdir(parents=True) (root / BUNDLE_NAME).write_bytes(BUNDLE) (root / "index-Dk3mQx9a.css").write_bytes(b"body { color: red; }\n" * 200) (root / "tiny-AbCdEfGh.js").write_bytes(b"export {};\n") (root / "logo-AbCdEfGh.png").write_bytes(b"\x89PNG" + b"\x00" * 4000) (root / "pdfjs" / "cmaps" / "Adobe-Japan1-UCS2.bcmap").write_bytes(b"\x00" * 4000) (root / "pdfjs" / "wasm.json").write_bytes(b'{"a": 1}' * 400) static_assets._gzip_cache.clear() yield root static_assets._gzip_cache.clear() @pytest.fixture async def client(assets_dir): app = Starlette(routes=[Mount("/assets", app=AssetStaticFiles(directory=assets_dir))]) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c async def _get(client: AsyncClient, path: str, **headers: str): """GET without httpx decoding the body, so the raw bytes can be checked.""" request = client.build_request("GET", path, headers=headers) response = await client.send(request, stream=True) raw = b"".join([chunk async for chunk in response.aiter_raw()]) await response.aclose() return response, raw class TestCompression: @pytest.mark.asyncio async def test_bundle_is_gzipped_when_accepted(self, client): response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip, deflate, br"}) assert response.status_code == 200 assert response.headers["content-encoding"] == "gzip" assert response.headers["vary"] == "Accept-Encoding" assert response.headers["content-type"].startswith("text/javascript") assert int(response.headers["content-length"]) == len(raw) < len(BUNDLE) assert gzip.decompress(raw) == BUNDLE assert "accept-ranges" not in response.headers @pytest.mark.asyncio async def test_identity_when_gzip_not_accepted(self, client): response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "identity"}) assert response.status_code == 200 assert "content-encoding" not in response.headers assert response.headers["vary"] == "Accept-Encoding" assert raw == BUNDLE @pytest.mark.asyncio async def test_gzip_q0_is_a_refusal(self, client): response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip;q=0, *"}) assert "content-encoding" not in response.headers assert raw == BUNDLE @pytest.mark.asyncio async def test_variants_carry_different_etags(self, client): plain, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "identity"}) zipped, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"}) assert plain.headers["etag"] != zipped.headers["etag"] @pytest.mark.asyncio async def test_gzip_etag_revalidates_to_304(self, client): first, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"}) again, raw = await _get( client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip", "If-None-Match": first.headers["etag"]}, ) assert again.status_code == 304 assert raw == b"" assert again.headers["etag"] == first.headers["etag"] assert again.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL @pytest.mark.asyncio async def test_range_request_is_served_uncompressed(self, client): response, raw = await _get( client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip", "Range": "bytes=0-9"} ) assert response.status_code == 206 assert "content-encoding" not in response.headers assert raw == BUNDLE[:10] @pytest.mark.asyncio async def test_head_is_not_compressed(self, client): response = await client.head(f"/assets/{BUNDLE_NAME}", headers={"Accept-Encoding": "gzip"}) assert response.status_code == 200 assert "content-encoding" not in response.headers assert response.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL @pytest.mark.asyncio @pytest.mark.parametrize("name", ["tiny-AbCdEfGh.js", "logo-AbCdEfGh.png", "pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap"]) async def test_small_or_binary_files_are_not_compressed(self, client, name): response, _ = await _get(client, f"/assets/{name}", **{"Accept-Encoding": "gzip"}) assert response.status_code == 200 assert "content-encoding" not in response.headers @pytest.mark.asyncio async def test_compressed_body_is_reused(self, client, monkeypatch): calls = [] real = static_assets._gzip_file monkeypatch.setattr(static_assets, "_gzip_file", lambda path: calls.append(path) or real(path)) for _ in range(3): response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"}) assert gzip.decompress(raw) == BUNDLE assert len(calls) == 1 @pytest.mark.asyncio async def test_rebuilt_file_is_compressed_again(self, client, assets_dir): await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"}) (assets_dir / BUNDLE_NAME).write_bytes(b"export const answer = 43;\n" * 500) _, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"}) assert gzip.decompress(raw) == b"export const answer = 43;\n" * 500 class TestCacheControl: @pytest.mark.asyncio @pytest.mark.parametrize("name", [BUNDLE_NAME, "index-Dk3mQx9a.css", "logo-AbCdEfGh.png"]) async def test_hashed_files_are_immutable(self, client, name): response, _ = await _get(client, f"/assets/{name}") assert response.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL @pytest.mark.asyncio @pytest.mark.parametrize("name", ["pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap", "pdfjs/wasm.json"]) async def test_pdfjs_runtime_files_are_revalidated(self, client, name): response, _ = await _get(client, f"/assets/{name}", **{"Accept-Encoding": "gzip"}) assert response.status_code == 200 assert response.headers["cache-control"] == REVALIDATE_CACHE_CONTROL @pytest.mark.asyncio async def test_identity_304_keeps_cache_control(self, client): first, _ = await _get(client, f"/assets/{BUNDLE_NAME}") again, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"If-None-Match": first.headers["etag"]}) assert again.status_code == 304 assert again.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL @pytest.mark.asyncio async def test_missing_old_chunk_is_404_not_html(self, client): """A tab still running the previous build asks for chunks the new build deleted. It must get a 404 so the frontend can reload, never a page.""" response, _ = await _get(client, "/assets/ProfilesPage-OldHash1.js", **{"Accept-Encoding": "gzip"}) assert response.status_code == 404 assert "cache-control" not in response.headers @pytest.mark.parametrize( ("header", "expected"), [ (None, False), ("", False), ("gzip", True), ("GZIP", True), ("gzip, deflate, br, zstd", True), ("br", False), ("identity", False), ("gzip;q=0", False), ("gzip; q=0.5", True), ("*", True), ("*;q=0", False), ("gzip;q=0, *", False), ("br, *;q=0.1", True), ("gzip;q=abc", False), ], ) def test_accepts_gzip(header, expected): assert accepts_gzip(header) is expected