Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
507 lines
20 KiB
Python
507 lines
20 KiB
Python
from datetime import timedelta
|
|
from email.headerregistry import Address
|
|
from typing import Any
|
|
|
|
import orjson
|
|
import time_machine
|
|
from django.conf import settings
|
|
from django.core import mail
|
|
from django.http import HttpRequest
|
|
from django.utils.html import escape
|
|
from django.utils.timezone import now
|
|
|
|
from confirmation.models import (
|
|
Confirmation,
|
|
confirmation_url,
|
|
create_confirmation_link,
|
|
generate_key,
|
|
)
|
|
from confirmation.settings import STATUS_USED
|
|
from zerver.actions.create_user import do_reactivate_user
|
|
from zerver.actions.realm_settings import do_deactivate_realm, do_set_realm_property
|
|
from zerver.actions.user_settings import do_change_user_setting, do_start_email_change_process
|
|
from zerver.actions.users import do_deactivate_user
|
|
from zerver.lib.test_classes import ZulipTestCase
|
|
from zerver.models import EmailChangeStatus, UserProfile
|
|
from zerver.models.realms import get_realm
|
|
from zerver.models.scheduled_jobs import ScheduledEmail
|
|
from zerver.models.users import get_user, get_user_by_delivery_email, get_user_profile_by_id
|
|
|
|
|
|
class EmailChangeTestCase(ZulipTestCase):
|
|
def generate_email_change_link(self, new_email: str) -> str:
|
|
data = {"email": new_email}
|
|
url = "/json/settings"
|
|
self.assert_length(mail.outbox, 0)
|
|
result = self.client_patch(url, data)
|
|
self.assert_length(mail.outbox, 1)
|
|
self.assert_json_success(result)
|
|
email_message = mail.outbox[0]
|
|
self.assertEqual(
|
|
email_message.subject,
|
|
"Verify your new email address for zulip.testserver",
|
|
)
|
|
body = email_message.body
|
|
self.assertIn("We received a request to change the email", body)
|
|
|
|
mail.outbox.pop()
|
|
|
|
activation_url = [s for s in body.split("\n") if s][2]
|
|
return activation_url
|
|
|
|
def use_email_change_confirmation_link(self, url: str, follow: bool = False) -> Any:
|
|
key = url.rsplit("/", 1)[-1]
|
|
response = self.client_post("/accounts/confirm_new_email/", {"key": key}, follow=follow)
|
|
return response
|
|
|
|
def test_confirm_email_change_with_non_existent_key(self) -> None:
|
|
self.login("hamlet")
|
|
key = generate_key()
|
|
url = confirmation_url(key, None, Confirmation.EMAIL_CHANGE)
|
|
response = self.use_email_change_confirmation_link(url)
|
|
self.assertEqual(response.status_code, 404)
|
|
self.assert_in_response(
|
|
"Whoops. We couldn't find your confirmation link in the system.", response
|
|
)
|
|
|
|
def test_confirm_email_change_with_invalid_key(self) -> None:
|
|
self.login("hamlet")
|
|
key = "invalid_key"
|
|
url = confirmation_url(key, None, Confirmation.EMAIL_CHANGE)
|
|
response = self.use_email_change_confirmation_link(url)
|
|
self.assertEqual(response.status_code, 404)
|
|
self.assert_in_response("Whoops. The confirmation link is malformed.", response)
|
|
|
|
def test_confirm_email_change_when_time_exceeded(self) -> None:
|
|
user_profile = self.example_user("hamlet")
|
|
old_email = user_profile.email
|
|
new_email = "hamlet-new@zulip.com"
|
|
self.login("hamlet")
|
|
obj = EmailChangeStatus.objects.create(
|
|
new_email=new_email,
|
|
old_email=old_email,
|
|
user_profile=user_profile,
|
|
realm=user_profile.realm,
|
|
)
|
|
date_sent = now() - timedelta(days=2)
|
|
with time_machine.travel(date_sent, tick=False):
|
|
url = create_confirmation_link(obj, Confirmation.EMAIL_CHANGE)
|
|
|
|
response = self.use_email_change_confirmation_link(url)
|
|
self.assertEqual(response.status_code, 404)
|
|
self.assert_in_response("The confirmation link has expired or been deactivated.", response)
|
|
|
|
def test_confirm_email_change(self) -> None:
|
|
user_profile = self.example_user("hamlet")
|
|
|
|
old_email = user_profile.delivery_email
|
|
new_email = '"<li>hamlet-new<li>"@zulip.com'
|
|
new_email_address = Address(addr_spec=new_email)
|
|
new_realm = get_realm("zulip")
|
|
self.login("hamlet")
|
|
obj = EmailChangeStatus.objects.create(
|
|
new_email=new_email,
|
|
old_email=old_email,
|
|
user_profile=user_profile,
|
|
realm=user_profile.realm,
|
|
)
|
|
url = create_confirmation_link(obj, Confirmation.EMAIL_CHANGE)
|
|
response = self.client_get(url)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assert_in_success_response(["Confirming your email address"], response)
|
|
|
|
key = url.split("/")[-1]
|
|
response = self.client_post("/accounts/confirm_new_email/", {"key": key})
|
|
self.assert_in_success_response(
|
|
[
|
|
"This confirms that the email address for your Zulip",
|
|
f'<a href="mailto:{escape(new_email)}">{escape(new_email_address.username)}@<wbr>{escape(new_email_address.domain)}</wbr></a>',
|
|
],
|
|
response,
|
|
)
|
|
user_profile = get_user_by_delivery_email(new_email, new_realm)
|
|
self.assertTrue(bool(user_profile))
|
|
obj.refresh_from_db()
|
|
self.assertEqual(obj.status, 1)
|
|
|
|
def test_change_email_link_cant_be_reused(self) -> None:
|
|
new_email = "hamlet-new@zulip.com"
|
|
user_profile = self.example_user("hamlet")
|
|
self.login_user(user_profile)
|
|
|
|
activation_url = self.generate_email_change_link(new_email)
|
|
response = self.use_email_change_confirmation_link(activation_url)
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
user_profile.refresh_from_db()
|
|
self.assertEqual(user_profile.delivery_email, new_email)
|
|
|
|
response = self.use_email_change_confirmation_link(activation_url)
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
def test_change_email_revokes(self) -> None:
|
|
user_profile = self.example_user("hamlet")
|
|
self.login_user(user_profile)
|
|
|
|
initial_new_email = "hamlet-new@zulip.com"
|
|
initial_url = self.generate_email_change_link(initial_new_email)
|
|
initial_email_change_status_obj = EmailChangeStatus.objects.latest("id")
|
|
self.assertEqual(initial_email_change_status_obj.new_email, initial_new_email)
|
|
response = self.use_email_change_confirmation_link(initial_url)
|
|
initial_email_change_status_obj.refresh_from_db()
|
|
self.assertEqual(initial_email_change_status_obj.status, STATUS_USED)
|
|
# Clear out the outbox, since the further test code doesn't expect any emails in there.
|
|
mail.outbox.pop()
|
|
|
|
first_email = "hamlet-newer@zulip.com"
|
|
first_url = self.generate_email_change_link(first_email)
|
|
second_email = "hamlet-newest@zulip.com"
|
|
second_url = self.generate_email_change_link(second_email)
|
|
response = self.client_get(first_url)
|
|
self.assertEqual(response.status_code, 404)
|
|
user_profile.refresh_from_db()
|
|
self.assertEqual(user_profile.delivery_email, initial_new_email)
|
|
|
|
response = self.use_email_change_confirmation_link(second_url)
|
|
self.assertEqual(response.status_code, 200)
|
|
user_profile.refresh_from_db()
|
|
self.assertEqual(user_profile.delivery_email, second_email)
|
|
|
|
# The originally used confirmation still has USED status and didn't get revoked.
|
|
initial_email_change_status_obj.refresh_from_db()
|
|
self.assertEqual(initial_email_change_status_obj.status, STATUS_USED)
|
|
|
|
def test_change_email_deactivated_user_realm(self) -> None:
|
|
new_email = "hamlet-new@zulip.com"
|
|
user_profile = self.example_user("hamlet")
|
|
self.login_user(user_profile)
|
|
|
|
activation_url = self.generate_email_change_link(new_email)
|
|
|
|
do_deactivate_user(user_profile, acting_user=None)
|
|
response = self.use_email_change_confirmation_link(activation_url)
|
|
self.assertEqual(response.status_code, 401)
|
|
error_page_title = "<title>Account is deactivated | Zulip</title>"
|
|
self.assert_in_response(error_page_title, response)
|
|
|
|
do_reactivate_user(user_profile, acting_user=None)
|
|
self.login_user(user_profile)
|
|
activation_url = self.generate_email_change_link(new_email)
|
|
|
|
do_deactivate_realm(
|
|
user_profile.realm,
|
|
acting_user=None,
|
|
deactivation_reason="owner_request",
|
|
email_owners=False,
|
|
)
|
|
|
|
response = self.use_email_change_confirmation_link(activation_url)
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(response["Location"].endswith("/accounts/deactivated/"))
|
|
|
|
def test_start_email_change_process(self) -> None:
|
|
user_profile = self.example_user("hamlet")
|
|
do_start_email_change_process(user_profile, "hamlet-new@zulip.com")
|
|
self.assertEqual(EmailChangeStatus.objects.count(), 1)
|
|
|
|
def test_end_to_end_flow(self) -> None:
|
|
data = {"email": "hamlet-new@zulip.com"}
|
|
self.login("hamlet")
|
|
url = "/json/settings"
|
|
self.assert_length(mail.outbox, 0)
|
|
result = self.client_patch(url, data)
|
|
self.assert_json_success(result)
|
|
self.assert_length(mail.outbox, 1)
|
|
email_message = mail.outbox[0]
|
|
self.assertEqual(
|
|
email_message.subject,
|
|
"Verify your new email address for zulip.testserver",
|
|
)
|
|
body = email_message.body
|
|
self.assertIn("We received a request to change the email", body)
|
|
self.assertEqual(self.email_envelope_from(email_message), settings.NOREPLY_EMAIL_ADDRESS)
|
|
self.assertRegex(
|
|
self.email_display_from(email_message),
|
|
rf"^testserver account security <{self.TOKENIZED_NOREPLY_REGEX}>\Z",
|
|
)
|
|
|
|
self.assertEqual(email_message.extra_headers["List-Id"], "Zulip Dev <zulip.testserver>")
|
|
|
|
activation_url = [s for s in body.split("\n") if s][2]
|
|
response = self.use_email_change_confirmation_link(activation_url)
|
|
|
|
self.assert_in_success_response(["This confirms that the email address"], response)
|
|
|
|
# Now confirm trying to change your email back doesn't throw an immediate error
|
|
result = self.client_patch(url, {"email": "hamlet@zulip.com"})
|
|
self.assert_json_success(result)
|
|
|
|
def test_unauthorized_email_change(self) -> None:
|
|
data = {"email": "hamlet-new@zulip.com"}
|
|
user_profile = self.example_user("hamlet")
|
|
self.login_user(user_profile)
|
|
do_set_realm_property(
|
|
user_profile.realm,
|
|
"email_changes_disabled",
|
|
True,
|
|
acting_user=None,
|
|
)
|
|
url = "/json/settings"
|
|
result = self.client_patch(url, data)
|
|
self.assert_length(mail.outbox, 0)
|
|
self.assertEqual(result.status_code, 400)
|
|
self.assert_in_response("Email address changes are disabled in this organization.", result)
|
|
# Realm admins can change their email address even setting is disabled.
|
|
data = {"email": "iago-new@zulip.com"}
|
|
self.login("iago")
|
|
url = "/json/settings"
|
|
result = self.client_patch(url, data)
|
|
self.assert_json_success(result)
|
|
|
|
def test_email_change_already_taken(self) -> None:
|
|
data = {"email": "cordelia@zulip.com"}
|
|
user_profile = self.example_user("hamlet")
|
|
self.login_user(user_profile)
|
|
|
|
url = "/json/settings"
|
|
result = self.client_patch(url, data)
|
|
self.assert_length(mail.outbox, 0)
|
|
self.assertEqual(result.status_code, 400)
|
|
self.assert_in_response("Already has an account", result)
|
|
|
|
def test_email_change_conflict_with_existing_mirror_dummy(self) -> None:
|
|
cordelia = self.example_user("cordelia")
|
|
do_deactivate_user(cordelia, acting_user=None)
|
|
cordelia.is_mirror_dummy = True
|
|
cordelia.save()
|
|
|
|
data = {"email": "cordelia@zulip.com"}
|
|
user_profile = self.example_user("hamlet")
|
|
self.login_user(user_profile)
|
|
|
|
url = "/json/settings"
|
|
result = self.client_patch(url, data)
|
|
self.assert_length(mail.outbox, 0)
|
|
self.assertEqual(result.status_code, 400)
|
|
self.assert_in_response("Account has been deactivated.", result)
|
|
|
|
def test_email_change_already_taken_later(self) -> None:
|
|
conflict_email = "conflict@zulip.com"
|
|
hamlet = self.example_user("hamlet")
|
|
cordelia = self.example_user("cordelia")
|
|
|
|
self.login_user(hamlet)
|
|
hamlet_url = self.generate_email_change_link(conflict_email)
|
|
self.logout()
|
|
|
|
self.login_user(cordelia)
|
|
cordelia_url = self.generate_email_change_link(conflict_email)
|
|
response = self.use_email_change_confirmation_link(cordelia_url)
|
|
self.assertEqual(response.status_code, 200)
|
|
cordelia.refresh_from_db()
|
|
self.assertEqual(cordelia.delivery_email, conflict_email)
|
|
|
|
self.logout()
|
|
self.login_user(hamlet)
|
|
response = self.use_email_change_confirmation_link(hamlet_url)
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assert_in_response("Already has an account", response)
|
|
|
|
def test_change_email_to_disposable_email(self) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
# Make the realm allow permissive email changes, create a
|
|
# link, and then lock the permissions down -- the change
|
|
# should not be allowed when the link is followed.
|
|
realm = hamlet.realm
|
|
realm.disallow_disposable_email_addresses = False
|
|
realm.emails_restricted_to_domains = False
|
|
realm.save()
|
|
|
|
self.login_user(hamlet)
|
|
confirmation_url = self.generate_email_change_link("hamlet@mailnator.com")
|
|
|
|
realm.disallow_disposable_email_addresses = True
|
|
realm.save()
|
|
|
|
response = self.use_email_change_confirmation_link(confirmation_url)
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assert_in_response("Please use your real email address.", response)
|
|
|
|
def test_unauthorized_email_change_from_email_confirmation_link(self) -> None:
|
|
new_email = "hamlet-new@zulip.com"
|
|
user_profile = self.example_user("hamlet")
|
|
self.login_user(user_profile)
|
|
|
|
activation_url = self.generate_email_change_link(new_email)
|
|
|
|
do_set_realm_property(
|
|
user_profile.realm,
|
|
"email_changes_disabled",
|
|
True,
|
|
acting_user=None,
|
|
)
|
|
|
|
response = self.use_email_change_confirmation_link(activation_url)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assert_in_response(
|
|
"Email address changes are disabled in this organization.", response
|
|
)
|
|
|
|
def test_post_invalid_email(self) -> None:
|
|
invalid_emails = ["", "hamlet-new"]
|
|
for email in invalid_emails:
|
|
data = {"email": email}
|
|
self.login("hamlet")
|
|
url = "/json/settings"
|
|
result = self.client_patch(url, data)
|
|
self.assert_in_response("Invalid address", result)
|
|
|
|
def test_post_same_email(self) -> None:
|
|
data = {"email": self.example_email("hamlet")}
|
|
self.login("hamlet")
|
|
url = "/json/settings"
|
|
result = self.client_patch(url, data)
|
|
response_dict = self.assert_json_success(result)
|
|
self.assertEqual("success", response_dict["result"])
|
|
self.assertEqual("", response_dict["msg"])
|
|
|
|
def test_change_delivery_email_end_to_end_with_admins_visibility(self) -> None:
|
|
user_profile = self.example_user("hamlet")
|
|
do_change_user_setting(
|
|
user_profile,
|
|
"email_address_visibility",
|
|
UserProfile.EMAIL_ADDRESS_VISIBILITY_ADMINS,
|
|
acting_user=None,
|
|
)
|
|
|
|
self.login_user(user_profile)
|
|
old_email = user_profile.delivery_email
|
|
new_email = "hamlet-new@zulip.com"
|
|
obj = EmailChangeStatus.objects.create(
|
|
new_email=new_email,
|
|
old_email=old_email,
|
|
user_profile=user_profile,
|
|
realm=user_profile.realm,
|
|
)
|
|
url = create_confirmation_link(obj, Confirmation.EMAIL_CHANGE)
|
|
response = self.use_email_change_confirmation_link(url)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assert_in_success_response(
|
|
["This confirms that the email address for your Zulip"], response
|
|
)
|
|
user_profile = get_user_profile_by_id(user_profile.id)
|
|
self.assertEqual(user_profile.delivery_email, new_email)
|
|
self.assertEqual(user_profile.email, f"user{user_profile.id}@zulip.testserver")
|
|
obj.refresh_from_db()
|
|
self.assertEqual(obj.status, 1)
|
|
with self.assertRaises(UserProfile.DoesNotExist):
|
|
get_user(old_email, user_profile.realm)
|
|
with self.assertRaises(UserProfile.DoesNotExist):
|
|
get_user_by_delivery_email(old_email, user_profile.realm)
|
|
self.assertEqual(get_user_by_delivery_email(new_email, user_profile.realm), user_profile)
|
|
|
|
def test_configure_demo_organization_owner_email(self) -> None:
|
|
user_profile = self.create_demo_organization_owner()
|
|
realm = user_profile.realm
|
|
|
|
# Because demo organization creators don't have an email
|
|
# set when the organization is created, this user profile
|
|
# field is set to false.
|
|
self.assertFalse(user_profile.enable_marketing_emails)
|
|
|
|
data = {"email": "demo-owner@example.com"}
|
|
url = "/json/settings"
|
|
self.assert_length(mail.outbox, 0)
|
|
result = self.client_patch(url, data, subdomain=realm.string_id)
|
|
self.assert_json_success(result)
|
|
self.assert_length(mail.outbox, 1)
|
|
|
|
email_message = mail.outbox[0]
|
|
self.assertEqual(
|
|
email_message.subject,
|
|
"Verify your email address for your Zulip demo organization",
|
|
)
|
|
body = email_message.body
|
|
self.assertIn(
|
|
"We received a request to add the email address",
|
|
body,
|
|
)
|
|
self.assertEqual(self.email_envelope_from(email_message), settings.NOREPLY_EMAIL_ADDRESS)
|
|
self.assertRegex(
|
|
self.email_display_from(email_message),
|
|
rf"^testserver account security <{self.TOKENIZED_NOREPLY_REGEX}>\Z",
|
|
)
|
|
self.assertEqual(
|
|
email_message.extra_headers["List-Id"], f"{realm.name} <{realm.subdomain}.testserver>"
|
|
)
|
|
|
|
# Email confirmation link
|
|
confirmation_url = [s for s in body.split("\n") if s][2]
|
|
result = self.use_email_change_confirmation_link(confirmation_url, follow=False)
|
|
|
|
# Redirects user to set a password
|
|
self.assertEqual(result.status_code, 302)
|
|
result = self.client_get(result["Location"])
|
|
self.assertEqual(result.status_code, 302)
|
|
|
|
password_set_url = result["Location"]
|
|
result = self.client_get(password_set_url, subdomain=realm.string_id)
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(
|
|
["Set a new password", "low-traffic newsletter (a few emails a year)"], result
|
|
)
|
|
|
|
user_profile.refresh_from_db()
|
|
self.assertEqual(user_profile.delivery_email, "demo-owner@example.com")
|
|
|
|
scheduled_emails = ScheduledEmail.objects.filter(users=user_profile).order_by(
|
|
"scheduled_timestamp"
|
|
)
|
|
self.assert_length(scheduled_emails, 3)
|
|
self.assertEqual(
|
|
orjson.loads(scheduled_emails[0].data)["template_prefix"],
|
|
"zerver/emails/onboarding_zulip_topics",
|
|
)
|
|
self.assertEqual(
|
|
orjson.loads(scheduled_emails[1].data)["template_prefix"],
|
|
"zerver/emails/onboarding_zulip_guide",
|
|
)
|
|
self.assertEqual(
|
|
orjson.loads(scheduled_emails[2].data)["template_prefix"],
|
|
"zerver/emails/onboarding_team_to_zulip",
|
|
)
|
|
|
|
# Set password
|
|
with self.settings(PASSWORD_MIN_LENGTH=3, PASSWORD_MIN_GUESSES=1000):
|
|
result = self.client_post(
|
|
password_set_url,
|
|
{
|
|
"new_password1": "f657gdGGk9",
|
|
"new_password2": "f657gdGGk9",
|
|
"enable_marketing_emails": "true",
|
|
},
|
|
subdomain=realm.string_id,
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertTrue(result["Location"].endswith("/password/done/"))
|
|
self.assert_logged_in_user_id(None)
|
|
|
|
# Log in with new password
|
|
request = HttpRequest()
|
|
request.session = self.client.session
|
|
self.assertTrue(
|
|
self.client.login(
|
|
request=request,
|
|
username="demo-owner@example.com",
|
|
password="f657gdGGk9",
|
|
realm=realm,
|
|
),
|
|
)
|
|
self.assert_logged_in_user_id(user_profile.id)
|
|
user_profile.refresh_from_db()
|
|
self.assertTrue(user_profile.enable_marketing_emails)
|