import time from datetime import datetime, timezone from typing import TYPE_CHECKING, Any from unittest import mock import orjson from django.http import HttpRequest from django.test import override_settings from zerver.lib.initial_password import initial_password from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_helpers import get_test_image_file, ratelimit_rule from zerver.lib.users import get_all_api_keys from zerver.models import Draft, ScheduledMessageNotificationEmail, UserProfile from zerver.models.scheduled_jobs import NotificationTriggers from zerver.models.users import get_user_profile_by_api_key if TYPE_CHECKING: from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse class ChangeSettingsTest(ZulipTestCase): # TODO: requires method consolidation, right now, there's no alternative # for check_for_toggle_param for PATCH. def check_for_toggle_param_patch(self, pattern: str, param: str) -> None: self.login("hamlet") user_profile = self.example_user("hamlet") json_result = self.client_patch(pattern, {param: orjson.dumps(True).decode()}) self.assert_json_success(json_result) # refetch user_profile object to correctly handle caching user_profile = self.example_user("hamlet") self.assertEqual(getattr(user_profile, param), True) json_result = self.client_patch(pattern, {param: orjson.dumps(False).decode()}) self.assert_json_success(json_result) # refetch user_profile object to correctly handle caching user_profile = self.example_user("hamlet") self.assertEqual(getattr(user_profile, param), False) def test_successful_change_settings(self) -> None: """ A call to /json/settings with valid parameters changes the user's settings correctly and returns correct values. """ user = self.example_user("hamlet") self.login_user(user) json_result = self.client_patch( "/json/settings", dict( full_name="Foo Bar", old_password=initial_password(user.delivery_email), new_password="foobar1", ), ) self.assert_json_success(json_result) user.refresh_from_db() self.assertEqual(user.full_name, "Foo Bar") self.logout() # This is one of the few places we log in directly # with Django's client (to test the password change # with as few moving parts as possible). request = HttpRequest() request.session = self.client.session self.assertTrue( self.client.login( request=request, username=user.delivery_email, password="foobar1", realm=user.realm, ), ) self.assert_logged_in_user_id(user.id) def test_password_change_check_strength(self) -> None: self.login("hamlet") with self.settings(PASSWORD_MIN_LENGTH=3, PASSWORD_MIN_GUESSES=1000): json_result = self.client_patch( "/json/settings", dict( full_name="Foo Bar", old_password=initial_password(self.example_email("hamlet")), new_password="easy", ), ) self.assert_json_error(json_result, "New password is too weak!") json_result = self.client_patch( "/json/settings", dict( full_name="Foo Bar", old_password=initial_password(self.example_email("hamlet")), new_password="f657gdGGk9", ), ) self.assert_json_success(json_result) def test_illegal_name_changes(self) -> None: user = self.example_user("hamlet") self.login_user(user) full_name = user.full_name with self.settings(NAME_CHANGES_DISABLED=True): json_result = self.client_patch("/json/settings", dict(full_name="Foo Bar")) # We actually fail silently here, since this only happens if # somebody is trying to game our API, and there's no reason to # give them the courtesy of an error reason. self.assert_json_success(json_result) user = self.example_user("hamlet") self.assertEqual(user.full_name, full_name) # Now try a too-long name json_result = self.client_patch("/json/settings", dict(full_name="x" * 1000)) self.assert_json_error(json_result, "Name too long!") # Now try too-short names short_names = ["", "x"] for name in short_names: json_result = self.client_patch("/json/settings", dict(full_name=name)) self.assert_json_error(json_result, "Name too short!") def test_illegal_characters_in_name_changes(self) -> None: self.login("hamlet") # Make sure unicode works json_result = self.client_patch("/json/settings", dict(full_name="BLÅHAJ")) self.assert_json_success(json_result) # Make sure zero-width-joiners work json_result = self.client_patch("/json/settings", dict(full_name="BLÅHAJ 🏳️‍⚧️")) self.assert_json_success(json_result) # Now try a name with invalid characters json_result = self.client_patch("/json/settings", dict(full_name="Opheli*")) self.assert_json_error(json_result, "Invalid characters in name!") def test_change_email_to_disposable_email(self) -> None: hamlet = self.example_user("hamlet") self.login_user(hamlet) realm = hamlet.realm realm.disallow_disposable_email_addresses = True realm.emails_restricted_to_domains = False realm.save() json_result = self.client_patch("/json/settings", dict(email="hamlet@mailnator.com")) self.assert_json_error(json_result, "Please use your real email address.") def test_change_email_batching_period(self) -> None: hamlet = self.example_user("hamlet") cordelia = self.example_user("cordelia") othello = self.example_user("othello") self.login_user(hamlet) # Default is two minutes self.assertEqual(hamlet.email_notifications_batching_period_seconds, 120) result = self.client_patch( "/json/settings", {"email_notifications_batching_period_seconds": -1} ) self.assert_json_error(result, "Invalid email batching period: -1 seconds") result = self.client_patch( "/json/settings", {"email_notifications_batching_period_seconds": 7 * 24 * 60 * 60 + 10} ) self.assert_json_error(result, "Invalid email batching period: 604810 seconds") result = self.client_patch( "/json/settings", {"email_notifications_batching_period_seconds": 5 * 60} ) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.email_notifications_batching_period_seconds, 300) # Test that timestamps get updated for existing ScheduledMessageNotificationEmail rows hamlet_msg_id1 = self.send_stream_message(sender=cordelia, stream_name="Verona") hamlet_msg_id2 = self.send_stream_message(sender=cordelia, stream_name="Verona") othello_msg_id1 = self.send_stream_message(sender=cordelia, stream_name="Verona") def create_entry(user_profile_id: int, message_id: int, timestamp: datetime) -> int: # The above messages don't actually mention anyone. We just fill up the trigger # because we need to. entry = ScheduledMessageNotificationEmail.objects.create( user_profile_id=user_profile_id, message_id=message_id, trigger=NotificationTriggers.MENTION, scheduled_timestamp=timestamp, ) return entry.id def get_datetime_object(minutes: int) -> datetime: return datetime( year=2021, month=8, day=10, hour=10, minute=minutes, second=15, tzinfo=timezone.utc ) hamlet_timestamp = get_datetime_object(10) othello_timestamp = get_datetime_object(20) hamlet_entry1_id = create_entry(hamlet.id, hamlet_msg_id1, hamlet_timestamp) hamlet_entry2_id = create_entry(hamlet.id, hamlet_msg_id2, hamlet_timestamp) othello_entry1_id = create_entry(othello.id, othello_msg_id1, othello_timestamp) # Update Hamlet's setting from 300 seconds (5 minutes) to 600 seconds (10 minutes) self.assertEqual(hamlet.email_notifications_batching_period_seconds, 300) result = self.client_patch( "/json/settings", {"email_notifications_batching_period_seconds": 10 * 60} ) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.email_notifications_batching_period_seconds, 10 * 60) def check_scheduled_timestamp(entry_id: int, expected_timestamp: datetime) -> None: entry = ScheduledMessageNotificationEmail.objects.get(id=entry_id) self.assertEqual(entry.scheduled_timestamp, expected_timestamp) # For Hamlet, the new scheduled timestamp should have been updated expected_hamlet_timestamp = get_datetime_object(15) check_scheduled_timestamp(hamlet_entry1_id, expected_hamlet_timestamp) check_scheduled_timestamp(hamlet_entry2_id, expected_hamlet_timestamp) # Nothing should have changed for Othello check_scheduled_timestamp(othello_entry1_id, othello_timestamp) def test_toggling_boolean_user_settings(self) -> None: """Test updating each boolean setting in UserProfile property_types""" boolean_settings = ( s for s in UserProfile.property_types if UserProfile.property_types[s] is bool # Dense mode can't be toggled without changing other settings too. # This setting is tested in test_changing_information_density_settings. and s not in ["dense_mode"] ) for user_setting in boolean_settings: self.check_for_toggle_param_patch("/json/settings", user_setting) def test_wrong_old_password(self) -> None: self.login("hamlet") result = self.client_patch( "/json/settings", dict( old_password="bad_password", new_password="ignored", ), ) self.assert_json_error(result, "Wrong password!") @override_settings(RATE_LIMITING_AUTHENTICATE=True) @ratelimit_rule(10, 2, domain="authenticate_by_username") def test_wrong_old_password_rate_limiter(self) -> None: self.login("hamlet") start_time = time.time() with mock.patch("time.time", return_value=start_time): result = self.client_patch( "/json/settings", dict( old_password="bad_password", new_password="ignored", ), ) self.assert_json_error(result, "Wrong password!") result = self.client_patch( "/json/settings", dict( old_password="bad_password", new_password="ignored", ), ) self.assert_json_error(result, "Wrong password!") # We're over the limit, so we'll get blocked even with the correct password. result = self.client_patch( "/json/settings", dict( old_password=initial_password(self.example_email("hamlet")), new_password="ignored", ), ) self.assert_json_error( result, "You're making too many attempts! Try again in 10 seconds." ) # After time passes, we should be able to succeed if we give the correct password. with mock.patch("time.time", return_value=start_time + 11): json_result = self.client_patch( "/json/settings", dict( old_password=initial_password(self.example_email("hamlet")), new_password="foobar1", ), ) self.assert_json_success(json_result) @override_settings( AUTHENTICATION_BACKENDS=( "zproject.backends.ZulipLDAPAuthBackend", "zproject.backends.EmailAuthBackend", "zproject.backends.ZulipDummyBackend", ) ) def test_change_password_ldap_backend(self) -> None: self.init_default_ldap_database() ldap_user_attr_map = {"full_name": "cn", "short_name": "sn"} self.login("hamlet") with self.settings( LDAP_APPEND_DOMAIN="zulip.com", AUTH_LDAP_USER_ATTR_MAP=ldap_user_attr_map ): result = self.client_patch( "/json/settings", dict( old_password=initial_password(self.example_email("hamlet")), new_password="ignored", ), ) self.assert_json_error(result, "Your Zulip password is managed in LDAP") result = self.client_patch( "/json/settings", dict( old_password=self.ldap_password("hamlet"), # hamlet's password in LDAP new_password="ignored", ), ) self.assert_json_error(result, "Your Zulip password is managed in LDAP") with ( self.settings( LDAP_APPEND_DOMAIN="example.com", AUTH_LDAP_USER_ATTR_MAP=ldap_user_attr_map ), self.assertLogs("zulip.ldap", "DEBUG") as debug_log, ): result = self.client_patch( "/json/settings", dict( old_password=initial_password(self.example_email("hamlet")), new_password="ignored", ), ) self.assert_json_success(result) self.assertEqual( debug_log.output, [ "DEBUG:zulip.ldap:ZulipLDAPAuthBackend: Email hamlet@zulip.com does not match LDAP domain example.com." ], ) with self.settings(LDAP_APPEND_DOMAIN=None, AUTH_LDAP_USER_ATTR_MAP=ldap_user_attr_map): result = self.client_patch( "/json/settings", dict( old_password=initial_password(self.example_email("hamlet")), new_password="ignored", ), ) self.assert_json_error(result, "Your Zulip password is managed in LDAP") def do_test_change_user_setting(self, setting_name: str) -> None: test_changes: dict[str, Any] = dict( default_language="de", web_home_view="all_messages", emojiset="google", timezone="America/Denver", demote_inactive_streams=2, web_mark_read_on_scroll_policy=2, web_channel_default_view=2, user_list_style=2, web_animate_image_previews="on_hover", web_stream_unreads_count_display_policy=2, web_font_size_px=14, web_line_height_percent=122, color_scheme=2, email_notifications_batching_period_seconds=100, notification_sound="ding", desktop_icon_count_display=2, email_address_visibility=3, realm_name_in_email_notifications_policy=2, automatically_follow_topics_policy=1, automatically_unmute_topics_in_muted_streams_policy=1, ) self.login("hamlet") test_value = test_changes.get(setting_name) # Error if a setting in UserProfile.property_types does not have test values if test_value is None: raise AssertionError(f"No test created for {setting_name}") if setting_name not in [ "demote_inactive_streams", "user_list_style", "color_scheme", "web_mark_read_on_scroll_policy", "web_channel_default_view", "web_stream_unreads_count_display_policy", ]: data = {setting_name: test_value} else: data = {setting_name: orjson.dumps(test_value).decode()} result = self.client_patch("/json/settings", data) self.assert_json_success(result) user_profile = self.example_user("hamlet") self.assertEqual(getattr(user_profile, setting_name), test_value) def test_change_user_setting(self) -> None: """Test updating each non-boolean setting in UserProfile property_types""" user_settings = ( s for s in UserProfile.property_types if UserProfile.property_types[s] is not bool ) for setting in user_settings: self.do_test_change_user_setting(setting) self.do_test_change_user_setting("timezone") def test_invalid_setting_value(self) -> None: invalid_values: list[dict[str, Any]] = [ { "setting_name": "default_language", "value": "invalid_de", "error_msg": "Invalid default_language", }, { "setting_name": "web_home_view", "value": "invalid_view", "error_msg": "Invalid web_home_view: Value error, Not in the list of possible values", }, { "setting_name": "emojiset", "value": "apple", "error_msg": "Invalid emojiset: Value error, Not in the list of possible values", }, { "setting_name": "timezone", "value": "invalid_US/Mountain", "error_msg": "Invalid timezone: Value error, Not a recognized time zone", }, { "setting_name": "demote_inactive_streams", "value": 10, "error_msg": "Invalid demote_inactive_streams: Value error, Not in the list of possible values", }, { "setting_name": "web_mark_read_on_scroll_policy", "value": 10, "error_msg": "Invalid web_mark_read_on_scroll_policy: Value error, Not in the list of possible values", }, { "setting_name": "web_channel_default_view", "value": 10, "error_msg": "Invalid web_channel_default_view: Value error, Not in the list of possible values", }, { "setting_name": "user_list_style", "value": 10, "error_msg": "Invalid user_list_style: Value error, Not in the list of possible values", }, { "setting_name": "web_animate_image_previews", "value": "invalid_value", "error_msg": "Invalid web_animate_image_previews: Value error, Not in the list of possible values", }, { "setting_name": "web_stream_unreads_count_display_policy", "value": 10, "error_msg": "Invalid web_stream_unreads_count_display_policy: Value error, Not in the list of possible values", }, { "setting_name": "color_scheme", "value": 10, "error_msg": "Invalid color_scheme: Value error, Not in the list of possible values", }, { "setting_name": "notification_sound", "value": "invalid_sound", "error_msg": "Invalid notification sound '\"invalid_sound\"'", }, { "setting_name": "desktop_icon_count_display", "value": 10, "error_msg": "Invalid desktop_icon_count_display: Value error, Not in the list of possible values", }, ] self.login("hamlet") for invalid_value in invalid_values: if isinstance(invalid_value["value"], str): invalid_value["value"] = orjson.dumps(invalid_value["value"]).decode() req = {invalid_value["setting_name"]: invalid_value["value"]} result = self.client_patch("/json/settings", req) self.assert_json_error(result, invalid_value["error_msg"]) hamlet = self.example_user("hamlet") self.assertNotEqual( getattr(hamlet, invalid_value["setting_name"]), invalid_value["value"] ) def do_change_emojiset(self, emojiset: str) -> "TestHttpResponse": self.login("hamlet") data = {"emojiset": emojiset} result = self.client_patch("/json/settings", data) return result def test_emojiset(self) -> None: """Test banned emoji sets are not accepted.""" banned_emojisets = ["apple", "emojione"] valid_emojisets = ["google", "google-blob", "text", "twitter"] for emojiset in banned_emojisets: result = self.do_change_emojiset(emojiset) self.assert_json_error( result, "Invalid emojiset: Value error, Not in the list of possible values" ) for emojiset in valid_emojisets: result = self.do_change_emojiset(emojiset) self.assert_json_success(result) def test_avatar_changes_disabled(self) -> None: self.login("hamlet") with self.settings(AVATAR_CHANGES_DISABLED=True): result = self.client_delete("/json/users/me/avatar") self.assert_json_error(result, "Avatar changes are disabled in this organization.", 400) with self.settings(AVATAR_CHANGES_DISABLED=True): with get_test_image_file("img.png") as fp1: result = self.client_post("/json/users/me/avatar", {"f1": fp1}) self.assert_json_error(result, "Avatar changes are disabled in this organization.", 400) def test_invalid_setting_name(self) -> None: self.login("hamlet") # Now try an invalid setting name result = self.client_patch("/json/settings", dict(invalid_setting="value")) self.assert_json_success(result, ignored_parameters=["invalid_setting"]) def test_changing_setting_using_display_setting_endpoint(self) -> None: """ This test is just for adding coverage for `/settings/display` endpoint which is now deprecated. """ self.login("hamlet") result = self.client_patch( "/json/settings/display", dict(color_scheme=UserProfile.COLOR_SCHEME_DARK) ) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.color_scheme, UserProfile.COLOR_SCHEME_DARK) def test_changing_setting_using_notification_setting_endpoint(self) -> None: """ This test is just for adding coverage for `/settings/notifications` endpoint which is now deprecated. """ self.login("hamlet") result = self.client_patch( "/json/settings/notifications", dict(enable_stream_desktop_notifications=orjson.dumps(True).decode()), ) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.enable_stream_desktop_notifications, True) def test_changing_information_density_settings(self) -> None: hamlet = self.example_user("hamlet") hamlet.dense_mode = True hamlet.web_font_size_px = 14 hamlet.web_line_height_percent = 122 hamlet.save() self.login("hamlet") data: dict[str, str | int] = {"web_font_size_px": 16} result = self.client_patch("/json/settings", data) self.assert_json_error( result, "Incompatible values for 'dense_mode' and 'web_font_size_px'.", ) data = {"web_font_size_px": 16, "dense_mode": orjson.dumps(False).decode()} result = self.client_patch("/json/settings", data) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.web_font_size_px, 16) self.assertEqual(hamlet.dense_mode, False) data = {"web_font_size_px": 20} result = self.client_patch("/json/settings", data) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.web_font_size_px, 20) self.assertEqual(hamlet.dense_mode, False) # Check dense_mode is still false when both the # settings are set to legacy values. data = {"web_font_size_px": 14} result = self.client_patch("/json/settings", data) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.web_font_size_px, 14) self.assertEqual(hamlet.web_line_height_percent, 122) self.assertEqual(hamlet.dense_mode, False) data = {"dense_mode": orjson.dumps(True).decode()} result = self.client_patch("/json/settings", data) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.web_font_size_px, 14) self.assertEqual(hamlet.dense_mode, True) data = {"web_line_height_percent": 140} result = self.client_patch("/json/settings", data) self.assert_json_error( result, "Incompatible values for 'dense_mode' and 'web_line_height_percent'.", ) data = {"web_line_height_percent": 140, "dense_mode": orjson.dumps(False).decode()} result = self.client_patch("/json/settings", data) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.web_line_height_percent, 140) self.assertEqual(hamlet.dense_mode, False) data = {"web_line_height_percent": 130} result = self.client_patch("/json/settings", data) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.web_line_height_percent, 130) self.assertEqual(hamlet.dense_mode, False) # Check dense_mode is still false when both the # settings are set to legacy values. data = {"web_line_height_percent": 122} result = self.client_patch("/json/settings", data) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.web_font_size_px, 14) self.assertEqual(hamlet.web_line_height_percent, 122) self.assertEqual(hamlet.dense_mode, False) data = {"dense_mode": orjson.dumps(True).decode(), "web_font_size_px": 16} result = self.client_patch("/json/settings", data) self.assert_json_error( result, "Incompatible values for 'dense_mode' and 'web_font_size_px'.", ) data = {"dense_mode": orjson.dumps(True).decode(), "web_line_height_percent": 140} result = self.client_patch("/json/settings", data) self.assert_json_error( result, "Incompatible values for 'dense_mode' and 'web_line_height_percent'.", ) data = { "dense_mode": orjson.dumps(True).decode(), "web_font_size_px": 14, "web_line_height_percent": 122, } result = self.client_patch("/json/settings", data) self.assert_json_success(result) hamlet = self.example_user("hamlet") self.assertEqual(hamlet.web_font_size_px, 14) self.assertEqual(hamlet.web_line_height_percent, 122) self.assertEqual(hamlet.dense_mode, True) class UserChangesTest(ZulipTestCase): def test_update_api_key(self) -> None: user = self.example_user("hamlet") email = user.email self.login_user(user) old_api_keys = get_all_api_keys(user) # Ensure the old API keys are in the authentication cache, so # that the below logic can test whether we have a cache-flushing bug. for api_key in old_api_keys: self.assertEqual(get_user_profile_by_api_key(api_key).email, email) # First verify this endpoint is not registered in the /json/... path # to prevent access with only a session. result = self.client_post("/json/users/me/api_key/regenerate") self.assertEqual(result.status_code, 404) # A logged-in session doesn't allow access to an /api/v1/ endpoint # of course. result = self.client_post("/api/v1/users/me/api_key/regenerate") self.assertEqual(result.status_code, 401) result = self.api_post(user, "/api/v1/users/me/api_key/regenerate") new_api_key = self.assert_json_success(result)["api_key"] self.assertNotIn(new_api_key, old_api_keys) user = self.example_user("hamlet") current_api_keys = get_all_api_keys(user) self.assertIn(new_api_key, current_api_keys) for api_key in old_api_keys: with self.assertRaises(UserProfile.DoesNotExist): get_user_profile_by_api_key(api_key) for api_key in current_api_keys: self.assertEqual(get_user_profile_by_api_key(api_key).email, email) class UserDraftSettingsTests(ZulipTestCase): def test_enable_drafts_syncing(self) -> None: hamlet = self.example_user("hamlet") hamlet.enable_drafts_synchronization = False hamlet.save() payload = {"enable_drafts_synchronization": orjson.dumps(True).decode()} resp = self.api_patch(hamlet, "/api/v1/settings", payload) self.assert_json_success(resp) hamlet = self.example_user("hamlet") self.assertTrue(hamlet.enable_drafts_synchronization) def test_disable_drafts_syncing(self) -> None: aaron = self.example_user("aaron") self.assertTrue(aaron.enable_drafts_synchronization) initial_count = Draft.objects.count() # Create some drafts. These should be deleted once aaron disables # syncing drafts. visible_stream_id = self.get_stream_id(self.get_streams(aaron)[0]) draft_dicts = [ { "type": "stream", "to": [visible_stream_id], "topic": "thinking out loud", "content": "What if pigs really could fly?", "timestamp": 15954790199, }, { "type": "private", "to": [], "topic": "", "content": "What if made it possible to sync drafts in Zulip?", "timestamp": 1595479020, }, ] payload = {"drafts": orjson.dumps(draft_dicts).decode()} resp = self.api_post(aaron, "/api/v1/drafts", payload) self.assert_json_success(resp) self.assertEqual(Draft.objects.count() - initial_count, 2) payload = {"enable_drafts_synchronization": orjson.dumps(False).decode()} resp = self.api_patch(aaron, "/api/v1/settings", payload) self.assert_json_success(resp) aaron = self.example_user("aaron") self.assertFalse(aaron.enable_drafts_synchronization) self.assertEqual(Draft.objects.count() - initial_count, 0)