diff --git a/server/szurubooru/func/users.py b/server/szurubooru/func/users.py index da69524..969824c 100644 --- a/server/szurubooru/func/users.py +++ b/server/szurubooru/func/users.py @@ -12,10 +12,10 @@ class InvalidPasswordError(errors.ValidationError): pass class InvalidRankError(errors.ValidationError): pass class InvalidAvatarError(errors.ValidationError): pass -def _get_avatar_path(name): - return 'avatars/' + name.lower() + '.png' +def get_avatar_path(user_name): + return 'avatars/' + user_name.lower() + '.png' -def _get_avatar_url(user): +def get_avatar_url(user): assert user if user.avatar_style == user.AVATAR_GRAVATAR: assert user.email or user.name @@ -27,7 +27,7 @@ def _get_avatar_url(user): return '%s/avatars/%s.png' % ( config.config['data_url'].rstrip('/'), user.name.lower()) -def _get_email(user, auth_user, force_show_email): +def get_email(user, auth_user, force_show_email): assert user assert auth_user if not force_show_email \ @@ -36,14 +36,14 @@ def _get_email(user, auth_user, force_show_email): return False return user.email -def _get_liked_post_count(user, auth_user): +def get_liked_post_count(user, auth_user): assert user assert auth_user if auth_user.user_id != user.user_id: return False return user.liked_post_count -def _get_disliked_post_count(user, auth_user): +def get_disliked_post_count(user, auth_user): assert user assert auth_user if auth_user.user_id != user.user_id: @@ -60,16 +60,16 @@ def serialize_user(user, auth_user, options=None, force_show_email=False): 'version': lambda: user.version, 'rank': lambda: user.rank, 'avatarStyle': lambda: user.avatar_style, - 'avatarUrl': lambda: _get_avatar_url(user), + 'avatarUrl': lambda: get_avatar_url(user), 'commentCount': lambda: user.comment_count, 'uploadedPostCount': lambda: user.post_count, 'favoritePostCount': lambda: user.favorite_post_count, 'likedPostCount': - lambda: _get_liked_post_count(user, auth_user), + lambda: get_liked_post_count(user, auth_user), 'dislikedPostCount': - lambda: _get_disliked_post_count(user, auth_user), + lambda: get_disliked_post_count(user, auth_user), 'email': - lambda: _get_email(user, auth_user, force_show_email), + lambda: get_email(user, auth_user, force_show_email), }, options) @@ -127,16 +127,16 @@ def update_user_name(user, name): raise InvalidUserNameError('Name cannot be empty.') if util.value_exceeds_column_size(name, db.User.name): raise InvalidUserNameError('User name is too long.') - other_user = try_get_user_by_name(name) - if other_user and other_user.user_id != user.user_id: - raise UserAlreadyExistsError('User %r already exists.' % name) name = name.strip() name_regex = config.config['user_name_regex'] if not re.match(name_regex, name): raise InvalidUserNameError( 'User name %r must satisfy regex %r.' % (name, name_regex)) - if user.name and files.has(_get_avatar_path(user.name)): - files.move(_get_avatar_path(user.name), _get_avatar_path(name)) + other_user = try_get_user_by_name(name) + if other_user and other_user.user_id != user.user_id: + raise UserAlreadyExistsError('User %r already exists.' % name) + if user.name and files.has(get_avatar_path(user.name)): + files.move(get_avatar_path(user.name), get_avatar_path(name)) user.name = name def update_user_password(user, password): @@ -178,7 +178,7 @@ def update_user_rank(user, rank, auth_user): raise errors.AuthError('Trying to set higher rank than your own.') user.rank = rank -def update_user_avatar(user, avatar_style, avatar_content): +def update_user_avatar(user, avatar_style, avatar_content=None): assert user if avatar_style == 'gravatar': user.avatar_style = user.AVATAR_GRAVATAR diff --git a/server/szurubooru/tests/func/test_users.py b/server/szurubooru/tests/func/test_users.py new file mode 100644 index 0000000..ea494f1 --- /dev/null +++ b/server/szurubooru/tests/func/test_users.py @@ -0,0 +1,394 @@ +import unittest.mock +import pytest +from datetime import datetime +from szurubooru import db, errors +from szurubooru.func import auth, users, files, util + +EMPTY_PIXEL = \ + b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00' \ + b'\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x01\x00\x2c\x00\x00\x00\x00' \ + b'\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b' + +@pytest.mark.parametrize('user_name', ['test', 'TEST']) +def test_get_avatar_path(user_name): + assert users.get_avatar_path(user_name) == 'avatars/test.png' + +@pytest.mark.parametrize('user_name,user_email,avatar_style,expected_url', [ + ( + 'user', + None , + db.User.AVATAR_GRAVATAR, + 'https://gravatar.com/avatar/ee11cbb19052e40b07aac0ca060c23ee?d=retro&s=100', + ), + ( + None, + 'user@example.com' , + db.User.AVATAR_GRAVATAR, + 'https://gravatar.com/avatar/b58996c504c5638798eb6b511e6f49af?d=retro&s=100', + ), + ( + 'user', + 'user@example.com' , + db.User.AVATAR_GRAVATAR, + 'https://gravatar.com/avatar/b58996c504c5638798eb6b511e6f49af?d=retro&s=100', + ), + ( + 'user', + None , + db.User.AVATAR_MANUAL, + 'http://example.com/avatars/user.png', + ), +]) +def test_get_avatar_url( + user_name, user_email, avatar_style, expected_url, config_injector): + config_injector({ + 'data_url': 'http://example.com/', + 'thumbnails': {'avatar_width': 100}, + }) + user = db.User() + user.name = user_name + user.email = user_email + user.avatar_style = avatar_style + assert users.get_avatar_url(user) == expected_url + +@pytest.mark.parametrize( + 'same_user,can_edit_any_email,force_show,expected_email', [ + (False, False, False, False), + (True, False, False, 'test@example.com'), + (False, True, False, 'test@example.com'), + (False, False, True, 'test@example.com'), +]) +def test_get_email( + same_user, can_edit_any_email, force_show, expected_email, user_factory): + with unittest.mock.patch('szurubooru.func.auth.has_privilege'): + auth.has_privilege = lambda user, name: can_edit_any_email + user = user_factory() + user.email = 'test@example.com' + auth_user = user if same_user else user_factory() + db.session.add_all([user, auth_user]) + db.session.flush() + assert users.get_email(user, auth_user, force_show) == expected_email + +@pytest.mark.parametrize( + 'same_user,score,expected_liked_post_count,expected_disliked_post_count', [ + (False, 1, False, False), + (False, -1, False, False), + (True, 1, 1, 0), + (True, -1, 0, 1), +]) +def test_get_liked_post_count( + same_user, + score, + expected_liked_post_count, + expected_disliked_post_count, + user_factory, + post_factory): + user = user_factory() + post = post_factory() + auth_user = user if same_user else user_factory() + score = db.PostScore(post=post, user=user, score=score, time=datetime.now()) + db.session.add_all([post, user, score]) + db.session.flush() + assert users.get_liked_post_count(user, auth_user) == expected_liked_post_count + assert users.get_disliked_post_count(user, auth_user) == expected_disliked_post_count + +def test_serialize_user_when_empty(): + assert users.serialize_user(None, None) is None + +def test_serialize_user(user_factory): + with unittest.mock.patch('szurubooru.func.users.get_email'), \ + unittest.mock.patch('szurubooru.func.users.get_avatar_url'), \ + unittest.mock.patch('szurubooru.func.users.get_liked_post_count'), \ + unittest.mock.patch('szurubooru.func.users.get_disliked_post_count'): + users.get_email.return_value = 'test@example.com' + users.get_avatar_url.return_value = 'https://example.com/avatar.png' + users.get_liked_post_count.return_value = 66 + users.get_disliked_post_count.return_value = 33 + + auth_user = user_factory() + user = user_factory(name='dummy user') + user.creation_time = datetime(1997, 1, 1) + user.last_edit_time = datetime(1998, 1, 1) + user.avatar_style = db.User.AVATAR_MANUAL + user.rank = db.User.RANK_ADMINISTRATOR + db.session.add(user) + db.session.flush() + + assert users.serialize_user(user, auth_user) == { + 'version': 1, + 'name': 'dummy user', + 'email': 'test@example.com', + 'rank': 'administrator', + 'creationTime': datetime(1997, 1, 1, 0, 0), + 'lastLoginTime': None, + 'avatarStyle': 'manual', + 'avatarUrl': 'https://example.com/avatar.png', + 'likedPostCount': 66, + 'dislikedPostCount': 33, + 'commentCount': 0, + 'favoritePostCount': 0, + 'uploadedPostCount': 0, + } + +def test_serialize_micro_user(user_factory): + with unittest.mock.patch('szurubooru.func.users.get_avatar_url'): + users.get_avatar_url.return_value = 'https://example.com/avatar.png' + auth_user = user_factory() + user = user_factory(name='dummy user') + db.session.add(user) + db.session.flush() + assert users.serialize_micro_user(user, auth_user) == { + 'name': 'dummy user', + 'avatarUrl': 'https://example.com/avatar.png', + } + +@pytest.mark.parametrize('count', [0, 1, 2]) +def test_get_user_count(count, user_factory): + for _ in range(count): + db.session.add(user_factory()) + db.session.flush() + assert users.get_user_count() == count + +def test_try_get_user_by_name(user_factory): + user = user_factory(name='name', email='email') + db.session.add(user) + assert users.try_get_user_by_name('non-existing') is None + assert users.try_get_user_by_name('email') is None + assert users.try_get_user_by_name('name') is user + assert users.try_get_user_by_name('NAME') is user + +def test_get_user_by_name(user_factory): + user = user_factory(name='name', email='email') + db.session.add(user) + with pytest.raises(users.UserNotFoundError): + assert users.get_user_by_name('non-existing') + with pytest.raises(users.UserNotFoundError): + assert users.get_user_by_name('email') + assert users.get_user_by_name('name') is user + assert users.get_user_by_name('NAME') is user + +def test_try_get_user_by_name_or_email(user_factory): + user = user_factory(name='name', email='email') + db.session.add(user) + assert users.try_get_user_by_name_or_email('non-existing') is None + assert users.try_get_user_by_name_or_email('email') is user + assert users.try_get_user_by_name_or_email('EMAIL') is user + assert users.try_get_user_by_name_or_email('name') is user + assert users.try_get_user_by_name_or_email('NAME') is user + +def test_get_user_by_name_or_email(user_factory): + user = user_factory(name='name', email='email') + db.session.add(user) + with pytest.raises(users.UserNotFoundError): + assert users.get_user_by_name_or_email('non-existing') + assert users.get_user_by_name_or_email('email') is user + assert users.get_user_by_name_or_email('EMAIL') is user + assert users.get_user_by_name_or_email('name') is user + assert users.get_user_by_name_or_email('NAME') is user + +def test_create_user_for_first_user(fake_datetime): + with unittest.mock.patch('szurubooru.func.users.update_user_name'), \ + unittest.mock.patch('szurubooru.func.users.update_user_password'), \ + unittest.mock.patch('szurubooru.func.users.update_user_email'), \ + fake_datetime('1997-01-01'): + user = users.create_user('name', 'password', 'email') + assert user.creation_time == datetime(1997, 1, 1) + assert user.last_login_time is None + assert user.rank == db.User.RANK_ADMINISTRATOR + users.update_user_name.assert_called_once_with(user, 'name') + users.update_user_password.assert_called_once_with(user, 'password') + users.update_user_email.assert_called_once_with(user, 'email') + +def test_create_user_for_subsequent_users(user_factory, config_injector): + config_injector({'default_rank': 'regular'}) + db.session.add(user_factory()) + db.session.flush() + with unittest.mock.patch('szurubooru.func.users.update_user_name'), \ + unittest.mock.patch('szurubooru.func.users.update_user_password'), \ + unittest.mock.patch('szurubooru.func.users.update_user_email'): + user = users.create_user('name', 'password', 'email') + assert user.rank == db.User.RANK_REGULAR + +def test_update_user_name_with_empty_string(user_factory): + user = user_factory() + with pytest.raises(users.InvalidUserNameError): + users.update_user_name(user, None) + +def test_update_user_name_with_too_long_string(user_factory): + user = user_factory() + with pytest.raises(users.InvalidUserNameError): + users.update_user_name(user, 'a' * 300) + +def test_update_user_name_with_invalid_name(user_factory, config_injector): + config_injector({'user_name_regex': '^[a-z]+$'}) + user = user_factory() + with pytest.raises(users.InvalidUserNameError): + users.update_user_name(user, '0') + +def test_update_user_name_with_duplicate_name(user_factory, config_injector): + config_injector({'user_name_regex': '^[a-z]+$'}) + user = user_factory() + existing_user = user_factory(name='dummy') + db.session.add(existing_user) + db.session.flush() + with pytest.raises(users.UserAlreadyExistsError): + users.update_user_name(user, 'dummy') + +def test_update_user_name_reusing_own_name(user_factory, config_injector): + config_injector({'user_name_regex': '^[a-z]+$'}) + user = user_factory(name='dummy') + db.session.add(user) + db.session.flush() + with unittest.mock.patch('szurubooru.func.files.has'): + files.has.return_value = False + users.update_user_name(user, 'dummy') + db.session.flush() + assert users.try_get_user_by_name('dummy') is user + +def test_update_user_name_for_new_user(user_factory, config_injector): + config_injector({'user_name_regex': '^[a-z]+$'}) + user = user_factory() + with unittest.mock.patch('szurubooru.func.files.has'): + files.has.return_value = False + users.update_user_name(user, 'dummy') + assert user.name == 'dummy' + +def test_update_user_name_moves_avatar(user_factory, config_injector): + config_injector({'user_name_regex': '^[a-z]+$'}) + user = user_factory(name='old') + with unittest.mock.patch('szurubooru.func.files.has'), \ + unittest.mock.patch('szurubooru.func.files.move'): + files.has.return_value = True + users.update_user_name(user, 'new') + files.move.assert_called_once_with('avatars/old.png', 'avatars/new.png') + +def test_update_user_password_with_empty_string(user_factory): + user = user_factory() + with pytest.raises(users.InvalidPasswordError): + users.update_user_password(user, None) + +def test_update_user_password_with_invalid_string(user_factory, config_injector): + config_injector({'password_regex': '^[a-z]+$'}) + user = user_factory() + with pytest.raises(users.InvalidPasswordError): + users.update_user_password(user, '0') + +def test_update_user_password(user_factory, config_injector): + config_injector({'password_regex': '^[a-z]+$'}) + user = user_factory() + with unittest.mock.patch('szurubooru.func.auth.create_password'), \ + unittest.mock.patch('szurubooru.func.auth.get_password_hash'): + auth.create_password.return_value = 'salt' + auth.get_password_hash.return_value = 'hash' + users.update_user_password(user, 'a') + assert user.password_salt == 'salt' + assert user.password_hash == 'hash' + +def test_update_user_email_with_too_long_string(user_factory): + user = user_factory() + with pytest.raises(users.InvalidEmailError): + users.update_user_email(user, 'a' * 300) + +def test_update_user_email_with_invalid_email(user_factory): + user = user_factory() + with unittest.mock.patch('szurubooru.func.util.is_valid_email'): + util.is_valid_email.return_value = False + with pytest.raises(users.InvalidEmailError): + users.update_user_email(user, 'a') + +def test_update_user_email_with_empty_string(user_factory): + user = user_factory() + with unittest.mock.patch('szurubooru.func.util.is_valid_email'): + util.is_valid_email.return_value = True + users.update_user_email(user, '') + assert user.email is None + +def test_update_user_email(user_factory): + user = user_factory() + with unittest.mock.patch('szurubooru.func.util.is_valid_email'): + util.is_valid_email.return_value = True + users.update_user_email(user, 'a') + assert user.email == 'a' + +def test_update_user_rank_with_empty_string(user_factory): + user = user_factory() + auth_user = user_factory() + with pytest.raises(users.InvalidRankError): + users.update_user_rank(user, '', auth_user) + +def test_update_user_rank_with_invalid_string(user_factory): + user = user_factory() + auth_user = user_factory() + with pytest.raises(users.InvalidRankError): + users.update_user_rank(user, 'invalid', auth_user) + with pytest.raises(users.InvalidRankError): + users.update_user_rank(user, 'anonymous', auth_user) + with pytest.raises(users.InvalidRankError): + users.update_user_rank(user, 'nobody', auth_user) + +def test_update_user_rank_with_higher_rank_than_possible(user_factory): + db.session.add(user_factory()) + user = user_factory() + auth_user = user_factory() + auth_user.rank = db.User.RANK_ANONYMOUS + with pytest.raises(errors.AuthError): + users.update_user_rank(user, 'regular', auth_user) + with pytest.raises(errors.AuthError): + users.update_user_rank(auth_user, 'regular', auth_user) + +def test_update_user_rank(user_factory): + db.session.add(user_factory()) + user = user_factory() + auth_user = user_factory() + auth_user.rank = db.User.RANK_ADMINISTRATOR + users.update_user_rank(user, 'regular', auth_user) + users.update_user_rank(auth_user, 'regular', auth_user) + assert user.rank == db.User.RANK_REGULAR + assert auth_user.rank == db.User.RANK_REGULAR + +def test_update_user_avatar_with_invalid_style(user_factory): + user = user_factory() + with pytest.raises(users.InvalidAvatarError): + users.update_user_avatar(user, 'invalid', b'') + +def test_update_user_avatar_to_gravatar(user_factory): + user = user_factory() + users.update_user_avatar(user, 'gravatar') + assert user.avatar_style == db.User.AVATAR_GRAVATAR + +def test_update_user_avatar_to_empty_manual(user_factory): + user = user_factory() + with unittest.mock.patch('szurubooru.func.files.has'), \ + pytest.raises(users.InvalidAvatarError): + files.has.return_value = False + users.update_user_avatar(user, 'manual', b'') + +def test_update_user_avatar_to_previous_manual(user_factory): + user = user_factory() + with unittest.mock.patch('szurubooru.func.files.has'): + files.has.return_value = True + users.update_user_avatar(user, 'manual', b'') + +def test_update_user_avatar_to_new_manual(user_factory, config_injector): + config_injector({'thumbnails': {'avatar_width': 500, 'avatar_height': 500}}) + user = user_factory() + with unittest.mock.patch('szurubooru.func.files.save'): + users.update_user_avatar(user, 'manual', EMPTY_PIXEL) + assert user.avatar_style == db.User.AVATAR_MANUAL + assert files.save.called + +def test_bump_user_login_time(user_factory, fake_datetime): + user = user_factory() + with fake_datetime('1997-01-01'): + users.bump_user_login_time(user) + assert user.last_login_time == datetime(1997, 1, 1) + +def test_reset_user_password(user_factory): + with unittest.mock.patch('szurubooru.func.auth.create_password'), \ + unittest.mock.patch('szurubooru.func.auth.get_password_hash'): + user = user_factory() + auth.create_password.return_value = 'salt' + auth.get_password_hash.return_value = 'hash' + users.reset_user_password(user) + assert user.password_salt == 'salt' + assert user.password_hash == 'hash'