import os import pytest from argon2 import PasswordHasher from app import audit from app.crypto import SecretBox from app.db import Database from app.users import MIN_PASSWORD_LEN, ROLE_ADMIN, ROLE_VIEWER, UserError, UserStore PASSWORD = "correct horse battery" ACTOR = "tester" @pytest.fixture def db(): return Database(":memory:") @pytest.fixture def store(db): hasher = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1) return UserStore(db, SecretBox(os.urandom(32)), hasher) def test_create_and_authenticate(store): created = store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) assert created.username == "alice" assert created.is_admin assert not created.has_totp assert store.authenticate("alice", PASSWORD).id == created.id assert store.count() == 1 def test_wrong_password(store): store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) assert store.authenticate("alice", "wrong password!!") is None def test_unknown_user(store): assert store.authenticate("ghost", PASSWORD) is None def test_password_too_short(store): with pytest.raises(UserError): store.create("alice", "x" * (MIN_PASSWORD_LEN - 1), ROLE_ADMIN, ACTOR) @pytest.mark.parametrize("name", ["ab", "a b c", "ümlaut", "x" * 65, "", "a/b"]) def test_invalid_username(store, name): with pytest.raises(UserError): store.create(name, PASSWORD, ROLE_VIEWER, ACTOR) def test_invalid_role(store): with pytest.raises(UserError): store.create("alice", PASSWORD, "root", ACTOR) def test_duplicate_case_insensitive(store): store.create("Alice", PASSWORD, ROLE_ADMIN, ACTOR) with pytest.raises(UserError): store.create("alice", PASSWORD, ROLE_VIEWER, ACTOR) def test_last_admin_guard_set_role(store): admin = store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) with pytest.raises(UserError): store.set_role(admin.id, ROLE_VIEWER, ACTOR) def test_last_admin_guard_set_disabled(store): admin = store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) with pytest.raises(UserError): store.set_disabled(admin.id, True, ACTOR) def test_last_admin_guard_delete(store): admin = store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) with pytest.raises(UserError): store.delete(admin.id, ACTOR) def test_second_admin_allows_changes(store): first = store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) store.create("bob", PASSWORD, ROLE_ADMIN, ACTOR) store.set_role(first.id, ROLE_VIEWER, ACTOR) assert store.get(first.id).role == ROLE_VIEWER store.set_disabled(first.id, True, ACTOR) assert store.get(first.id).disabled store.delete(first.id, ACTOR) assert store.get(first.id) is None def test_viewer_can_be_removed_freely(store): store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) viewer = store.create("bob", PASSWORD, ROLE_VIEWER, ACTOR) store.set_disabled(viewer.id, True, ACTOR) store.delete(viewer.id, ACTOR) assert store.count() == 1 def test_set_password_bumps_session_version(store): user = store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) store.set_password(user.id, "another long password", ACTOR) updated = store.get(user.id) assert updated.session_version == user.session_version + 1 assert store.authenticate("alice", PASSWORD) is None assert store.authenticate("alice", "another long password") is not None def test_set_password_validates_length(store): user = store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) with pytest.raises(UserError): store.set_password(user.id, "short", ACTOR) def test_disabled_user_cannot_login(store): store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) bob = store.create("bob", PASSWORD, ROLE_VIEWER, ACTOR) store.set_disabled(bob.id, True, ACTOR) assert store.authenticate("bob", PASSWORD) is None store.set_disabled(bob.id, False, ACTOR) assert store.authenticate("bob", PASSWORD) is not None def test_unknown_user_id(store): with pytest.raises(UserError): store.set_role(999, ROLE_VIEWER, ACTOR) def test_totp_secret_encrypted_and_roundtrip(store, db): user = store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" store.set_totp(user.id, secret, ACTOR) raw = db.query("SELECT totp_secret FROM users WHERE id=?", (user.id,))[0]["totp_secret"] assert raw and secret not in raw assert store.totp_secret(user.id) == secret assert store.get(user.id).has_totp store.set_totp(user.id, None, ACTOR) assert store.totp_secret(user.id) is None assert not store.get(user.id).has_totp def test_audit_entries_without_password(store, db): user = store.create("alice", PASSWORD, ROLE_ADMIN, ACTOR) store.set_password(user.id, "another long password", ACTOR) store.set_totp(user.id, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", ACTOR) entries = audit.recent(db) actions = {e["action"] for e in entries} assert {"user.create", "user.password", "user.totp.enable"} <= actions dump = repr(entries) assert PASSWORD not in dump assert "another long password" not in dump assert "GEZDGNBVGY3TQOJQ" not in dump