2026-07-22 21:12:17 +02:00
|
|
|
"""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
|
2026-07-26 18:15:29 +02:00
|
|
|
assert users.authenticate("admin", "volumen-test-password") is not None
|
2026-07-22 21:12:17 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-07-27 10:27:41 +02:00
|
|
|
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"}
|
|
|
|
|
|
2026-07-22 21:12:17 +02:00
|
|
|
|
|
|
|
|
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:
|
2026-07-26 18:15:29 +02:00
|
|
|
assert users.authenticate("admin", "volumen-test-password") is not None
|
2026-07-22 21:12:17 +02:00
|
|
|
|
|
|
|
|
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:
|
2026-07-26 18:15:29 +02:00
|
|
|
assert users.authenticate("nobody", "volumen-test-password") is None
|
2026-07-22 21:12:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-26 18:15:29 +02:00
|
|
|
users.add("alice", "alice-test-password")
|
2026-07-22 21:12:17 +02:00
|
|
|
fresh = Users(path=users_path)
|
2026-07-26 18:15:29 +02:00
|
|
|
assert fresh.authenticate("alice", "alice-test-password") is not None
|
2026-07-22 21:12:17 +02:00
|
|
|
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
|