Files
volumen/tests/test_users.py
T
2026-07-27 10:27:41 +02:00

256 lines
9.6 KiB
Python

"""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", "volumen-test-password") 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
def test_bootstrap_used_when_users_file_is_empty(
self, users_path: str, bootstrap_hash: str
) -> None:
# Regression: `volumen init` calls `users_file.touch(exist_ok=True)`
# which leaves a zero-byte users.toml. The bootstrap admin must
# still be usable so the very first login after `volumen init`
# works in production.
with open(users_path, "w") as fh:
fh.write("")
users = Users(path=users_path, bootstrap_hash=bootstrap_hash)
assert users.any
assert users.authenticate("admin", "volumen-test-password") is not None
def test_bootstrap_ignored_when_users_present(
self, users_path: str, bootstrap_hash: str
) -> None:
# Bootstrap must NOT shadow a populated users file — otherwise the
# configured `admin` user could log in with the bootstrap password
# even after their stored hash was rotated.
writer = Users(path=users_path)
writer.add("alice", "alice-test-password")
writer.add("bob", "bob-test-password")
reader = Users(path=users_path, bootstrap_hash=bootstrap_hash)
usernames = {u.username for u in reader.all}
# Only alice + bob from disk; no synthetic admin from bootstrap.
assert usernames == {"alice", "bob"}
class TestFind:
def test_find_existing_user(self, users: Users) -> None:
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", "volumen-test-password") 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", "volumen-test-password") 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", "alice-test-password")
fresh = Users(path=users_path)
assert fresh.authenticate("alice", "alice-test-password") 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