refactor: rewrite from Ruby/Sinatra to Python/FastAPI

This commit is contained in:
2026-07-22 21:12:28 +02:00
parent 6f62d3e3f3
commit 44b879371e
69 changed files with 5852 additions and 3463 deletions
View File
+124
View File
@@ -0,0 +1,124 @@
"""Shared fixtures for the volumen test suite."""
from __future__ import annotations
import tempfile
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from volumen import reset_login_attempts
from volumen.config import Config
from volumen.password import hash_password as hash_pw
from volumen.store import Store
from volumen.users import Users
@pytest.fixture(autouse=True)
def _reset_login_attempts() -> Generator[None, None, None]:
"""Reset the global login-attempts tracker before every test."""
reset_login_attempts()
yield
@pytest.fixture
def tmp_dir() -> Generator[Path, None, None]:
"""Create a temporary directory that is cleaned up after the test."""
with tempfile.TemporaryDirectory() as d:
yield Path(d)
@pytest.fixture
def tmp_content_dir(tmp_path: Path) -> Path:
"""Temporary content directory with sample posts."""
content = tmp_path / "posts"
content.mkdir()
(content / "hello.md").write_text(
'+++\ntitle = "Hello"\ntags = ["intro"]\n+++\n\nHello **world**.\n'
)
(content / "draft.md").write_text('+++\ntitle = "Draft"\ndraft = true\n+++\n\nSecret.\n')
return content
@pytest.fixture
def config(tmp_content_dir: Path) -> Config:
"""Test Config instance pointing at a temp content directory."""
return Config.load(
path="/nonexistent/volumen.toml",
overrides={"content": str(tmp_content_dir)},
)
@pytest.fixture
def store(tmp_content_dir: Path) -> Store:
"""Test Store instance backed by a temp content directory."""
return Store(str(tmp_content_dir), default_lang="en")
@pytest.fixture
def users_path(tmp_path: Path) -> str:
"""Path for a temporary users.toml file."""
return str(tmp_path / "users.toml")
@pytest.fixture
def bootstrap_hash() -> str:
"""A pre-hashed password for bootstrap tests."""
return hash_pw("secret")
@pytest.fixture
def users(users_path: str, bootstrap_hash: str) -> Users:
"""Test Users instance with bootstrap admin user."""
return Users(path=users_path, bootstrap_hash=bootstrap_hash)
@pytest.fixture
def client(config: Config, store: Store) -> TestClient:
"""FastAPI TestClient for public API tests."""
from volumen.server import create_app
app = create_app(config, store)
return TestClient(app)
@pytest.fixture
def admin_config_dir(tmp_path: Path) -> Path:
"""Create a directory with config and posts for admin tests."""
posts_dir = tmp_path / "posts"
posts_dir.mkdir()
users_path = tmp_path / "users.toml"
config_path = tmp_path / "config.toml"
pw_hash = hash_pw("secret")
config_path.write_text(
f'content_dir = "{posts_dir}"\n'
f'users_file = "{users_path}"\n\n'
"[admin]\n"
f'password_hash = "{pw_hash}"\n'
)
return tmp_path
@pytest.fixture
def admin_client(admin_config_dir: Path) -> TestClient:
"""FastAPI TestClient for admin tests with bootstrap admin."""
from volumen.config import Config
from volumen.server import create_app
from volumen.store import Store
config = Config.load(path=str(admin_config_dir / "config.toml"))
store = Store(config.content_dir, default_lang="en")
app = create_app(config, store)
return TestClient(app)
@pytest.fixture
def sample_post_md() -> str:
"""Sample post in Markdown with TOML frontmatter."""
return (
'+++\ntitle = "Test Post"\nslug = "test-post"\nlang = "en"\n'
'tags = ["python", "blog"]\ndate = 2026-01-15\ndraft = false\n+++\n\n'
"This is the **body** of the post.\n\nSecond paragraph."
)
+423
View File
@@ -0,0 +1,423 @@
"""Tests for the admin routes."""
from __future__ import annotations
import re
from pathlib import Path
from fastapi.testclient import TestClient
from volumen.users import Users
def _csrf_from_body(body: str) -> str:
match = re.search(r'name="_csrf" value="([^"]+)"', body)
if match:
return match.group(1)
return ""
class TestAdminLogin:
def test_admin_redirect_to_login(self, admin_client: TestClient) -> None:
response = admin_client.get("/admin/", follow_redirects=False)
assert response.status_code in (302, 307, 301, 303)
def test_login_page_renders(self, admin_client: TestClient) -> None:
response = admin_client.get("/admin/login")
assert response.status_code == 200
assert "Sign in" 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)
resp = admin_client.post(
"/admin/login",
data={"username": "admin", "password": "secret", "_csrf": token},
follow_redirects=False,
)
assert resp.status_code in (302, 307, 303)
def test_login_with_invalid_credentials(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/login")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/login",
data={"username": "admin", "password": "nope", "_csrf": token},
)
assert "Invalid" in resp.text or "invalid" in resp.text.lower()
def test_wrong_password_is_rejected(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/login")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/login",
data={"username": "admin", "password": "nope", "_csrf": token},
)
assert "Invalid username or password" in resp.text
def _login(client: TestClient) -> str:
"""Log in as admin and return the session cookie value."""
resp = client.get("/admin/login")
token = _csrf_from_body(resp.text)
client.post(
"/admin/login",
data={"username": "admin", "password": "secret", "_csrf": token},
follow_redirects=True,
)
return token
def _login_as(client: TestClient, username: str, password: str) -> str:
resp = client.get("/admin/login")
token = _csrf_from_body(resp.text)
client.post(
"/admin/login",
data={"username": username, "password": password, "_csrf": token},
follow_redirects=True,
)
return token
class TestAdminPostList:
def test_post_list_requires_login(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/", follow_redirects=False)
assert resp.status_code in (302, 307, 303)
def test_post_list_after_login(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/")
assert resp.status_code == 200
assert "Posts" in resp.text
class TestCreatePost:
def test_create_post_writes_file(
self, admin_client: TestClient, admin_config_dir: Path
) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "Test",
"slug": "test",
"lang": "en",
"tags": "a, b",
"body": "Hello **world**.",
},
follow_redirects=False,
)
assert resp.status_code in (302, 307, 303)
post_path = admin_config_dir / "posts" / "test.md"
assert post_path.exists()
content = post_path.read_text()
assert "Test" in content
assert "a, b" in content or "a" in content
def test_create_post_with_all_langs(
self, admin_client: TestClient, admin_config_dir: Path
) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "Global",
"slug": "global",
"lang": "en",
"all_langs": "on",
"body": "Hi",
},
follow_redirects=False,
)
post_path = admin_config_dir / "posts" / "global.md"
assert post_path.exists()
content = post_path.read_text()
assert "all_langs" in content
def test_create_post_with_cover(self, admin_client: TestClient, admin_config_dir: Path) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "Covered",
"slug": "covered",
"lang": "en",
"cover": "/media/c.png",
"body": "Hi",
},
follow_redirects=False,
)
post_path = admin_config_dir / "posts" / "covered.md"
content = post_path.read_text()
assert "/media/c.png" in content
def test_create_post_rejects_invalid_slug(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "X",
"slug": "../../etc/passwd",
"lang": "en",
"body": "y",
},
)
assert "Invalid slug" in resp.text
def test_create_post_rejects_xss_in_slug(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "X",
"slug": 'foo" onclick',
"lang": "en",
"body": "y",
},
)
assert "Invalid slug" in resp.text
def test_create_post_duplicate_slug(
self, admin_client: TestClient, admin_config_dir: Path
) -> None:
(admin_config_dir / "posts" / "dup.md").write_text(
'+++\ntitle = "Existing"\n+++\n\nBody.\n'
)
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": token,
"title": "Dup",
"slug": "dup",
"lang": "en",
"body": "x",
},
)
assert "already exists" in resp.text.lower()
class TestCSRF:
def test_csrf_token_required(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.post(
"/admin/posts",
data={"title": "X", "slug": "x", "body": "y"},
)
assert resp.status_code == 403
def test_invalid_csrf_token_rejected(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/posts/new")
resp = admin_client.post(
"/admin/posts",
data={
"_csrf": "invalid-token",
"title": "X",
"slug": "x",
"body": "y",
},
)
assert resp.status_code == 403
class TestLogout:
def test_logout_clears_session(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/")
assert resp.status_code == 200
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
admin_client.post("/admin/logout", data={"_csrf": token})
resp = admin_client.get("/admin/", follow_redirects=False)
assert resp.status_code in (302, 307, 303)
class TestSettings:
def test_settings_requires_login(self, admin_client: TestClient) -> None:
resp = admin_client.get("/admin/settings", follow_redirects=False)
assert resp.status_code in (302, 307, 303)
def test_change_password(self, admin_client: TestClient, admin_config_dir: Path) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/password",
data={
"_csrf": token,
"current_password": "secret",
"new_password": "brand-new",
},
)
assert "Password updated" in resp.text
users_path = str(admin_config_dir / "users.toml")
fresh = Users(path=users_path)
assert fresh.authenticate("admin", "secret") is None
assert fresh.authenticate("admin", "brand-new") is not None
def test_change_password_wrong_current(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/password",
data={
"_csrf": token,
"current_password": "wrong",
"new_password": "brand-new",
},
)
assert "incorrect" in resp.text.lower()
def test_change_username(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/username",
data={"_csrf": token, "username": "superadmin"},
)
assert "updated" in resp.text.lower()
def test_change_username_accepts_same_name(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/username",
data={"_csrf": token, "username": "admin"},
)
assert "unchanged" in resp.text.lower()
def test_change_username_rejects_special_chars(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/username",
data={"_csrf": token, "username": 'foo" bar'},
)
assert "letters" in resp.text.lower()
def test_change_display_name(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/name",
data={"_csrf": token, "name": "Admin User"},
)
assert "Display name updated" in resp.text
class TestFediverseSettings:
def test_change_fediverse_creator_valid(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/fediverse",
data={"_csrf": token, "fediverse_creator": "@user@example.com"},
)
assert "updated" in resp.text.lower() or "updated" in resp.text.lower()
def test_change_fediverse_creator_invalid(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/fediverse",
data={"_csrf": token, "fediverse_creator": "not-a-handle"},
)
assert "handle" in resp.text.lower()
class TestUserManagement:
def test_create_user_admin_only(self, admin_client: TestClient, admin_config_dir: Path) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/users",
data={"_csrf": token, "username": "editor", "password": "pw12345", "role": "author"},
)
assert "User added" in resp.text or "added" in resp.text.lower()
users_path = str(admin_config_dir / "users.toml")
users = Users(path=users_path)
assert users.find("editor") is not None
def test_create_user_then_login(self, admin_client: TestClient, admin_config_dir: Path) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
admin_client.post(
"/admin/settings/users",
data={"_csrf": token, "username": "editor2", "password": "pw12345"},
)
# Logout
resp = admin_client.get("/admin/login")
token = _csrf_from_body(resp.text)
admin_client.post("/admin/logout", data={"_csrf": token})
# Login as new user
_login_as(admin_client, "editor2", "pw12345")
resp = admin_client.get("/admin/")
assert resp.status_code == 200
def test_cannot_delete_own_account(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/users/admin/delete",
data={"_csrf": token},
)
assert "cannot delete your own" in resp.text.lower()
def test_cannot_create_user_with_empty_fields(self, admin_client: TestClient) -> None:
_login(admin_client)
resp = admin_client.get("/admin/settings")
token = _csrf_from_body(resp.text)
resp = admin_client.post(
"/admin/settings/users",
data={"_csrf": token, "username": "", "password": ""},
)
assert "required" in resp.text.lower() or "empty" in resp.text.lower()
class TestOrphanedSession:
def test_orphaned_session_is_rejected(
self, admin_client: TestClient, admin_config_dir: Path
) -> None:
_login(admin_client)
resp = admin_client.get("/admin/")
assert resp.status_code == 200
# Clear users file to simulate deleted user
users_path = admin_config_dir / "users.toml"
users_path.write_text("")
resp = admin_client.get("/admin/", follow_redirects=False)
assert resp.status_code in (302, 307, 303)
+89
View File
@@ -0,0 +1,89 @@
"""Tests for CLI entry point."""
from __future__ import annotations
import contextlib
import sys
from pathlib import Path
from unittest import mock
import pytest
from volumen import VERSION
from volumen.cli import main
class TestVersionCommand:
def test_version_output(self, capsys: pytest.CaptureFixture[str]) -> None:
with (
mock.patch.object(sys, "argv", ["volumen", "version"]),
contextlib.suppress(SystemExit),
):
main()
captured = capsys.readouterr()
assert VERSION in captured.out
class TestHelpCommand:
def test_help_output(self, capsys: pytest.CaptureFixture[str]) -> None:
with mock.patch.object(sys, "argv", ["volumen", "--help"]), contextlib.suppress(SystemExit):
main()
captured = capsys.readouterr()
assert "volumen" in captured.out or "usage" in captured.out.lower()
class TestUnknownCommand:
def test_unknown_command_exits(self) -> None:
with mock.patch.object(sys, "argv", ["volumen", "bogus"]), pytest.raises(SystemExit):
main()
class TestParseServeOptions:
def test_parse_serve_defaults(self) -> None:
with mock.patch.object(sys, "argv", ["volumen", "serve"]):
import argparse
parser = argparse.ArgumentParser()
sp = parser.add_subparsers(dest="command")
sp_serve = sp.add_parser("serve")
sp_serve.add_argument("--config", default="/etc/volumen/config.toml")
sp_serve.add_argument("--host", default=None)
sp_serve.add_argument("--port", type=int, default=None)
sp_serve.add_argument("--content", default=None)
args = parser.parse_args(["serve"])
assert args.config == "/etc/volumen/config.toml"
def test_parse_serve_overrides(self) -> None:
import argparse
parser = argparse.ArgumentParser()
sp = parser.add_subparsers(dest="command")
sp_serve = sp.add_parser("serve")
sp_serve.add_argument("--config", default="/etc/volumen/config.toml")
sp_serve.add_argument("--host", default=None)
sp_serve.add_argument("--port", type=int, default=None)
sp_serve.add_argument("--content", default=None)
args = parser.parse_args(
["serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"]
)
assert args.config == "/t.toml"
assert args.host == "127.0.0.1"
assert args.port == 9000
class TestPrepareConfig:
def test_prepare_config_writes_template(self, tmp_path: Path) -> None:
from volumen.config import Config
path = tmp_path / "config.toml"
result = Config.generate_default(str(path))
assert result == str(path)
assert path.exists()
def test_prepare_config_noops_when_present(self, tmp_path: Path) -> None:
from volumen.config import Config
path = tmp_path / "config.toml"
path.write_text("[server]\nport = 1\n")
result = Config.generate_default(str(path))
assert result is None
+112
View File
@@ -0,0 +1,112 @@
"""Tests for Config — loading, merging, defaults, overrides."""
from __future__ import annotations
from pathlib import Path
from volumen.config import Config
MISSING = "/nonexistent/volumen.toml"
class TestDefaults:
def test_defaults_when_file_missing(self) -> None:
config = Config.load(path=MISSING)
assert config.port == 9090
assert config.language == "en"
assert config.host == "0.0.0.0"
def test_default_content_dir(self) -> None:
config = Config.load(path=MISSING)
assert config.content_dir == "/var/lib/volumen/posts"
def test_default_site_title(self) -> None:
config = Config.load(path=MISSING)
assert config.site["title"] == "My Blog"
class TestOverrides:
def test_overrides_apply(self) -> None:
config = Config.load(
path=MISSING,
overrides={"host": "127.0.0.1", "port": 9000, "content": "/srv/posts"},
)
assert config.host == "127.0.0.1"
assert config.port == 9000
assert config.content_dir == "/srv/posts"
def test_does_not_mutate_defaults(self) -> None:
Config.load(path=MISSING, overrides={"host": "10.0.0.1"})
fresh = Config.load(path=MISSING)
assert fresh.host == "0.0.0.0"
def test_does_not_mutate_default_inner_hashes(self) -> None:
config = Config.load(path=MISSING)
config.site["title"] = "MUTATED"
fresh = Config.load(path=MISSING)
assert fresh.site["title"] == "My Blog"
class TestLoadFromFile:
def test_loads_and_merges_file(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text('[site]\ntitle = "Custom"\n')
config = Config.load(path=str(config_path))
assert config.site["title"] == "Custom"
assert config.port == 9090 # default preserved
def test_loads_server_section(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text('[server]\nhost = "1.2.3.4"\nport = 8000\n')
config = Config.load(path=str(config_path))
assert config.host == "1.2.3.4"
assert config.port == 8000
class TestGenerateDefault:
def test_generate_default_writes_template(self, tmp_path: Path) -> None:
dest = tmp_path / "sub" / "config.toml"
result = Config.generate_default(str(dest))
assert result == str(dest)
assert dest.exists()
loaded = Config.load(path=str(dest))
assert loaded.port == 9090
def test_generate_default_skips_existing(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text("[server]\nport = 9999\n")
result = Config.generate_default(str(config_path))
assert result is None
loaded = Config.load(path=str(config_path))
assert loaded.port == 9999
class TestAccessors:
def test_host(self) -> None:
config = Config.load(path=MISSING, overrides={"host": "127.0.0.1"})
assert config.host == "127.0.0.1"
def test_port(self) -> None:
assert Config.load(path=MISSING).port == 9090
def test_content_dir(self) -> None:
config = Config.load(path=MISSING, overrides={"content": "/tmp/posts"})
assert config.content_dir == "/tmp/posts"
def test_users_file(self) -> None:
config = Config.load(path=MISSING)
assert config.users_file == "/var/lib/volumen/users.toml"
def test_site(self) -> None:
config = Config.load(path=MISSING)
assert isinstance(config.site, dict)
assert "title" in config.site
def test_admin(self) -> None:
config = Config.load(path=MISSING)
assert isinstance(config.admin, dict)
assert "password_hash" in config.admin
def test_language(self) -> None:
config = Config.load(path=MISSING)
assert config.language == "en"
+78
View File
@@ -0,0 +1,78 @@
"""Tests for Frontmatter parser — TOML frontmatter parsing and serialisation."""
from __future__ import annotations
from volumen.frontmatter import dump, parse
class TestParse:
def test_parse_extracts_metadata_and_body(self) -> None:
content = '+++\ntitle = "Hello"\ntags = ["a", "b"]\n+++\n\nBody text.\n'
metadata, body = parse(content)
assert metadata["title"] == "Hello"
assert metadata["tags"] == ["a", "b"]
assert body == "Body text.\n"
def test_parse_without_frontmatter(self) -> None:
metadata, body = parse("# Just markdown\n")
assert metadata == {}
assert body == "# Just markdown\n"
def test_parse_without_closing_delimiter(self) -> None:
content = '+++\ntitle = "x"\n\nbody'
metadata, body = parse(content)
assert metadata == {}
assert body == content
def test_parse_empty_frontmatter(self) -> None:
content = "+++\n+++\n\nBody here.\n"
metadata, body = parse(content)
assert metadata == {}
assert body == "Body here.\n"
def test_parse_draft_false(self) -> None:
content = '+++\ntitle = "X"\ndraft = false\n+++\n\nBody\n'
metadata, body = parse(content)
assert metadata["draft"] is False
def test_parse_nested_tables(self) -> None:
content = '+++\n[meta]\ntitle = "X"\n+++\n\nBody\n'
metadata, body = parse(content)
assert metadata["meta"]["title"] == "X"
def test_parse_normalises_crlf(self) -> None:
metadata, body = parse('+++\r\ntitle = "x"\r\n+++\r\n\r\nBody\r\n')
assert metadata["title"] == "x"
assert body == "Body\n"
class TestDump:
def test_dump_produces_valid_frontmatter(self) -> None:
result = dump({"title": "Hello", "tags": ["a"]}, "Body text.\n")
assert result.startswith("+++\n")
assert "+++\n\n" in result
assert "Body text." in result
def test_dump_empty_metadata(self) -> None:
result = dump({}, "Just body.\n")
assert "+++" in result
assert "Just body." in result
def test_round_trip(self) -> None:
metadata = {"title": "Hello", "tags": ["intro"], "draft": False}
dumped = dump(metadata, "Hello **world**.")
parsed_meta, parsed_body = parse(dumped)
assert parsed_meta == metadata
assert parsed_body.strip() == "Hello **world**."
def test_round_trip_with_empty_metadata(self) -> None:
dumped = dump({}, "Just body.\n")
parsed_meta, parsed_body = parse(dumped)
assert parsed_meta == {}
assert parsed_body.strip() == "Just body."
def test_round_trip_preserves_dates(self) -> None:
metadata = {"title": "X", "date": "2026-01-15"}
dumped = dump(metadata, "Body\n")
parsed_meta, _ = parse(dumped)
assert parsed_meta["date"] == "2026-01-15"
+80
View File
@@ -0,0 +1,80 @@
"""Tests for Markdown renderer."""
from __future__ import annotations
from volumen.markdown import render
class TestBasicRendering:
def test_renders_bold(self) -> None:
assert "<strong>bold</strong>" in render("**bold**")
def test_renders_italic(self) -> None:
result = render("*italic*")
assert "<em>italic</em>" in result
def test_renders_heading(self) -> None:
assert "<h1" in render("# Title")
def test_renders_paragraph(self) -> None:
result = render("Simple paragraph.")
assert "<p>" in result
def test_renders_link(self) -> None:
result = render("[link](https://example.com)")
assert '<a href="https://example.com"' in result
def test_renders_unordered_list(self) -> None:
result = render("- one\n- two\n")
assert "<ul>" in result
assert "<li>one</li>" in result
class TestGFMFeatures:
def test_renders_gfm_table(self) -> None:
table = "| A | B |\n|---|---|\n| 1 | 2 |\n"
assert "<table" in render(table)
def test_renders_fenced_code_block(self) -> None:
code = "```python\nprint(1)\n```\n"
html = render(code)
assert "<pre" in html or "<code" in html
def test_renders_task_list(self) -> None:
md = "- [x] Done\n- [ ] Todo\n"
result = render(md)
assert "checkbox" in result.lower() or "checked" in result.lower()
def test_renders_strikethrough(self) -> None:
result = render("~~strike~~")
assert "<del>" in result
class TestFigureWrapping:
def test_image_with_title_wrapped_in_figure(self) -> None:
result = render('![alt](url "caption")')
assert "<figure>" in result
def test_image_without_title_no_wrapping(self) -> None:
result = render("![alt](url)")
assert "<figure>" not in result
def test_figure_includes_figcaption(self) -> None:
result = render('![alt](url "caption")')
assert "<figcaption>" in result
assert "caption" in result
class TestEdgeCases:
def test_empty_input(self) -> None:
result = render("")
assert isinstance(result, str)
assert result == ""
def test_plain_text(self) -> None:
result = render("Hello world")
assert "Hello world" in result
def test_html_passthrough(self) -> None:
result = render("<span>inline</span>")
assert "<span>" in result
+51
View File
@@ -0,0 +1,51 @@
"""Tests for Password hashing and verification."""
from __future__ import annotations
from volumen.password import hash_password, verify_password
class TestHash:
def test_hash_produces_string(self) -> None:
result = hash_password("secret")
assert isinstance(result, str)
def test_hash_format(self) -> None:
result = hash_password("pw")
assert result.startswith("scrypt$16384$8$1$")
def test_hash_is_deterministic_in_format(self) -> None:
result = hash_password("pw")
parts = result.split("$")
assert len(parts) == 6
assert parts[0] == "scrypt"
assert parts[1] == "16384"
assert parts[2] == "8"
assert parts[3] == "1"
class TestVerify:
def test_verify_correct_password(self) -> None:
encoded = hash_password("s3cret")
assert verify_password("s3cret", encoded) is True
def test_verify_wrong_password(self) -> None:
encoded = hash_password("s3cret")
assert verify_password("nope", encoded) is False
def test_verify_empty_string(self) -> None:
encoded = hash_password("")
assert verify_password("", encoded) is True
def test_verify_malformed_input(self) -> None:
assert verify_password("x", "not-a-hash") is False
assert verify_password("x", "") is False
def test_verify_tampered_hash(self) -> None:
encoded = hash_password("s3cret")
tampered = encoded[:-1] + ("X" if encoded[-1] != "X" else "Y")
assert verify_password("s3cret", tampered) is False
def test_verify_empty_password_against_hash(self) -> None:
encoded = hash_password("secret")
assert verify_password("", encoded) is False
+207
View File
@@ -0,0 +1,207 @@
"""Tests for Post model — parsing, metadata, rendering, serialisation."""
from __future__ import annotations
from datetime import date
import pytest
from volumen.post import Post
class TestPostParsing:
def test_parse_builds_post(self) -> None:
content = (
"+++\n"
'title = "Hello"\n'
'slug = "hello"\n'
'lang = "en"\n'
'tags = ["intro", "ruby"]\n'
"date = 2026-01-15\n"
"draft = true\n"
"+++\n\n"
"First paragraph.\n"
)
post = Post.parse(content)
assert post.slug == "hello"
assert post.title == "Hello"
assert post.lang == "en"
assert post.tags == ["intro", "ruby"]
assert post.draft is True
assert post.date_string == "2026-01-15"
def test_parse_preserves_body_newlines(self) -> None:
content = '+++\ntitle = "X"\nslug = "x"\n+++\n\nLine1\n\nLine2\n'
post = Post.parse(content)
assert "Line1" in post.body
assert "Line2" in post.body
class TestExcerpt:
def test_excerpt_from_metadata(self) -> None:
post = Post(metadata={"slug": "x", "excerpt": "Custom blurb."}, body="Long body text here.")
assert post.excerpt == "Custom blurb."
def test_excerpt_derived_from_body(self) -> None:
post = Post(metadata={"slug": "x"}, body="# Heading\n\nThe body paragraph.")
assert post.excerpt == "The body paragraph."
def test_derived_excerpt_strips_html_tags(self) -> None:
post = Post(metadata={"slug": "x"}, body="<strong>Bold</strong> and <em>italic</em>.")
assert post.excerpt == "Bold and italic."
def test_derived_excerpt_skips_heading(self) -> None:
post = Post(metadata={"slug": "x"}, body="# Heading\n\nText here.")
assert post.excerpt == "Text here."
def test_derived_excerpt_empty_body(self) -> None:
post = Post(metadata={"slug": "x"}, body="")
assert post.excerpt == ""
def test_derived_excerpt_truncates_long(self) -> None:
long_text = "A" * 300
post = Post(metadata={"slug": "x"}, body=long_text)
assert len(post.excerpt) <= 203 # limit + ellipsis
assert post.excerpt.endswith("\u2026")
class TestSummaryDict:
def test_summary_shape(self) -> None:
post = Post(metadata={"slug": "x", "title": "X"}, body="Body")
summary = post.summary()
assert summary["url"] == "/api/volumen/posts/x"
assert summary["title"] == "X"
assert summary["slug"] == "x"
def test_summary_includes_cover_and_reading_time(self) -> None:
post = Post(metadata={"slug": "x", "cover": "/media/c.png"}, body="word " * 250)
assert post.summary()["cover"] == "/media/c.png"
assert post.summary()["reading_time"] == 2
def test_summary_includes_fediverse_creator(self) -> None:
post = Post(
metadata={"slug": "x", "fediverse_creator": "@user@example.social"},
body="body",
)
assert post.summary()["fediverse_creator"] == "@user@example.social"
def test_summary_omits_fediverse_creator_when_absent(self) -> None:
post = Post(metadata={"slug": "x"}, body="body")
assert post.summary()["fediverse_creator"] is None
class TestDetailDict:
def test_detail_includes_rendered_html(self) -> None:
post = Post(metadata={"slug": "x"}, body="**hi**")
assert "<strong>hi</strong>" in post.detail()["html"]
def test_detail_includes_body(self) -> None:
post = Post(metadata={"slug": "x"}, body="raw body")
assert post.detail()["body"] == "raw body"
class TestReadingTime:
def test_reading_time_is_at_least_one(self) -> None:
post = Post(metadata={"slug": "x"}, body="short")
assert post.reading_time == 1
def test_reading_time_200_words(self) -> None:
post = Post(metadata={"slug": "x"}, body="word " * 200)
assert post.reading_time == 1
def test_reading_time_201_words(self) -> None:
post = Post(metadata={"slug": "x"}, body="word " * 201)
assert post.reading_time == 2
class TestScheduled:
def test_scheduled_post_when_publish_at_is_future(self) -> None:
future = date.today().replace(year=date.today().year + 1)
post = Post(metadata={"publish_at": future})
assert post.scheduled is True
def test_scheduled_post_when_publish_at_is_past(self) -> None:
past = date(2000, 1, 1)
post = Post(metadata={"publish_at": past})
assert post.scheduled is False
def test_post_without_publish_at_is_not_scheduled(self) -> None:
post = Post(metadata={})
assert post.scheduled is False
class TestFediverseCreator:
def test_fediverse_creator_returns_metadata_value(self) -> None:
post = Post(metadata={"slug": "x", "fediverse_creator": "@user@example.social"})
assert post.fediverse_creator == "@user@example.social"
def test_fediverse_creator_is_none_when_missing(self) -> None:
post = Post(metadata={"slug": "x"})
assert post.fediverse_creator is None
def test_fediverse_creator_is_none_when_blank(self) -> None:
post = Post(metadata={"slug": "x", "fediverse_creator": " "})
assert post.fediverse_creator is None
@pytest.mark.parametrize(
"handle",
[
"@ada@lovelace.org",
"@user@example.com",
" @user@example.com ",
],
)
def test_valid_fediverse_creator_accepts_well_formed(self, handle: str) -> None:
post = Post(metadata={"slug": "x", "fediverse_creator": handle})
assert post.valid_fediverse_creator is True
@pytest.mark.parametrize(
"handle",
[
"ada@example.com",
"@only-one-at",
"@user@example.com extra",
"@user@",
"@@example.com",
],
)
def test_valid_fediverse_creator_rejects_malformed(self, handle: str) -> None:
post = Post(metadata={"slug": "x", "fediverse_creator": handle})
assert post.valid_fediverse_creator is False
class TestDateString:
def test_date_string_from_string(self) -> None:
post = Post(metadata={"date": "2026-01-15"})
assert post.date_string == "2026-01-15"
def test_date_string_from_date(self) -> None:
post = Post(metadata={"date": date(2026, 1, 15)})
assert post.date_string == "2026-01-15"
def test_date_string_none_when_missing(self) -> None:
post = Post(metadata={})
assert post.date_string is None
class TestTranslations:
def test_translations_from_metadata(self) -> None:
post = Post(metadata={"slug": "x", "translations": {"cs": "ahoj", "de": "hallo"}})
assert post.translations == {"cs": "ahoj", "de": "hallo"}
def test_translations_empty_when_missing(self) -> None:
post = Post(metadata={"slug": "x"})
assert post.translations == {}
class TestFileSerialization:
def test_round_trip(self) -> None:
metadata = {"slug": "hello", "title": "Hello", "tags": ["intro"], "draft": False}
post = Post(metadata=metadata, body="Hello **world**.\n")
serialised = post.to_file()
reloaded = Post.parse(serialised)
assert reloaded.slug == "hello"
assert reloaded.title == "Hello"
assert reloaded.tags == ["intro"]
assert reloaded.draft is False
assert "Hello **world**." in reloaded.body
+242
View File
@@ -0,0 +1,242 @@
"""Tests for the public API endpoints."""
from __future__ import annotations
from datetime import date
from pathlib import Path
from fastapi.testclient import TestClient
class TestSiteEndpoint:
def test_site_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert response.status_code == 200
data = response.json()
assert data["title"] == "My Blog"
def test_site_endpoint_exposes_fediverse_creator_default(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert response.status_code == 200
assert "fediverse_creator" in response.json()
class TestPostsEndpoint:
def test_posts_excludes_drafts(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts")
data = response.json()
assert data["total"] == 1
assert data["posts"][0]["slug"] == "hello"
def test_post_detail_renders_html(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts/hello")
assert response.status_code == 200
assert "<strong>world</strong>" in response.json()["html"]
def test_post_not_found(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts/missing")
assert response.status_code == 404
assert response.json()["detail"]["error"] == "not_found"
def test_draft_post_detail_returns_error(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts/draft")
assert response.status_code == 404
assert response.json()["detail"]["error"] == "draft"
class TestTagsEndpoint:
def test_tags_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/tags")
data = response.json()
assert data["tags"] == [{"name": "intro", "count": 1}]
class TestCORS:
def test_cors_header_present(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert response.headers.get("access-control-allow-origin") == "*"
def test_cors_preflight(self, client: TestClient) -> None:
response = client.options("/api/volumen/site")
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "*"
assert "GET" in response.headers["access-control-allow-methods"]
class TestLanguageFilter:
def test_all_langs_post_appears_in_other_languages(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "global.md").write_text(
'+++\ntitle = "Global"\nlang = "en"\nall_langs = true\n+++\n\nEverywhere.\n'
)
response = client.get("/api/volumen/posts?lang=cs")
slugs = [p["slug"] for p in response.json()["posts"]]
assert "global" in slugs
assert "hello" not in slugs
def test_lang_filter_default_shows_default_lang_posts(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts")
slugs = [p["slug"] for p in response.json()["posts"]]
assert "hello" in slugs
class TestScheduledPosts:
def test_scheduled_post_is_hidden(self, client: TestClient, tmp_content_dir: Path) -> None:
future = date.today().replace(year=date.today().year + 1).isoformat()
(tmp_content_dir / "later.md").write_text(
f'+++\ntitle = "Later"\npublish_at = {future}\n+++\n\nNot yet.\n'
)
response = client.get("/api/volumen/posts")
slugs = [p["slug"] for p in response.json()["posts"]]
assert "later" not in slugs
class TestSearch:
def test_search_by_title(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts?q=Hello")
data = response.json()
assert data["total"] == 1
assert data["posts"][0]["slug"] == "hello"
def test_search_by_body(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts?q=world")
data = response.json()
assert data["total"] == 1
def test_search_no_match(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts?q=nonexistent")
assert response.json()["total"] == 0
def test_search_is_case_insensitive(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts?q=HELLO")
assert response.json()["total"] == 1
class TestPagination:
def test_pagination_has_next_and_prev(self, client: TestClient, tmp_content_dir: Path) -> None:
for i in range(5):
(tmp_content_dir / f"post-{i}.md").write_text(
f'+++\ntitle = "Post {i}"\n+++\n\nBody {i}.\n'
)
resp = client.get("/api/volumen/posts?page=1&limit=2")
data = resp.json()
assert data["has_next"] is True
assert data["has_prev"] is False
resp = client.get("/api/volumen/posts?page=2&limit=2")
data = resp.json()
assert data["has_next"] is True
assert data["has_prev"] is True
resp = client.get("/api/volumen/posts?page=3&limit=2")
data = resp.json()
assert data["has_next"] is False
assert data["has_prev"] is True
class TestRSSFeed:
def test_feed_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/feed.xml")
assert response.status_code == 200
ct = response.headers.get("content-type", "")
assert "application/rss+xml" in ct or "application/xml" in ct
body = response.text
assert '<rss version="2.0"' in body
assert "<title>Hello</title>" in body
assert "Draft" not in body
assert "<language>en</language>" in body
def test_rss_feed_includes_dc_creator_when_set(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "rss.md").write_text(
'+++\ntitle = "Rss"\nslug = "rss"\n'
'fediverse_creator = "@rss@example.com"\n+++\n\nBody.\n'
)
response = client.get("/api/volumen/feed.xml")
assert "<dc:creator>@rss@example.com</dc:creator>" in response.text
assert "xmlns:dc" in response.text
def test_rss_feed_omits_dc_creator_when_not_set(self, client: TestClient) -> None:
response = client.get("/api/volumen/feed.xml")
assert "<dc:creator>" not in response.text
class TestJSONFeed:
def test_json_feed_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/feed.json")
assert response.status_code == 200
data = response.json()
assert data["version"] == "https://jsonfeed.org/version/1.1"
assert data["title"] == "My Blog"
assert len(data["items"]) == 1
assert data["items"][0]["title"] == "Hello"
assert "<strong>world</strong>" in data["items"][0]["content_html"]
titles = [i["title"] for i in data["items"]]
assert "Draft" not in titles
def test_json_feed_includes_authors_from_post(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "jsonfed.md").write_text(
'+++\ntitle = "Jsonfed"\nslug = "jsonfed"\n'
'fediverse_creator = "@jf@example.io"\n+++\n\nBody.\n'
)
response = client.get("/api/volumen/feed.json")
items = response.json()["items"]
item = next(i for i in items if i.get("id", "").endswith("jsonfed"))
assert item["authors"] == [{"name": "@jf@example.io"}]
def test_json_feed_falls_back_to_site_default_fediverse_creator(
self, client: TestClient, tmp_content_dir: Path
) -> None:
pass # Requires a custom config with site.fediverse_creator set
class TestSitemap:
def test_sitemap_endpoint(self, client: TestClient) -> None:
response = client.get("/api/volumen/sitemap.xml")
assert response.status_code == 200
ct = response.headers.get("content-type", "")
assert "application/xml" in ct or "text/xml" in ct
body = response.text
assert "urlset" in body
assert "<loc>https://example.com/hello</loc>" in body
assert "draft" not in body
class TestFediverseCreator:
def test_post_summary_exposes_fediverse_creator(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "attributed.md").write_text(
'+++\ntitle = "Attributed"\nslug = "attributed"\n'
'fediverse_creator = "@ada@lovelace.org"\n+++\n\nBody.\n'
)
response = client.get("/api/volumen/posts/attributed")
assert response.status_code == 200
assert response.json()["fediverse_creator"] == "@ada@lovelace.org"
def test_post_summary_fediverse_creator_is_null_when_not_set(self, client: TestClient) -> None:
response = client.get("/api/volumen/posts/hello")
assert response.status_code == 200
assert response.json()["fediverse_creator"] is None
def test_posts_list_summary_exposes_fediverse_creator(
self, client: TestClient, tmp_content_dir: Path
) -> None:
(tmp_content_dir / "fed.md").write_text(
'+++\ntitle = "Fed"\nslug = "fed"\n'
'fediverse_creator = "@user@example.social"\n+++\n\nBody.\n'
)
response = client.get("/api/volumen/posts")
fed = next(p for p in response.json()["posts"] if p["slug"] == "fed")
assert fed["fediverse_creator"] == "@user@example.social"
class TestCacheHeaders:
def test_cache_control_on_api_responses(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert "cache-control" in response.headers
+130
View File
@@ -0,0 +1,130 @@
"""Tests for Store — post loading, saving, deleting, media handling."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from volumen.post import Post
from volumen.store import Store
class TestLoading:
def test_all_loads_every_post(self, store: Store) -> None:
posts = store.all()
assert len(posts) == 2 # hello.md + draft.md
def test_slug_falls_back_to_filename(self, store: Store) -> None:
slugs = sorted(p.slug for p in store.all())
assert slugs == ["draft", "hello"]
def test_lang_derived_from_subdirectory(self, tmp_path: Path) -> None:
content = tmp_path / "posts"
content.mkdir()
(content / "hello.md").write_text('+++\ntitle = "Hello"\n+++\n\nBody\n')
cs_dir = content / "cs"
cs_dir.mkdir()
(cs_dir / "ahoj.md").write_text('+++\ntitle = "Ahoj"\n+++\n\nTelo\n')
st = Store(str(content), default_lang="en")
assert st.find("ahoj").lang == "cs"
assert st.find("hello").lang == "en"
def test_find_returns_none_when_missing(self, store: Store) -> None:
assert store.find("nope") is None
def test_all_langs_post_visible_in_any_language(self, tmp_content_dir: Path) -> None:
(tmp_content_dir / "global.md").write_text(
'+++\ntitle = "Global"\nlang = "en"\nall_langs = true\n+++\n\nVisible everywhere.\n'
)
store = Store(str(tmp_content_dir), default_lang="en")
assert store.find("global", lang="cs") is not None
assert store.find("global", lang="en") is not None
def test_draft_post_is_loaded(self, store: Store) -> None:
draft = store.find("draft")
assert draft is not None
assert draft.draft is True
def test_post_with_missing_slug_derived_from_path(self, tmp_content_dir: Path) -> None:
(tmp_content_dir / "noslug.md").write_text('+++\ntitle = "No Slug"\n+++\n\nBody\n')
store = Store(str(tmp_content_dir), default_lang="en")
post = store.find("noslug")
assert post is not None
assert post.slug == "noslug"
assert post.lang == "en"
class TestSave:
def test_save_new_post(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
post = Post(metadata={"slug": "newpost", "title": "New"}, body="Content")
store.save(post)
assert os.path.isfile(os.path.join(str(tmp_content_dir), "newpost.md"))
def test_save_updates_existing_post(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
post = store.find("hello")
assert post is not None
post.body = "Updated content."
store.save(post)
reloaded = store.find("hello")
assert reloaded is not None
assert "Updated content." in reloaded.body
def test_save_rejects_path_traversal_slug(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir))
post = Post(metadata={"slug": "../../etc/passwd"}, body="x")
with pytest.raises(ValueError, match="slug escapes"):
store.save(post)
def test_save_uses_lang_subdirectory(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
post = Post(metadata={"slug": "bonjour", "lang": "fr"}, body="x")
store.save(post)
assert os.path.isfile(os.path.join(str(tmp_content_dir), "fr", "bonjour.md"))
def test_save_uses_root_for_default_language(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
post = Post(metadata={"slug": "hello-new", "lang": "en"}, body="x")
store.save(post)
assert os.path.isfile(os.path.join(str(tmp_content_dir), "hello-new.md"))
assert not os.path.isfile(os.path.join(str(tmp_content_dir), "en", "hello-new.md"))
class TestDelete:
def test_delete_post(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
path = os.path.join(str(tmp_content_dir), "hello.md")
assert os.path.isfile(path)
store.delete("hello")
assert not os.path.isfile(path)
class TestMedia:
def test_media_file_path_traversal_prevention(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir))
bad = store.media_file("../etc/passwd")
assert bad is None
def test_media_file_nonexistent(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir))
assert store.media_file("nonexistent.webp") is None
class TestCacheInvalidation:
def test_mtime_cache_invalidation(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
initial = len(store.all())
# Add a new file
(tmp_content_dir / "new.md").write_text('+++\ntitle = "New"\n+++\n\nBody\n')
assert len(store.all()) == initial + 1
def test_cache_invalidation_on_save(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
initial = len(store.all())
post = Post(metadata={"slug": "extra"}, body="x")
store.save(post)
assert len(store.all()) == initial + 1
+227
View File
@@ -0,0 +1,227 @@
"""Tests for Users — file-backed admin/author management."""
from __future__ import annotations
import os
from volumen.users import Users
class TestBootstrap:
def test_bootstrap_user_from_hash(self, users_path: str, bootstrap_hash: str) -> None:
users = Users(path=users_path, bootstrap_hash=bootstrap_hash)
assert len(users.all) == 1
assert users.authenticate("admin", "secret") is not None
def test_bootstrap_user_is_admin(self, users_path: str, bootstrap_hash: str) -> None:
users = Users(path=users_path, bootstrap_hash=bootstrap_hash)
admin = users.find("admin")
assert admin is not None
assert admin.role == "admin"
def test_no_users_without_file_or_hash(self, users_path: str) -> None:
users = Users(path=users_path)
assert not users.any
class TestFind:
def test_find_existing_user(self, users: Users) -> None:
assert users.find("admin") is not None
def test_find_nonexistent_user(self, users: Users) -> None:
assert users.find("nobody") is None
class TestAuthenticate:
def test_authenticate_correct_password(self, users: Users) -> None:
assert users.authenticate("admin", "secret") is not None
def test_authenticate_wrong_password(self, users: Users) -> None:
assert users.authenticate("admin", "nope") is None
def test_authenticate_nonexistent_user(self, users: Users) -> None:
assert users.authenticate("nobody", "secret") is None
class TestAdd:
def test_add_creates_user(self, users_path: str) -> None:
users = Users(path=users_path)
result = users.add("alice", "wonderland")
assert result is not None
assert result.username == "alice"
assert os.path.isfile(users_path)
def test_add_and_authenticate(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "wonderland")
assert users.authenticate("alice", "wonderland") is not None
assert users.authenticate("alice", "nope") is None
def test_add_rejects_duplicate(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
assert users.add("alice", "y") is None
def test_add_rejects_empty_username(self, users_path: str) -> None:
users = Users(path=users_path)
assert users.add("", "password") is None
def test_add_with_explicit_role(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("boss", "x", "admin")
assert users.find("boss").role == "admin"
def test_add_default_role_is_author(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
assert users.find("alice").role == "author"
def test_add_invalid_role_falls_back_to_default(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x", "wizard")
assert users.find("alice").role == "author"
class TestUpdate:
def test_update_name(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
users.update_name("alice", "Alice Smith")
assert users.find("alice").name == "Alice Smith"
def test_update_name_clear(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
users.update_name("alice", "Alice")
users.update_name("alice", None)
assert users.find("alice").name is None
def test_update_fediverse_creator(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
users.update_fediverse_creator("alice", "@alice@example.com")
assert users.find("alice").fediverse_creator == "@alice@example.com"
def test_update_fediverse_creator_clear(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
users.update_fediverse_creator("alice", "@alice@example.com")
users.update_fediverse_creator("alice", None)
assert users.find("alice").fediverse_creator is None
def test_update_photo(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
users.update_photo("alice", "/media/photo.webp")
assert users.find("alice").photo == "/media/photo.webp"
def test_update_password(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "old")
users.update_password("alice", "new")
assert users.authenticate("alice", "old") is None
assert users.authenticate("alice", "new") is not None
class TestRename:
def test_rename_user(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
assert users.rename("alice", "alicia") is not None
assert users.find("alice") is None
assert users.find("alicia") is not None
def test_rename_to_existing_username(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
users.add("bob", "y")
assert users.rename("alice", "bob") is None
assert users.find("alice") is not None
def test_rename_empty_new_name(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
assert users.rename("alice", "") is None
class TestSetRole:
def test_set_role(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert users.set_role("bob", "admin") is not None
assert users.find("bob").role == "admin"
def test_cannot_demote_last_admin(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert users.set_role("alice", "author") is None
assert users.find("alice").role == "admin"
def test_set_role_invalid(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
assert users.set_role("alice", "wizard") is None
class TestDelete:
def test_delete_user(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
users.add("bob", "y")
assert users.delete("bob") == "bob"
assert users.find("bob") is None
def test_cannot_delete_last_user(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
assert users.delete("alice") is None
assert users.find("alice") is not None
def test_cannot_delete_last_admin(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x", "admin")
users.add("bob", "y", "author")
assert users.delete("alice") is None
# After adding another admin, deletion works
users.add("carol", "z", "admin")
assert users.delete("alice") == "alice"
class TestPersistence:
def test_persistence_write_and_read(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "wonderland")
users.add("bob", "builder", "admin")
users.update_name("alice", "Alice")
# Read back from disk with a fresh instance
fresh = Users(path=users_path)
assert len(fresh.all) == 2
alice = fresh.find("alice")
assert alice is not None
assert alice.name == "Alice"
assert alice.role == "author"
bob = fresh.find("bob")
assert bob is not None
assert bob.role == "admin"
def test_password_survives_persistence(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "secret123")
fresh = Users(path=users_path)
assert fresh.authenticate("alice", "secret123") is not None
assert fresh.authenticate("alice", "wrong") is None
def test_mtime_cache_invalidation(self, users_path: str) -> None:
users = Users(path=users_path)
users.add("alice", "x")
assert len(users.all) == 1
# Directly modify the file behind the cache
with open(users_path, "w") as fh:
fh.write("")
# Should detect file change and return empty
assert len(users.all) == 0