Files
turnstone/tests/test_web_helpers.py
2026-08-12 19:00:31 -07:00

344 lines
14 KiB
Python

"""Tests for turnstone.core.web_helpers — version_html() cache-busting."""
from __future__ import annotations
import os
import pytest
class TestVersionHtml:
def test_app_css_gets_version(self):
from turnstone.core.web_helpers import version_html
html = '<link rel="stylesheet" href="/shared/base.css">'
result = version_html(html)
assert "?v=" in result
assert "/shared/base.css?v=" in result
def test_app_js_gets_version(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/static/app.js"></script>'
result = version_html(html)
assert "/static/app.js?v=" in result
def test_shared_js_gets_version(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/shared/utils.js"></script>'
result = version_html(html)
assert "/shared/utils.js?v=" in result
def test_vendored_katex_skipped(self):
from turnstone.core.web_helpers import version_html
html = '<link rel="stylesheet" href="/shared/katex-0.18.4/katex.min.css">'
result = version_html(html)
assert result == html # unchanged
def test_vendored_hljs_skipped(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/shared/hljs-11.11.1/highlight.min.js"></script>'
result = version_html(html)
assert result == html # unchanged
def test_vendored_mermaid_skipped(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/shared/mermaid-11.16.1/mermaid.min.js"></script>'
result = version_html(html)
assert result == html # unchanged
def test_vendored_hls_skipped(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/shared/hls-1.6.17/hls.min.js"></script>'
result = version_html(html)
assert result == html # unchanged
def test_vendor_prefix_lookalike_is_still_versioned(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/shared/hls-2-player.js"></script>'
result = version_html(html)
assert "/shared/hls-2-player.js?v=" in result
def test_external_urls_not_modified(self):
from turnstone.core.web_helpers import version_html
html = (
'<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono" rel="stylesheet">'
)
result = version_html(html)
assert result == html # unchanged
def test_docs_link_not_modified(self):
from turnstone.core.web_helpers import version_html
html = '<a href="/docs#/System:%20Settings" target="_blank">docs</a>'
result = version_html(html)
assert result == html # unchanged
def test_multiple_tags(self):
from turnstone import __version__
from turnstone.core.web_helpers import version_html
html = (
'<link rel="stylesheet" href="/shared/base.css">\n'
'<link rel="stylesheet" href="/shared/katex-0.18.4/katex.min.css">\n'
'<link rel="stylesheet" href="/static/style.css">\n'
'<script src="/shared/utils.js"></script>\n'
'<script src="/shared/hljs-11.11.1/highlight.min.js"></script>\n'
'<script src="/static/app.js"></script>'
)
result = version_html(html)
assert f'/shared/base.css?v={__version__}"' in result
assert f'/static/style.css?v={__version__}"' in result
assert f'/shared/utils.js?v={__version__}"' in result
assert f'/static/app.js?v={__version__}"' in result
# Vendored libs unchanged
assert '/shared/katex-0.18.4/katex.min.css"' in result
assert '/shared/hljs-11.11.1/highlight.min.js"' in result
def test_version_matches_package(self):
from turnstone import __version__
from turnstone.core.web_helpers import version_html
html = '<script src="/static/app.js"></script>'
result = version_html(html)
assert f"?v={__version__}" in result
def test_double_apply_is_idempotent(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/static/app.js"></script>'
once = version_html(html)
twice = version_html(once)
assert once == twice
assert twice.count("?v=") == 1
def test_existing_query_string_preserved(self):
from turnstone.core.web_helpers import version_html
html = '<script src="/static/app.js?foo=bar"></script>'
result = version_html(html)
assert result == html # unchanged — already has query string
class TestRevalidatingStaticFiles:
def test_same_versioned_url_revalidates_after_asset_changes(self, tmp_path):
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.testclient import TestClient
from turnstone import __version__
from turnstone.core.web_helpers import RevalidatingStaticFiles
asset = tmp_path / "app.js"
old_body = b"export const generation = 'old';"
new_body = b"export const generation = 'new';"
assert len(old_body) == len(new_body)
asset.write_bytes(old_body)
original_stat = asset.stat()
app = Starlette(
routes=[Mount("/static", app=RevalidatingStaticFiles(directory=str(tmp_path)))]
)
with TestClient(app) as client:
url = f"/static/app.js?v={__version__}"
first = client.get(url)
assert first.status_code == 200
assert first.headers["cache-control"] == "no-cache"
assert "last-modified" not in first.headers
old_etag = first.headers["etag"]
asset.write_bytes(new_body)
os.utime(
asset,
ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns),
)
changed_stat = asset.stat()
assert changed_stat.st_size == original_stat.st_size
assert changed_stat.st_mtime_ns == original_stat.st_mtime_ns
changed = client.get(url, headers={"If-None-Match": old_etag})
assert changed.status_code == 200
assert changed.content == new_body
assert changed.headers["etag"] != old_etag
assert changed.headers["cache-control"] == "no-cache"
assert "last-modified" not in changed.headers
stale_date_only = client.get(
url,
headers={"If-Modified-Since": "Wed, 31 Dec 9999 23:59:59 GMT"},
)
assert stale_date_only.status_code == 200
assert stale_date_only.content == new_body
unchanged = client.get(url, headers={"If-None-Match": changed.headers["etag"]})
assert unchanged.status_code == 304
assert unchanged.headers["cache-control"] == "no-cache"
def test_version_named_vendor_asset_is_immutable(self, tmp_path):
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.testclient import TestClient
from turnstone.core.web_helpers import RevalidatingStaticFiles
vendor_dir = tmp_path / "katex-0.18.4"
vendor_dir.mkdir()
(vendor_dir / "katex.min.css").write_text(".katex {}", encoding="utf-8")
app = Starlette(
routes=[Mount("/shared", app=RevalidatingStaticFiles(directory=str(tmp_path)))]
)
with TestClient(app) as client:
resp = client.get("/shared/katex-0.18.4/katex.min.css")
assert resp.status_code == 200
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
assert resp.headers["etag"]
def test_missing_asset_is_not_cached(self, tmp_path):
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.testclient import TestClient
from turnstone.core.web_helpers import RevalidatingStaticFiles
app = Starlette(
routes=[Mount("/shared", app=RevalidatingStaticFiles(directory=str(tmp_path)))]
)
with TestClient(app) as client:
resp = client.get("/shared/not-deployed-yet.js")
assert resp.status_code == 404
assert resp.headers["cache-control"] == "no-store"
class TestStaticAssetCacheControl:
@pytest.mark.parametrize(
("path", "expected"),
[
("interactive.js", "no-cache"),
("hls-2-player.js", "no-cache"),
("katex-0.18.4/katex.min.css", "public, max-age=31536000, immutable"),
("hljs-11.11.1/highlight.min.js", "public, max-age=31536000, immutable"),
("katex-0.18.4/../private.json", "no-store"),
(r"katex-0.18.4\..\private.json", "no-store"),
("nested//asset.js", "no-store"),
],
)
def test_policy_requires_a_canonical_exact_vendor_path(self, path, expected):
from turnstone.core.web_helpers import static_asset_cache_control
assert static_asset_cache_control(path) == expected
def test_every_packaged_versioned_vendor_directory_uses_the_shared_policy(self):
import re
from pathlib import Path
import turnstone
from turnstone.core.web_helpers import static_asset_cache_control, version_html
shared_dir = Path(turnstone.__file__).resolve().parent / "shared_static"
versioned_dir = re.compile(r"^[a-z][a-z0-9_-]*-\d+(?:\.\d+)+$")
vendor_dirs = sorted(
path.name
for path in shared_dir.iterdir()
if path.is_dir() and versioned_dir.fullmatch(path.name)
)
assert vendor_dirs
for directory in vendor_dirs:
asset_path = f"{directory}/asset.js"
assert static_asset_cache_control(asset_path) == "public, max-age=31536000, immutable"
html = f'<script src="/shared/{asset_path}"></script>'
assert version_html(html) == html
class TestLatin1SafeFilename:
"""Content-Disposition filename sanitizer — must yield a value that is
both latin-1 encodable (Starlette) and control-char free (h11)."""
def _assert_wire_safe(self, out: str) -> None:
# Independent oracle — deliberately does NOT reuse the impl's
# isprintable() gate (that would pass by construction). Every char
# must be printable ASCII (0x20..0x7e) and neither quoted-string
# metacharacter, so the value is latin-1 clean, control-free, and
# safely quotable.
assert all(0x20 <= ord(c) <= 0x7E and c not in '"\\' for c in out)
def test_plain_ascii_unchanged(self):
from turnstone.core.web_helpers import latin1_safe_filename
assert latin1_safe_filename("report_2026.md") == "report_2026.md"
def test_non_latin1_folds_to_question_marks(self):
from turnstone.core.web_helpers import latin1_safe_filename
# CJK + em dash (U+2014) are printable but non-latin-1 → fold to '?'.
out = latin1_safe_filename("文書 — v1.md")
assert out == "?? ? v1.md"
self._assert_wire_safe(out)
def test_latin1_but_control_chars_are_stripped(self):
from turnstone.core.web_helpers import latin1_safe_filename
# All latin-1 encodable, so the old strip/fold left them in the header
# and the HTTP server layer then 500'd (h11 rejects NUL/CR/LF/FF/VT;
# httptools is stricter). NUL / form-feed / DEL / TAB / VT / C1-NEL
# (0x85) must all be dropped, not merely folded.
out = latin1_safe_filename("a\x00b\x0cc\x7fd\te\x0bf\x85g.md")
assert out == "abcdefg.md"
self._assert_wire_safe(out)
def test_crlf_and_quote_stripped(self):
from turnstone.core.web_helpers import latin1_safe_filename
out = latin1_safe_filename('a"\r\nX-Evil: 1.md')
assert "\r" not in out and "\n" not in out and '"' not in out
self._assert_wire_safe(out)
def test_backslash_stripped(self):
from turnstone.core.web_helpers import latin1_safe_filename
# Backslash is the RFC 6266 quoted-pair escape inside filename="..." —
# a trailing '\' would escape the closing quote, and '\x' mid-name
# becomes a spurious escape. Both must be dropped (Windows-origin
# uploads legitimately carry '\').
assert latin1_safe_filename("dir\\file.md") == "dirfile.md"
assert latin1_safe_filename("trailing\\") == "trailing"
self._assert_wire_safe(latin1_safe_filename("a\\b\\c"))
def test_empty_after_sanitizing_uses_fallback(self):
from turnstone.core.web_helpers import latin1_safe_filename
# A name of only quotes / controls sanitizes to empty → fallback,
# never ``filename=""``.
assert latin1_safe_filename('"""') == "attachment"
assert latin1_safe_filename("\x00\x0c\x7f") == "attachment"
assert latin1_safe_filename("", fallback="preview") == "preview"
def test_all_non_latin1_stays_non_empty_no_fallback(self):
from turnstone.core.web_helpers import latin1_safe_filename
# An all-CJK name folds to '???' (truthy) — must NOT hit the fallback.
assert latin1_safe_filename("日本語", fallback="preview") == "???"
def test_fallback_is_also_sanitized(self):
from turnstone.core.web_helpers import latin1_safe_filename
# The fallback fires only when the name sanitizes to empty, and it is
# cleaned by the SAME rules — a caller can't reintroduce the crash /
# corruption through an unsafe fallback.
assert latin1_safe_filename("", fallback="—\x00.txt") == "?.txt"
self._assert_wire_safe(latin1_safe_filename("", fallback="bad\\\x00name"))
# If even the fallback sanitizes to empty, a safe constant backs it —
# never filename="".
assert latin1_safe_filename("", fallback='"\x00') == "download"