fix(admin): ship templates in wheel, fix CSP and bootstrap login
Test / test (push) Successful in 1m1s

This commit is contained in:
2026-07-27 10:27:41 +02:00
parent 98f7f3c9e3
commit 38544b84cf
15 changed files with 361 additions and 41 deletions
+111
View File
@@ -166,6 +166,117 @@ class TestSecurityHeaders:
assert "'unsafe-inline'" not in csp
assert "nonce-" in csp
def test_admin_csp_has_no_duplicate_directives(self, admin_client: TestClient) -> None:
# Regression: nonce-augmented script-src / style-src used to be
# appended after the base ones, so the same directive name appeared
# twice. Browsers use the FIRST occurrence and silently drop the
# nonce, breaking all admin JS. Verify each directive is unique.
response = admin_client.get("/admin/login")
csp = response.headers.get("content-security-policy", "")
directives = [d.strip().split(maxsplit=1)[0] for d in csp.split(";")]
assert len(directives) == len(set(directives)), (
f"Duplicate CSP directives: {[d for d in directives if directives.count(d) > 1]}"
)
# And specifically: only one script-src, only one style-src.
assert directives.count("script-src") == 1
assert directives.count("style-src") == 1
def test_admin_csp_nonce_appears_in_rendered_html(self, admin_client: TestClient) -> None:
# Regression: nonce used to be set AFTER call_next, so templates
# rendered with an empty csp_nonce and the matching <script nonce="...">
# tag in the response never carried the nonce that CSP demanded.
# Browsers then blocked every inline script. Verify the nonce in
# the response header matches the nonce on the <script> tags.
import re
response = admin_client.get("/admin/login")
csp = response.headers.get("content-security-policy", "")
match = re.search(r"nonce-([A-Za-z0-9_-]+)", csp)
assert match is not None, "No nonce in CSP"
nonce = match.group(1)
# At least one inline <script> must carry this exact nonce.
script_tags = re.findall(r"<script[^>]*>", response.text)
assert script_tags, "No <script> tags rendered"
assert any(f'nonce="{nonce}"' in tag for tag in script_tags), (
"Rendered <script> tags do not carry the CSP nonce:\n" + "\n".join(script_tags)
)
def test_admin_pages_have_no_blocking_inline_styles(self, admin_client: TestClient) -> None:
# Regression: with `style-src 'self' 'nonce-...'`, any HTML element
# carrying `style="..."` is blocked by the browser — including
# style attributes that do *not* start with `--` (CSS custom
# properties, which are explicitly allowed by CSP3). Walk every
# admin page and assert no offending attribute survives.
import re
def _offending(body: str) -> list[str]:
results: list[str] = []
for match in re.finditer(r"\sstyle=\"([^\"]*)\"", body):
value = match.group(1).strip()
# `style="--foo: ..."` is a CSS custom-property declaration
# and is allowed by CSP3 without `unsafe-inline`. Anything
# else is a style application and must move into a class.
if not value.startswith("--"):
results.append(match.group(0))
return results
for path in ("/admin/login", "/admin/", "/admin/settings"):
response = admin_client.get(path)
assert response.status_code == 200, f"{path} returned {response.status_code}"
offending = _offending(response.text)
assert not offending, (
f"{path} renders {len(offending)} non-custom-property inline "
f"style attribute(s) that strict CSP will block:\n" + "\n".join(offending)
)
# Also check the post form (requires login) — it had several
# one-off utility styles that should now be classes.
from tests.test_admin import _login
_login(admin_client)
response = admin_client.get("/admin/posts/new")
assert response.status_code == 200
offending = _offending(response.text)
assert not offending, (
"/admin/posts/new renders inline style attributes that strict "
"CSP will block:\n" + "\n".join(offending)
)
def test_favicon_ico_serves_svg(self, client: TestClient) -> None:
# Regression: browsers auto-request /favicon.ico even when the page
# declares an explicit <link rel="icon">, and the previous behaviour
# was a 404. Serve the packaged icon SVG so the console is clean
# and the tab gets a proper icon.
response = client.get("/favicon.ico")
assert response.status_code == 200
assert response.headers["content-type"].startswith("image/svg+xml")
body = response.content
assert body.startswith(b"<") and b"</svg>" in body
# And the admin login page must declare the icon explicitly.
login = client.get("/admin/login")
assert login.status_code == 200
assert 'rel="icon"' in login.text
assert 'href="/favicon.ico"' in login.text
def test_admin_layout_renders_version(self, admin_client: TestClient) -> None:
# Regression: `version` was not in admin_context, so the sidebar
# footer rendered as just "v" with no number behind it.
# The sidebar footer only renders on the authenticated layout,
# so we log in first.
from tests.test_admin import _login
from volumen.version import VERSION
_login(admin_client)
response = admin_client.get("/admin/")
assert response.status_code == 200
assert "sidebar-version" in response.text
# The version label is "v{version}" inside a div.sidebar-version.
# Refuse the bare "v</div>" rendering that this bug produced.
assert "v" + VERSION in response.text, (
f"Expected the sidebar version label to include {VERSION}; "
f"admin_context is not propagating the package version"
)
def test_https_only_cookie_unset_when_untrusted(self, client: TestClient) -> None:
# Default trust_proxy=false — X-Forwarded-Proto is ignored.
response = client.get("/admin/login")