test_static_assets.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """/assets serving: gzip and long-lived caching for the frontend build (#3175)."""
  2. from __future__ import annotations
  3. import gzip
  4. import pytest
  5. from httpx import ASGITransport, AsyncClient
  6. from starlette.applications import Starlette
  7. from starlette.routing import Mount
  8. from backend.app.core import static_assets
  9. from backend.app.core.static_assets import (
  10. IMMUTABLE_CACHE_CONTROL,
  11. REVALIDATE_CACHE_CONTROL,
  12. AssetStaticFiles,
  13. accepts_gzip,
  14. )
  15. BUNDLE = b"export const answer = 42;\n" * 400 # ~10 KB, compresses well
  16. BUNDLE_NAME = "index-CdoB5JwO.js"
  17. @pytest.fixture
  18. def assets_dir(tmp_path):
  19. root = tmp_path / "assets"
  20. (root / "pdfjs" / "cmaps").mkdir(parents=True)
  21. (root / BUNDLE_NAME).write_bytes(BUNDLE)
  22. (root / "index-Dk3mQx9a.css").write_bytes(b"body { color: red; }\n" * 200)
  23. (root / "tiny-AbCdEfGh.js").write_bytes(b"export {};\n")
  24. (root / "logo-AbCdEfGh.png").write_bytes(b"\x89PNG" + b"\x00" * 4000)
  25. (root / "pdfjs" / "cmaps" / "Adobe-Japan1-UCS2.bcmap").write_bytes(b"\x00" * 4000)
  26. (root / "pdfjs" / "wasm.json").write_bytes(b'{"a": 1}' * 400)
  27. static_assets._gzip_cache.clear()
  28. yield root
  29. static_assets._gzip_cache.clear()
  30. @pytest.fixture
  31. async def client(assets_dir):
  32. app = Starlette(routes=[Mount("/assets", app=AssetStaticFiles(directory=assets_dir))])
  33. async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
  34. yield c
  35. async def _get(client: AsyncClient, path: str, **headers: str):
  36. """GET without httpx decoding the body, so the raw bytes can be checked."""
  37. request = client.build_request("GET", path, headers=headers)
  38. response = await client.send(request, stream=True)
  39. raw = b"".join([chunk async for chunk in response.aiter_raw()])
  40. await response.aclose()
  41. return response, raw
  42. class TestCompression:
  43. @pytest.mark.asyncio
  44. async def test_bundle_is_gzipped_when_accepted(self, client):
  45. response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip, deflate, br"})
  46. assert response.status_code == 200
  47. assert response.headers["content-encoding"] == "gzip"
  48. assert response.headers["vary"] == "Accept-Encoding"
  49. assert response.headers["content-type"].startswith("text/javascript")
  50. assert int(response.headers["content-length"]) == len(raw) < len(BUNDLE)
  51. assert gzip.decompress(raw) == BUNDLE
  52. assert "accept-ranges" not in response.headers
  53. @pytest.mark.asyncio
  54. async def test_identity_when_gzip_not_accepted(self, client):
  55. response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "identity"})
  56. assert response.status_code == 200
  57. assert "content-encoding" not in response.headers
  58. assert response.headers["vary"] == "Accept-Encoding"
  59. assert raw == BUNDLE
  60. @pytest.mark.asyncio
  61. async def test_gzip_q0_is_a_refusal(self, client):
  62. response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip;q=0, *"})
  63. assert "content-encoding" not in response.headers
  64. assert raw == BUNDLE
  65. @pytest.mark.asyncio
  66. async def test_variants_carry_different_etags(self, client):
  67. plain, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "identity"})
  68. zipped, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
  69. assert plain.headers["etag"] != zipped.headers["etag"]
  70. @pytest.mark.asyncio
  71. async def test_gzip_etag_revalidates_to_304(self, client):
  72. first, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
  73. again, raw = await _get(
  74. client,
  75. f"/assets/{BUNDLE_NAME}",
  76. **{"Accept-Encoding": "gzip", "If-None-Match": first.headers["etag"]},
  77. )
  78. assert again.status_code == 304
  79. assert raw == b""
  80. assert again.headers["etag"] == first.headers["etag"]
  81. assert again.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
  82. @pytest.mark.asyncio
  83. async def test_range_request_is_served_uncompressed(self, client):
  84. response, raw = await _get(
  85. client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip", "Range": "bytes=0-9"}
  86. )
  87. assert response.status_code == 206
  88. assert "content-encoding" not in response.headers
  89. assert raw == BUNDLE[:10]
  90. @pytest.mark.asyncio
  91. async def test_head_is_not_compressed(self, client):
  92. response = await client.head(f"/assets/{BUNDLE_NAME}", headers={"Accept-Encoding": "gzip"})
  93. assert response.status_code == 200
  94. assert "content-encoding" not in response.headers
  95. assert response.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
  96. @pytest.mark.asyncio
  97. @pytest.mark.parametrize("name", ["tiny-AbCdEfGh.js", "logo-AbCdEfGh.png", "pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap"])
  98. async def test_small_or_binary_files_are_not_compressed(self, client, name):
  99. response, _ = await _get(client, f"/assets/{name}", **{"Accept-Encoding": "gzip"})
  100. assert response.status_code == 200
  101. assert "content-encoding" not in response.headers
  102. @pytest.mark.asyncio
  103. async def test_compressed_body_is_reused(self, client, monkeypatch):
  104. calls = []
  105. real = static_assets._gzip_file
  106. monkeypatch.setattr(static_assets, "_gzip_file", lambda path: calls.append(path) or real(path))
  107. for _ in range(3):
  108. response, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
  109. assert gzip.decompress(raw) == BUNDLE
  110. assert len(calls) == 1
  111. @pytest.mark.asyncio
  112. async def test_rebuilt_file_is_compressed_again(self, client, assets_dir):
  113. await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
  114. (assets_dir / BUNDLE_NAME).write_bytes(b"export const answer = 43;\n" * 500)
  115. _, raw = await _get(client, f"/assets/{BUNDLE_NAME}", **{"Accept-Encoding": "gzip"})
  116. assert gzip.decompress(raw) == b"export const answer = 43;\n" * 500
  117. class TestCacheControl:
  118. @pytest.mark.asyncio
  119. @pytest.mark.parametrize("name", [BUNDLE_NAME, "index-Dk3mQx9a.css", "logo-AbCdEfGh.png"])
  120. async def test_hashed_files_are_immutable(self, client, name):
  121. response, _ = await _get(client, f"/assets/{name}")
  122. assert response.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
  123. @pytest.mark.asyncio
  124. @pytest.mark.parametrize("name", ["pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap", "pdfjs/wasm.json"])
  125. async def test_pdfjs_runtime_files_are_revalidated(self, client, name):
  126. response, _ = await _get(client, f"/assets/{name}", **{"Accept-Encoding": "gzip"})
  127. assert response.status_code == 200
  128. assert response.headers["cache-control"] == REVALIDATE_CACHE_CONTROL
  129. @pytest.mark.asyncio
  130. async def test_identity_304_keeps_cache_control(self, client):
  131. first, _ = await _get(client, f"/assets/{BUNDLE_NAME}")
  132. again, _ = await _get(client, f"/assets/{BUNDLE_NAME}", **{"If-None-Match": first.headers["etag"]})
  133. assert again.status_code == 304
  134. assert again.headers["cache-control"] == IMMUTABLE_CACHE_CONTROL
  135. @pytest.mark.asyncio
  136. async def test_missing_old_chunk_is_404_not_html(self, client):
  137. """A tab still running the previous build asks for chunks the new build
  138. deleted. It must get a 404 so the frontend can reload, never a page."""
  139. response, _ = await _get(client, "/assets/ProfilesPage-OldHash1.js", **{"Accept-Encoding": "gzip"})
  140. assert response.status_code == 404
  141. assert "cache-control" not in response.headers
  142. @pytest.mark.parametrize(
  143. ("header", "expected"),
  144. [
  145. (None, False),
  146. ("", False),
  147. ("gzip", True),
  148. ("GZIP", True),
  149. ("gzip, deflate, br, zstd", True),
  150. ("br", False),
  151. ("identity", False),
  152. ("gzip;q=0", False),
  153. ("gzip; q=0.5", True),
  154. ("*", True),
  155. ("*;q=0", False),
  156. ("gzip;q=0, *", False),
  157. ("br, *;q=0.1", True),
  158. ("gzip;q=abc", False),
  159. ],
  160. )
  161. def test_accepts_gzip(header, expected):
  162. assert accepts_gzip(header) is expected