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
+44 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import re
from pathlib import Path
@@ -27,6 +28,29 @@ class TestAdminLogin:
assert response.status_code == 200
assert "Sign in" in response.text
def test_login_page_omits_warning_when_users_exist(self, admin_client: TestClient) -> None:
# Regression: `users_exist` was missing from the admin template
# context, so the "No users configured" warning was shown on every
# login page render regardless of whether authentication worked.
response = admin_client.get("/admin/login")
assert response.status_code == 200
assert "No users configured" not in response.text
def test_login_page_shows_warning_when_no_users(self, config, store) -> None:
# No bootstrap_hash, no users.toml → `users_exist` must be False
# so the warning block is rendered.
from volumen.app import create_app
users_path = config.users_file
# Make sure no stale users file exists.
if os.path.exists(users_path):
os.remove(users_path)
app = create_app(config, store)
client = TestClient(app)
response = client.get("/admin/login")
assert response.status_code == 200
assert "No users configured" in response.text
def test_login_with_valid_credentials(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/login")
token = _csrf_from_body(resp.text)
@@ -424,13 +448,31 @@ class TestOrphanedSession:
def test_orphaned_session_is_rejected(
self, admin_client: TestClient, admin_config_dir: Path
) -> None:
import tomli_w
from volumen.password import hash_password as hash_pw
_login(admin_client)
resp = admin_client.get("/admin/")
assert resp.status_code == 200
# Clear users file to simulate deleted user
# Replace users file with a different admin so the logged-in
# 'admin' is truly orphaned. An empty file would resurrect the
# bootstrap admin via the password_hash fallback, so we must
# write a real users list that does not include 'admin'.
users_path = admin_config_dir / "users.toml"
users_path.write_text("")
payload = tomli_w.dumps(
{
"users": [
{
"username": "other",
"password_hash": hash_pw("other-password"),
"role": "admin",
}
]
}
).encode("utf-8")
users_path.write_bytes(payload)
resp = admin_client.get("/admin/", follow_redirects=False)
assert resp.status_code in (302, 307, 303)
+73
View File
@@ -2,6 +2,14 @@
from __future__ import annotations
import shutil
import subprocess
import tomllib
import zipfile
from pathlib import Path
import pytest
from volumen.admin_auth_routes import router as auth_router
from volumen.admin_media_routes import router as media_router
from volumen.admin_post_routes import router as post_router
@@ -21,3 +29,68 @@ def test_split_modules_import_and_construct_app(config, store) -> None:
assert app.state.config is config
assert app.state.store is store
# ---------------------------------------------------------------------------
# Distribution wheel — make sure admin assets are packaged.
# ---------------------------------------------------------------------------
REPO_ROOT = Path(__file__).resolve().parent.parent
def test_pyproject_declares_web_package_data() -> None:
"""Regression: admin panel returned 500 in production because Jinja
templates and SVG icons were not declared as package data and the
wheel dropped them. Catch accidental removal in pyproject.toml."""
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text())
package_data = (
data.get("tool", {}).get("setuptools", {}).get("package-data", {}).get("volumen", [])
)
assert any("web/templates" in p and "*.jinja" in p for p in package_data), (
f"volumen package-data must include web/templates/*.jinja, got: {package_data}"
)
assert any("web/static" in p for p in package_data), (
f"volumen package-data must include web/static/*.svg, got: {package_data}"
)
def test_built_wheel_contains_web_templates_and_static(tmp_path: Path) -> None:
"""Stronger version of the regression: build a wheel with ``uv build``
and assert the admin assets are inside the resulting archive. Skipped
when ``uv`` is not on PATH (e.g. minimal CI image)."""
uv = shutil.which("uv")
if uv is None:
pytest.skip("uv not on PATH — cannot build the wheel")
out_dir = tmp_path / "wheels"
out_dir.mkdir()
result = subprocess.run(
[uv, "build", "--wheel", "--out-dir", str(out_dir)],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
)
assert result.returncode == 0, (
f"uv build failed (exit {result.returncode}):\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
wheels = sorted(out_dir.glob("*.whl"))
assert len(wheels) == 1, f"Expected exactly one wheel, got {wheels}"
with zipfile.ZipFile(wheels[0]) as zf:
names = zf.namelist()
templates = [n for n in names if n.startswith("volumen/web/templates/")]
statics = [n for n in names if n.startswith("volumen/web/static/")]
assert templates, (
f"web/templates missing from wheel {wheels[0].name}; "
f"admin panel would 500 on first request. Members: {names}"
)
assert statics, (
f"web/static missing from wheel {wheels[0].name}; "
f"admin panel icons would 404. Members: {names}"
)
assert any(n.endswith(".jinja") for n in templates), templates
assert any(n.endswith(".svg") for n in statics), statics
+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")
+28
View File
@@ -23,6 +23,34 @@ class TestBootstrap:
users = Users(path=users_path)
assert not users.any
def test_bootstrap_used_when_users_file_is_empty(
self, users_path: str, bootstrap_hash: str
) -> None:
# Regression: `volumen init` calls `users_file.touch(exist_ok=True)`
# which leaves a zero-byte users.toml. The bootstrap admin must
# still be usable so the very first login after `volumen init`
# works in production.
with open(users_path, "w") as fh:
fh.write("")
users = Users(path=users_path, bootstrap_hash=bootstrap_hash)
assert users.any
assert users.authenticate("admin", "volumen-test-password") is not None
def test_bootstrap_ignored_when_users_present(
self, users_path: str, bootstrap_hash: str
) -> None:
# Bootstrap must NOT shadow a populated users file — otherwise the
# configured `admin` user could log in with the bootstrap password
# even after their stored hash was rotated.
writer = Users(path=users_path)
writer.add("alice", "alice-test-password")
writer.add("bob", "bob-test-password")
reader = Users(path=users_path, bootstrap_hash=bootstrap_hash)
usernames = {u.username for u in reader.all}
# Only alice + bob from disk; no synthetic admin from bootstrap.
assert usernames == {"alice", "bob"}
class TestFind:
def test_find_existing_user(self, users: Users) -> None: