fix(admin): ship templates in wheel, fix CSP and bootstrap login
Test / test (push) Successful in 1m1s
Test / test (push) Successful in 1m1s
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user