refactor: rewrite from Ruby/Sinatra to Python/FastAPI
This commit is contained in:
@@ -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."
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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('')
|
||||
assert "<figure>" in result
|
||||
|
||||
def test_image_without_title_no_wrapping(self) -> None:
|
||||
result = render("")
|
||||
assert "<figure>" not in result
|
||||
|
||||
def test_figure_includes_figcaption(self) -> None:
|
||||
result = render('')
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user