test_static_html_cache_headers.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. """Tests for the SPA index.html cache-control behaviour.
  2. Background: Vite emits content-hashed JS/CSS bundle filenames (e.g.
  3. ``index-JRaF_JhW.js``), so those assets are safe to cache forever — the
  4. hash changes when their content changes. The wrapping HTML, however, is
  5. the only file that knows which hash is current. Without explicit cache
  6. directives, Chromium falls back to heuristic caching (typically 10% of
  7. the time since Last-Modified) and on long-running kiosks happily serves
  8. stale HTML across browser restarts. That stale HTML references an old
  9. bundle hash, which is also still in disk cache, so the kiosk runs
  10. pre-deploy JS indefinitely without ever knowing why.
  11. Reproduced in the wild during the #1133 rollout — the SpoolBuddy
  12. display kept serving the pre-fix picker for hours after every
  13. cache-clear attempt because Chromium would re-seed its cache from
  14. disk on next start. Fixed by sending ``no-cache, must-revalidate`` on
  15. the two routes that serve ``index.html``.
  16. These tests pin that behaviour so it can't silently regress (e.g. a
  17. later PR adding a third index.html serve route forgetting the headers,
  18. or someone tightening the policy to ``max-age=N`` and breaking deploys
  19. in subtle ways).
  20. """
  21. from __future__ import annotations
  22. import pytest
  23. from httpx import AsyncClient
  24. # index.html is served by two distinct routes:
  25. # - "/" — root entry
  26. # - the SPA catch-all (any unrecognised path that isn't /api/)
  27. # Both must carry the same headers; testing both individually is the
  28. # only guard against one being added later without the other.
  29. HTML_ROUTES = [
  30. pytest.param("/", id="root"),
  31. # Catch-all routes a path like /spoolbuddy/ to index.html. The trailing
  32. # slash matters — without it FastAPI redirects, which would skip the
  33. # cache-control middleware. Tested as a real-world client URL.
  34. pytest.param("/spoolbuddy/", id="spa-catchall-spoolbuddy"),
  35. pytest.param("/printers", id="spa-catchall-printers"),
  36. ]
  37. @pytest.fixture
  38. def fake_static_index(monkeypatch, tmp_path):
  39. """Provide a minimal ``static/index.html`` so the route handlers don't
  40. fall through to their "frontend not built" JSON branch.
  41. The ``backend-test`` Dockerfile.test target intentionally doesn't bake
  42. in the built frontend (saves ~30 s of build time per test run), and
  43. contributors running ``pytest backend/tests/`` from a checkout without
  44. a prior ``npm run build`` would also miss it. The test asserts the
  45. cache-header contract on the index.html serve path, not the bundle
  46. contents — so a one-line stub is enough to exercise the real route
  47. handlers in ``main.py:serve_frontend`` / ``main.py:serve_spa`` against
  48. a real ``index.html`` on disk.
  49. """
  50. from backend.app import main as main_mod
  51. from backend.app.core import config as config_mod
  52. static_dir = tmp_path / "static"
  53. static_dir.mkdir()
  54. (static_dir / "index.html").write_text("<!doctype html><title>stub</title>")
  55. monkeypatch.setattr(config_mod.settings, "static_dir", static_dir)
  56. # main.py imports `settings as app_settings`, so the route handlers
  57. # resolve `app_settings.static_dir` per request. Patch the import-site
  58. # binding too in case future refactors stop sharing the singleton.
  59. monkeypatch.setattr(main_mod.app_settings, "static_dir", static_dir)
  60. return static_dir
  61. @pytest.mark.asyncio
  62. @pytest.mark.parametrize(("path",), HTML_ROUTES)
  63. async def test_index_html_emits_no_cache_directive(async_client: AsyncClient, fake_static_index, path: str):
  64. """Every index.html serve must emit ``Cache-Control: no-cache,
  65. must-revalidate`` — kiosks rely on this to pick up new builds without
  66. operator intervention."""
  67. response = await async_client.get(path)
  68. # Both serve routes should return 200 with HTML content type.
  69. assert response.status_code == 200, f"Expected 200 for {path}, got {response.status_code}: {response.text[:200]}"
  70. assert response.headers.get("content-type", "").startswith("text/html"), (
  71. f"{path} returned non-HTML content-type: {response.headers.get('content-type')}"
  72. )
  73. # The Cache-Control header is the actual contract under test.
  74. cache_control = response.headers.get("cache-control", "")
  75. assert "no-cache" in cache_control, (
  76. f"{path} missing 'no-cache' in Cache-Control header (got: {cache_control!r}). "
  77. f"Without this kiosks serve stale HTML across browser restarts and never "
  78. f"pick up new builds."
  79. )
  80. assert "must-revalidate" in cache_control, (
  81. f"{path} missing 'must-revalidate' in Cache-Control header (got: {cache_control!r}). "
  82. f"This belt-and-braces directive prevents stale-while-revalidate-style "
  83. f"intermediaries from serving cached HTML even when it's expired."
  84. )
  85. @pytest.mark.asyncio
  86. async def test_api_routes_unaffected_by_html_cache_headers(async_client: AsyncClient):
  87. """Defensive: the cache-control directive must NOT leak onto API
  88. responses. API responses set their own headers (or none at all) per
  89. endpoint; a global ``no-cache`` would silently disable the React
  90. Query cache wins we depend on for snappy UI updates."""
  91. response = await async_client.get("/api/v1/printers")
  92. # We don't care about success/failure here — just that no cache
  93. # directive was inherited from the HTML serve path. (The endpoint
  94. # itself may 401/403 depending on auth state in the test fixture
  95. # which is fine; what matters is the response shape.)
  96. cache_control = response.headers.get("cache-control", "")
  97. assert "no-cache" not in cache_control or "private" in cache_control, (
  98. f"API route /api/v1/printers leaked HTML cache-control: {cache_control!r}. "
  99. f"If a 'no-cache' directive is intentional on an API endpoint it should be "
  100. f"set per-route, not inherited from the SPA HTML path."
  101. )