156 lines
5.1 KiB
Python
156 lines
5.1 KiB
Python
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
|