chore: merge development into main (v0.4.2)
Release / release (push) Successful in 1m11s

This commit is contained in:
2026-07-27 10:37:47 +02:00
15 changed files with 364 additions and 42 deletions
BIN
View File
Binary file not shown.
+9
View File
@@ -8,9 +8,18 @@ __pycache__/
.ruff_cache/
dist/
# setuptools / uv build output
/build/
/src/*.egg-info/
# Test / coverage
/coverage/
/tmp/
/.coverage
/.coverage.*
# Transient log files (pytest, dev servers, scratch)
/*.log
# mkdocs / Sphinx build output
/site/
+16 -7
View File
@@ -10,12 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [development]
## [0.4.2] — 2026-07-27
### Fixed
- **`web/templates/*.jinja` missing from distribution wheel** — added `web/templates/*.jinja` to `[tool.setuptools.package-data]` in `pyproject.toml`. After a fresh install from PyPI the admin panel returned 500 because `Jinja2Templates` could not find any templates.
- **Duplicate `script-src` / `style-src` in admin CSP** — the nonce-augmented directives used to be *appended* after the base ones, so the browser (which honours the first occurrence) ignored the nonce and blocked every inline JS/CSS. They are now *replaced*: `script-src` and `style-src` are filtered out of the base CSP and the nonce versions are appended as the only occurrence.
- **CSP nonce generated after template rendering** — `request.state.csp_nonce` is now set in `GlobalSecurityHeadersMiddleware.dispatch()` *before* `call_next(request)` so templates see it during rendering. Previously it was generated afterwards, so `csp_nonce` was an empty string in the template context and every inline script was blocked.
- **Missing `nonce` attribute on `<script>` in `list.html.jinja` and `settings.html.jinja`** — inline scripts in these templates now carry `nonce="{{ csp_nonce | default('') }}"` so they match the nonce in the CSP header.
- **Bootstrap `admin.password_hash` ignored when `users.toml` is empty** — `Users.all` now falls back to the bootstrap admin even when `users.toml` exists but is empty (e.g. right after `volumen init`, which creates the file via `Path.touch()`). Previously the first login after a clean install failed.
- **`admin_context` did not pass `users_exist`** — the login page showed the “No users configured” warning even when authentication worked. Added `"users_exist": users_obj.any` to the admin template context.
- **Inline `style="..."` blocked by strict admin CSP** — the admin templates used inline `style="..."` attributes (and one `card.style.display = ...` JS assignment) which the new strict `style-src 'self' 'nonce-…'` directive blocks. Replaced every offending attribute with a utility class in the `<style>` block (`page-head-actions`, `form-options`, `form-inline`, `form-hidden`, `form-spaced`, `flex-spacer`, `btn-static`, `btn-danger-text`, `text-fg-strong`, `badge-inline`, `h3-section`, `mt-3`, `is-hidden`); the dynamic `background-image: url(...)` on `.post-cover` now flows through a `--cover-url` custom property (`style="--cover-url: …"`) which CSP3 does not treat as an inline style application.
- **`/favicon.ico` returned 404** — browsers auto-request `/favicon.ico` even when the page declares an explicit `<link rel="icon">`. Added a tiny route in `create_app()` that serves the packaged `web/static/volumen-icon.svg` with `Content-Type: image/svg+xml` and a 1-day `Cache-Control`; added the matching `<link rel="icon" type="image/svg+xml" href="/favicon.ico">` to the admin layout so the tab gets the volumen icon instead of a generic placeholder.
- **Sidebar version label rendered as bare `v`** — `admin_context` did not include the package `VERSION`, so the footer `<div class="sidebar-version">v{{ version }}</div>` rendered as just `v` with no number behind it. Added `"version": VERSION` to the context.
## [0.4.1] — 2026-07-27
### Fixed
- **CSP nonce chybí pro `style-src` v admin routách** — přidán `style-src 'self' 'nonce-{nonce}'` do CSP hlavičky pro `/admin` routy. Bez nonce prohlížeč blokoval všechny inline `<style>` tagy v admin šablonách.
- **`web/static/` SVG soubory chybí v distribučním kole** — přidána sekce `[tool.setuptools.package-data]` do `pyproject.toml`, aby `volumen-icon.svg` a `volumen-logo.svg` byly součástí instalovaného wheelu. Admin panel dříve vracel 500 na `/admin/icon.svg` a `/admin/logo.svg`.
- **Missing CSP nonce for `style-src` on admin routes** — added `style-src 'self' 'nonce-{nonce}'` to the CSP header on `/admin` routes. Without a nonce the browser blocked every inline `<style>` tag in the admin templates.
- **`web/static/` SVG assets missing from distribution wheel** — added a `[tool.setuptools.package-data]` section to `pyproject.toml` so `volumen-icon.svg` and `volumen-logo.svg` ship inside the installed wheel. The admin panel previously returned 500 on `/admin/icon.svg` and `/admin/logo.svg`.
## [0.4.0] — 2026-07-26
@@ -387,8 +401,3 @@ admin — no database, no external services.
production.
- Configurable, stable `session_key` so sessions survive restarts.
- Generic login error message to avoid username enumeration.
[0.1.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.1.0
[0.2.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.2.0
[0.3.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.3.0
[0.4.0]: https://sourcedock.dev/petrbalvin/volumen/releases/tag/v0.4.0
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "volumen"
version = "0.4.1"
version = "0.4.2"
description = "A small, file-based Markdown blog engine with a built-in admin."
readme = "README.md"
keywords = ["blog", "markdown", "fastapi", "cms", "static-blog", "toml", "rss", "json-feed", "engine"]
@@ -62,7 +62,7 @@ dev = [
where = ["src"]
[tool.setuptools.package-data]
volumen = ["web/static/*"]
volumen = ["web/templates/*.jinja", "web/static/*.svg"]
[tool.ruff]
+30 -5
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.base import BaseHTTPMiddleware
@@ -84,6 +85,19 @@ def create_app(config: Config, store: Store) -> FastAPI:
if _STATIC_DIR.is_dir():
app.mount("/admin/static", StaticFiles(directory=str(_STATIC_DIR)), name="admin_static")
# Browsers auto-request `/favicon.ico` even when the page declares an
# explicit icon link, so serve the admin icon from the same source as
# `/admin/icon.svg` (the packaged SVG in web/static/). Modern browsers
# accept SVG content here as long as the Content-Type is set correctly.
@app.get("/favicon.ico", include_in_schema=False)
async def favicon() -> FileResponse:
icon = _STATIC_DIR / "volumen-icon.svg"
return FileResponse(
icon,
media_type="image/svg+xml",
headers={"Cache-Control": "public, max-age=86400"},
)
from .admin_auth_routes import router as admin_auth_router
from .admin_media_routes import router as admin_media_router
from .admin_post_routes import router as admin_post_router
@@ -152,6 +166,13 @@ class GlobalSecurityHeadersMiddleware(BaseHTTPMiddleware):
self._cookie_secure = cookie_secure
async def dispatch(self, request: Request, call_next): # type: ignore[override]
# Generate nonce BEFORE call_next so templates can access
# request.state.csp_nonce during rendering.
nonce: str | None = None
if request.url.path.startswith("/admin"):
nonce = secrets.token_urlsafe(18)
request.state.csp_nonce = nonce
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
@@ -161,12 +182,16 @@ class GlobalSecurityHeadersMiddleware(BaseHTTPMiddleware):
# Always set base CSP
response.headers["Content-Security-Policy"] = "; ".join(_BASE_CSP_DIRECTIVES)
# For admin routes, augment with nonce-based script-src
if request.url.path.startswith("/admin"):
nonce = secrets.token_urlsafe(18)
request.state.csp_nonce = nonce
# For admin routes, replace base script-src / style-src with
# nonce-based versions (do NOT append — browsers use the first
# occurrence when a directive is duplicated).
if nonce is not None:
csp = "; ".join(
_BASE_CSP_DIRECTIVES
tuple(
d
for d in _BASE_CSP_DIRECTIVES
if not d.startswith("script-src") and not d.startswith("style-src")
)
+ (
f"script-src 'self' 'nonce-{nonce}'",
f"style-src 'self' 'nonce-{nonce}'",
+4
View File
@@ -117,6 +117,8 @@ def admin_context(
**extra: Any,
) -> dict[str, Any]:
"""Build the standard Jinja2 template context for admin routes."""
from .version import VERSION
username: str = request.session.get("user", "")
record = users_obj.find(username) if username else None
csrf_val = csrf_token(request)
@@ -126,11 +128,13 @@ def admin_context(
"config": config,
"store": store,
"users": users_obj,
"users_exist": users_obj.any,
"csrf_token": csrf_val,
"current_user": username or None,
"current_role": record.role if record else None,
"current_user_record": record,
"csp_nonce": nonce,
"version": VERSION,
}
ctx.update(extra)
return ctx
+2 -2
View File
@@ -61,10 +61,10 @@ class Users:
self._snapshot = snapshot
if os.path.isfile(self._path):
self._cached = self._read_file()
elif self._bootstrap_hash:
self._cached = [User("admin", self._bootstrap_hash, "admin")]
else:
self._cached = []
if not self._cached and self._bootstrap_hash:
self._cached = [User("admin", self._bootstrap_hash, "admin")]
return list(self._cached)
@property
+5 -5
View File
@@ -12,16 +12,16 @@
<h1>{{ "New post" if mode == "new" else "Edit post" }}</h1>
<p class="lede">{{ "Draft a new article in Markdown." if mode == "new" else "Update and publish this post." }}</p>
</div>
<div style="display: flex; gap: 0.5rem;">
<div class="page-head-actions">
{% if mode == "edit" %}
<a class="btn btn-ghost" href="/api/volumen/posts/{{ post.slug }}" target="_blank">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Preview
</a>
{% endif %}
<a class="btn btn-ghost" href="/admin/">Cancel</a>
<button type="submit" form="post-form" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save
</button>
</div>
@@ -101,7 +101,7 @@
<p class="help">Auto-generated from the first paragraph if left empty</p>
</div>
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
<div class="form-options">
<label class="checkbox">
<input type="checkbox" name="draft" {{ 'checked' if post.draft else '' }}> Draft
</label>
@@ -135,7 +135,7 @@
</div>
<div class="surface full">
<div class="editor-tabs" style="margin-bottom: 0.75rem;">
<div class="editor-tabs">
<button type="button" data-tab="markdown" class="active">Markdown</button>
<button type="button" data-tab="visual">Visual</button>
</div>
+23 -2
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>volumen admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
<style nonce="{{ csp_nonce | default('') }}">
:root {
color-scheme: light dark;
@@ -250,6 +251,7 @@
aspect-ratio: 16 / 9; background: var(--surface-2);
background-size: cover; background-position: center;
border-bottom: 1px solid var(--border);
background-image: var(--cover-url);
}
.post-cover.placeholder {
display: grid; place-items: center; color: var(--fg-subtle);
@@ -339,7 +341,7 @@
.editor-tabs {
display: inline-flex; gap: 2px; background: var(--surface-2);
padding: 3px; border: 1px solid var(--border); border-radius: var(--radius);
margin-bottom: 1rem;
margin-bottom: 0.75rem;
}
.editor-tabs button {
font: inherit; font-size: 0.8rem; font-weight: 550;
@@ -810,6 +812,25 @@
border-color: var(--accent-hover);
color: var(--on-accent);
}
/* ------------------------------------------------------------------
* Utility classes that replace inline `style="..."` attributes.
* Strict CSP blocks inline style attributes, so anything that needs
* a one-off look lives here.
* ------------------------------------------------------------------ */
.page-head-actions { display: flex; gap: 0.5rem; align-items: center; }
.form-options { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
.form-inline { margin: 0; }
.form-hidden { display: none; }
.form-spaced { margin-top: 1rem; }
.flex-spacer { flex: 1; }
.btn-static { cursor: default; }
.btn-danger-text { color: var(--danger); }
.text-fg-strong { color: var(--fg); }
.badge-inline { margin-left: 0.4rem; }
.h3-section { margin: 0 0 0.75rem; }
.mt-3 { margin-top: 0.75rem; }
.is-hidden { display: none; }
</style>
</head>
<body>
@@ -888,7 +909,7 @@
<div class="name">{{ display_name }}</div>
<div class="role">{{ current_role }}</div>
</div>
<form method="post" action="/admin/logout" style="margin: 0;">
<form method="post" action="/admin/logout" class="form-inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<button type="submit" class="icon-btn" title="Log out" aria-label="Log out">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
+8 -8
View File
@@ -30,7 +30,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<h3>No posts yet</h3>
<p>Create your first post to get started.</p>
<a class="btn btn-primary" href="/admin/posts/new" style="margin-top: 0.75rem;">Create post</a>
<a class="btn btn-primary mt-3" href="/admin/posts/new">Create post</a>
</div>
{% else %}
<div class="post-grid" id="post-grid">
@@ -48,7 +48,7 @@
data-slug="{{ post.slug }}"
data-status="{{ status }}">
{% if post.cover %}
<div class="post-cover" style="background-image: url('{{ post.cover }}');"></div>
<div class="post-cover" style="--cover-url: url('{{ post.cover }}');"></div>
{% else %}
<div class="post-cover placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
@@ -67,12 +67,12 @@
</div>
</div>
<div class="post-actions">
<span class="btn btn-sm btn-ghost" style="cursor: default;">Edit</span>
<span class="spacer" style="flex: 1;"></span>
<span class="btn btn-sm btn-ghost btn-static">Edit</span>
<span class="flex-spacer"></span>
<form method="post" action="/admin/posts/{{ post.slug }}/delete"
onsubmit="return confirm('Delete this post?');" style="margin: 0;">
onsubmit="return confirm('Delete this post?');" class="form-inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm btn-ghost" style="color: var(--danger);" onclick="event.preventDefault(); event.stopPropagation(); this.closest('form').submit();">
<button type="submit" class="btn btn-sm btn-ghost btn-danger-text" onclick="event.preventDefault(); event.stopPropagation(); this.closest('form').submit();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form>
@@ -82,7 +82,7 @@
</div>
{% endif %}
<script>
<script nonce="{{ csp_nonce | default('') }}">
(function () {
var search = document.getElementById("post-search");
var filterButtons = document.querySelectorAll("#post-filters .filter-chip");
@@ -97,7 +97,7 @@
var status = card.dataset.status;
var matchesSearch = !q || title.indexOf(q) !== -1 || slug.indexOf(q) !== -1;
var matchesFilter = currentFilter === "all" || status === currentFilter;
card.style.display = matchesSearch && matchesFilter ? "" : "none";
card.classList.toggle("is-hidden", !(matchesSearch && matchesFilter));
});
}
@@ -40,7 +40,7 @@
<div class="surface-head">
<div>
<h2>Account</h2>
<p class="lede">Signed in as <strong style="color: var(--fg);">{{ current_user }}</strong> · {{ current_role }}</p>
<p class="lede">Signed in as <strong class="text-fg-strong">{{ current_user }}</strong> · {{ current_role }}</p>
</div>
</div>
@@ -72,7 +72,7 @@
</div>
</form>
{% if current_user_record and current_user_record.photo %}
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" style="display: none;">
<form id="remove-photo-form" method="post" action="/admin/settings/photo/remove" class="form-hidden">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
</form>
{% endif %}
@@ -94,7 +94,7 @@
</div>
<button type="submit" class="btn">Save name</button>
</form>
<form method="post" action="/admin/settings/fediverse" class="settings-inline-form" style="margin-top: 1rem;">
<form method="post" action="/admin/settings/fediverse" class="settings-inline-form form-spaced">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field">
<label for="fediverse_creator">Fediverse handle</label>
@@ -126,7 +126,7 @@
</div>
<button type="submit" class="btn">Update password</button>
</form>
<form method="post" action="/admin/settings/username" class="settings-inline-form" style="margin-top: 1rem;">
<form method="post" action="/admin/settings/username" class="settings-inline-form form-spaced">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field">
<label for="username">Username</label>
@@ -159,7 +159,7 @@
<div class="user-info">
<div class="user-name">
{{ user.name or user.username }}
{% if user.username == current_user %}<span class="badge badge-accent" style="margin-left: 0.4rem;">you</span>{% endif %}
{% if user.username == current_user %}<span class="badge badge-accent badge-inline">you</span>{% endif %}
</div>
<div class="user-username">@{{ user.username }}</div>
</div>
@@ -167,7 +167,7 @@
{% if user.username == current_user %}
<span class="badge badge-neutral">{{ user.role }}</span>
{% else %}
<form method="post" action="/admin/settings/users/{{ user.username }}/role" style="margin: 0;">
<form method="post" action="/admin/settings/users/{{ user.username }}/role" class="form-inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<select class="select select-sm" name="role" onchange="this.form.submit()">
{% for role in roles %}
@@ -178,7 +178,7 @@
{% endif %}
</div>
{% if user.username != current_user %}
<form method="post" action="/admin/settings/users/{{ user.username }}/delete" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
<form method="post" action="/admin/settings/users/{{ user.username }}/delete" onsubmit="return confirm('Remove this user?');" class="form-inline">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
</form>
@@ -188,7 +188,7 @@
</div>
<div class="add-user-form">
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
<h3 class="h3-section">Add user</h3>
<form method="post" action="/admin/settings/users">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field-row-3">
@@ -217,7 +217,7 @@
</div>
</div>
<script>
<script nonce="{{ csp_nonce | default('') }}">
(function () {
var sections = document.querySelectorAll(".settings-panels .surface[id]");
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
+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: