The P0 templates loaded Alpine.js from cdn.jsdelivr.net and Tailwind from cdn.tailwindcss.com, so LAN-only demo clients got a login page whose JS never engaged (stuck form). Vendor both libraries under app/static/vendor/ (alpine-3.17.2.min.js, tailwind-3.4.17.js) served same-origin at /static, point base.html at local paths, and drop both CDN hosts from the CSP (script-src/style-src stay 'self' 'unsafe-inline'; connect-src 'self'). HTML pages now ship Cache-Control: no-cache; vendored assets are cached public, max-age=31536000, immutable (versioned filenames). HSTS stays TLS-gated. Adds tests/test_frontend_vendoring.py (no external script src on /login, both vendor paths 200, no-cache + immutable header checks, CSP without CDN hosts).
75 lines
3.0 KiB
Python
75 lines
3.0 KiB
Python
"""Frontend must be fully self-contained — no CDN (LAN page-freeze regression).
|
|
|
|
The P0 batch's templates loaded Alpine.js from ``cdn.jsdelivr.net`` and Tailwind
|
|
from ``cdn.tailwindcss.com``, so any demo client that cannot reach those CDNs
|
|
(LAN-only devices, filtered networks) got a login page whose JS never engaged
|
|
(a stuck form). Both libraries are now vendored under ``app/static/vendor/``
|
|
and served same-origin with no external ``script src`` in the HTML. HTML pages
|
|
always revalidate (``Cache-Control: no-cache``); the vendored assets carry
|
|
long-lived immutable caching (their URLs embed the version).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
# Any <script … src="//host/…"> or src="https?://host/…"> — i.e. NOT same-origin.
|
|
_EXTERNAL_SRC = re.compile(r"""<script\b[^>]*\bsrc\s*=\s*["'](?:https?:)?//[^"']+["']""")
|
|
|
|
VENDORED_SCRIPTS = (
|
|
"/static/vendor/alpine-3.17.2.min.js",
|
|
"/static/vendor/tailwind-3.4.17.js",
|
|
)
|
|
|
|
|
|
async def test_login_page_has_no_external_script_srcs(client: AsyncClient):
|
|
"""/login must reference only same-origin scripts — regex over the body."""
|
|
resp = await client.get("/login")
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.text
|
|
external = _EXTERNAL_SRC.findall(body)
|
|
assert not external, f"external script srcs found: {external}"
|
|
for src in VENDORED_SCRIPTS:
|
|
assert src in body, f"missing vendored script {src} in /login HTML"
|
|
|
|
|
|
async def test_vendor_assets_served_same_origin(client: AsyncClient):
|
|
"""Both vendored libraries must resolve locally with real JS content."""
|
|
for src in VENDORED_SCRIPTS:
|
|
resp = await client.get(src)
|
|
assert resp.status_code == 200, f"{src} -> {resp.status_code}"
|
|
assert len(resp.content) > 1000, f"{src} looks empty ({len(resp.content)} bytes)"
|
|
|
|
|
|
async def test_html_pages_are_not_cached(client: AsyncClient):
|
|
"""HTML page responses must always revalidate (Cache-Control: no-cache)."""
|
|
resp = await client.get("/login")
|
|
assert resp.status_code == 200
|
|
assert resp.headers["cache-control"] == "no-cache"
|
|
|
|
|
|
async def test_vendor_assets_cached_immutable(client: AsyncClient):
|
|
"""Versioned vendor assets must carry long-lived immutable caching."""
|
|
for src in VENDORED_SCRIPTS:
|
|
resp = await client.get(src)
|
|
assert resp.status_code == 200
|
|
cc = resp.headers.get("cache-control", "")
|
|
assert "max-age=31536000" in cc and "immutable" in cc, f"{src}: {cc!r}"
|
|
|
|
|
|
async def test_csp_no_longer_allows_cdn_hosts(client: AsyncClient):
|
|
"""CSP must be 'self'-only for scripts/styles; connect-src stays 'self'."""
|
|
resp = await client.get("/login")
|
|
assert resp.status_code == 200
|
|
csp = resp.headers["content-security-policy"]
|
|
for host in ("cdn.jsdelivr.net", "cdn.tailwindcss.com"):
|
|
assert host not in csp, f"CSP still allows {host}"
|
|
assert "script-src 'self' 'unsafe-inline'" in csp
|
|
assert "style-src 'self' 'unsafe-inline'" in csp
|
|
assert "connect-src 'self'" in csp
|