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:
+44
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user