Files
denya-onecare/tests/test_frontend_vendoring.py
root 40f1c0ecf6 fix(csp): add 'unsafe-eval' to script-src so Alpine.js initializes
Alpine 3.17.2's CDN build compiles every x-data/x-show/x-text expression
with new Function(), which the strict P0 CSP (script-src 'self'
'unsafe-inline') blocked. Every Alpine directive threw "Evaluating a
string as JavaScript violates ... 'unsafe-eval' is not an allowed
source", Alpine never initialized, and the loading overlay
(x-show="loading" in base.html) stayed visible forever on /login and
every Alpine-driven page.

Add 'unsafe-eval' to script-src (Alpine's documented CSP requirement for
its runtime); everything else in the header is unchanged. Regression test
asserts the /login CSP header carries 'unsafe-eval' inside script-src.

Verified live: headless chromium (playwright build 1243) shows zero
CSP/eval console errors after the fix, with Alpine applying
style="display:none" to the loading overlay; the pre-fix header produces
the Alpine Expression Error spam and leaves the overlay visible.
2026-09-09 15:45:52 +00:00

105 lines
4.4 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
def _directive_sources(csp: str, directive: str) -> list[str]:
"""Return the source list of one CSP directive (e.g. ``script-src``)."""
for part in csp.split(";"):
tokens = part.split()
if tokens and tokens[0].strip() == directive:
return [t.strip() for t in tokens[1:]]
return []
async def test_csp_script_src_allows_unsafe_eval_for_alpine(client: AsyncClient):
"""/login CSP must permit 'unsafe-eval' in script-src (Alpine 3.17.2 runtime).
Alpine's expression evaluator compiles every ``x-data``/``x-show``/``x-text``
expression with ``new Function()``. A strict CSP without ``'unsafe-eval'``
blocks each evaluation ("Refused to evaluate a string as JavaScript ..."),
Alpine never initializes, and the loading overlay (``x-show="loading"`` in
base.html) stays visible forever — regression shipped with the P0 CSP.
"""
resp = await client.get("/login")
assert resp.status_code == 200
csp = resp.headers["content-security-policy"]
script_sources = _directive_sources(csp, "script-src")
assert script_sources, f"no script-src directive in CSP: {csp}"
assert "'unsafe-eval'" in script_sources, f"script-src missing 'unsafe-eval': {csp}"
# Everything else stays as hardened: still 'self'-only apart from the two
# Alpine-required relaxations, and connect-src remains 'self'.
assert "'self'" in script_sources
assert "connect-src 'self'" in csp