Repository: zulip/zulip

Issue:
wrongly cross-expired invites on zulipchat
Here's the reproducable bug:

Step 1: create 2 zulips on *.zulipchat.com:
I created invitebug1.zulipchat.com and invitebug2.zulipchat.com for this demo.

Step 2: invite the same email address on both.

Step 3: Follow one link (I followed the most recent first), confirm and join the zulip invitebug1.
The link was: https://invitebug1.zulipchat.com/accounts/do_confirm/p3ppoywp6sh4fzoshgilmayl

Step 4: Follow the other link, and you'll get:

> The registration link has expired or is not valid.

The link was: https://invitebug2.zulipchat.com/accounts/do_confirm/jmxogh5ppnilhkzylubsbi22

I included the links in case they help tracking down the problem.



Discussion:

Resolving patch:
diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py
--- a/zerver/lib/actions.py
+++ b/zerver/lib/actions.py
@@ -464,8 +464,6 @@ def process_new_human_user(
 
     mit_beta_user = realm.is_zephyr_mirror_realm
     if prereg_user is not None:
-        prereg_user.status = confirmation_settings.STATUS_ACTIVE
-        prereg_user.save(update_fields=["status"])
         streams = prereg_user.streams.all()
         acting_user: Optional[UserProfile] = prereg_user.referred_by
     else:
@@ -501,20 +499,10 @@ def process_new_human_user(
                     user=f"{user_profile.full_name} <`{user_profile.email}`>"
                 ),
             )
-    # Mark any other PreregistrationUsers that are STATUS_ACTIVE as
-    # inactive so we can keep track of the PreregistrationUser we
-    # actually used for analytics
-    if prereg_user is not None:
-        PreregistrationUser.objects.filter(email__iexact=user_profile.delivery_email).exclude(
-            id=prereg_user.id
-        ).update(status=confirmation_settings.STATUS_REVOKED)
 
-        if prereg_user.referred_by is not None:
-            notify_invites_changed(user_profile)
-    else:
-        PreregistrationUser.objects.filter(email__iexact=user_profile.delivery_email).update(
-            status=confirmation_settings.STATUS_REVOKED
-        )
+    revoke_preregistration_users(user_profile, prereg_user, realm_creation)
+    if not realm_creation and prereg_user is not None and prereg_user.referred_by is not None:
+        notify_invites_changed(user_profile)
 
     notify_new_user(user_profile)
     # Clear any scheduled invitation emails to prevent them
@@ -530,6 +518,39 @@ def process_new_human_user(
     send_initial_pms(user_profile)
 
 
+def revoke_preregistration_users(
+    created_user_profile: UserProfile,
+    used_preregistration_user: Optional[PreregistrationUser],
+    realm_creation: bool,
+) -> None:
+    if used_preregistration_user is None:
+        assert not realm_creation, "realm_creation should only happen with a PreregistrationUser"
+
+    if used_preregistration_user is not None:
+        used_preregistration_user.status = confirmation_settings.STATUS_ACTIVE
+        used_preregistration_user.save(update_fields=["status"])
+
+    # In the special case of realm creation, there can be no additional PreregistrationUser
+    # for us to want to modify - because other realm_creation PreregistrationUsers should be
+    # left usable for creating different realms.
+    if realm_creation:
+        return
+
+    # Mark any other PreregistrationUsers in the realm that are STATUS_ACTIVE as
+    # inactive so we can keep track of the PreregistrationUser we
+    # actually used for analytics.
+    if used_preregistration_user is not None:
+        PreregistrationUser.objects.filter(
+            email__iexact=created_user_profile.delivery_email, realm=created_user_profile.realm
+        ).exclude(id=used_preregistration_user.id).update(
+            status=confirmation_settings.STATUS_REVOKED
+        )
+    else:
+        PreregistrationUser.objects.filter(
+            email__iexact=created_user_profile.delivery_email, realm=created_user_profile.realm
+        ).update(status=confirmation_settings.STATUS_REVOKED)
+
+
 def notify_created_user(user_profile: UserProfile) -> None:
     user_row = user_profile_to_user_row(user_profile)
     person = format_user_row(
diff --git a/zerver/worker/queue_processors.py b/zerver/worker/queue_processors.py
--- a/zerver/worker/queue_processors.py
+++ b/zerver/worker/queue_processors.py
@@ -410,19 +410,12 @@ def consume(self, event: Dict[str, Any]) -> None:
 @assign_queue("invites")
 class ConfirmationEmailWorker(QueueProcessingWorker):
     def consume(self, data: Mapping[str, Any]) -> None:
-        if "email" in data:
-            # When upgrading from a version up through 1.7.1, there may be
-            # existing items in the queue with `email` instead of `prereg_id`.
-            invitee = filter_to_valid_prereg_users(
-                PreregistrationUser.objects.filter(email__iexact=data["email"].strip())
-            ).latest("invited_at")
-        else:
-            invitee = filter_to_valid_prereg_users(
-                PreregistrationUser.objects.filter(id=data["prereg_id"])
-            ).first()
-            if invitee is None:
-                # The invitation could have been revoked
-                return
+        invitee = filter_to_valid_prereg_users(
+            PreregistrationUser.objects.filter(id=data["prereg_id"])
+        ).first()
+        if invitee is None:
+            # The invitation could have been revoked
+            return
 
         referrer = get_user_profile_by_id(data["referrer_id"])
         logger.info(


Test patch:
diff --git a/zerver/tests/test_queue_worker.py b/zerver/tests/test_queue_worker.py
--- a/zerver/tests/test_queue_worker.py
+++ b/zerver/tests/test_queue_worker.py
@@ -445,8 +445,6 @@ def test_invites_worker(self) -> None:
             dict(prereg_id=prereg_alice.id, referrer_id=inviter.id, email_body=None),
             # Nonexistent prereg_id, as if the invitation was deleted
             dict(prereg_id=-1, referrer_id=inviter.id, email_body=None),
-            # Form with `email` is from versions up to Zulip 1.7.1
-            dict(email=self.nonreg_email("bob"), referrer_id=inviter.id, email_body=None),
         ]
         for element in data:
             fake_client.enqueue("invites", element)
@@ -458,7 +456,7 @@ def test_invites_worker(self) -> None:
                 "zerver.worker.queue_processors.send_future_email"
             ) as send_mock:
                 worker.start()
-                self.assertEqual(send_mock.call_count, 2)
+                self.assertEqual(send_mock.call_count, 1)
 
     def test_error_handling(self) -> None:
         processed = []
diff --git a/zerver/tests/test_signup.py b/zerver/tests/test_signup.py
--- a/zerver/tests/test_signup.py
+++ b/zerver/tests/test_signup.py
@@ -1772,8 +1772,13 @@ def test_send_more_than_one_invite_to_same_user(self) -> None:
         do_invite_users(self.user_profile, ["foo@zulip.com"], streams, False)
         do_invite_users(self.user_profile, ["foo@zulip.com"], streams, False)
 
+        # Also send an invite from a different realm.
+        lear = get_realm("lear")
+        lear_user = self.lear_user("cordelia")
+        do_invite_users(lear_user, ["foo@zulip.com"], [], False)
+
         invites = PreregistrationUser.objects.filter(email__iexact="foo@zulip.com")
-        self.assertEqual(len(invites), 3)
+        self.assertEqual(len(invites), 4)
 
         do_create_user(
             "foo@zulip.com",
@@ -1794,9 +1799,13 @@ def test_send_more_than_one_invite_to_same_user(self) -> None:
         self.assertEqual(len(accepted_invite), 1)
         self.assertEqual(accepted_invite[0].id, prereg_user.id)
 
-        expected_revoked_invites = set(invites.exclude(id=prereg_user.id))
+        expected_revoked_invites = set(invites.exclude(id=prereg_user.id).exclude(realm=lear))
         self.assertEqual(set(revoked_invites), expected_revoked_invites)
 
+        self.assertEqual(
+            PreregistrationUser.objects.get(email__iexact="foo@zulip.com", realm=lear).status, 0
+        )
+
     def test_confirmation_obj_not_exist_error(self) -> None:
         """Since the key is a param input by the user to the registration endpoint,
         if it inserts an invalid value, the confirmation object won't be found. This
@@ -2745,6 +2754,83 @@ def test_create_realm_during_free_trial(self) -> None:
         self.assertEqual(realm.name, realm_name)
         self.assertEqual(realm.subdomain, string_id)
 
+    @override_settings(OPEN_REALM_CREATION=True)
+    def test_create_two_realms(self) -> None:
+        """
+        Verify correct behavior and PreregistrationUser handling when using
+        two pre-generated realm creation links to create two different realms.
+        """
+        password = "test"
+        first_string_id = "zuliptest"
+        second_string_id = "zuliptest2"
+        email = "user1@test.com"
+        first_realm_name = "Test"
+        second_realm_name = "Test"
+
+        # Make sure the realms do not exist
+        with self.assertRaises(Realm.DoesNotExist):
+            get_realm(first_string_id)
+        with self.assertRaises(Realm.DoesNotExist):
+            get_realm(second_string_id)
+
+        # Now we pre-generate two realm creation links
+        result = self.client_post("/new/", {"email": email})
+        self.assertEqual(result.status_code, 302)
+        self.assertTrue(result["Location"].endswith(f"/accounts/new/send_confirm/{email}"))
+        result = self.client_get(result["Location"])
+        self.assert_in_response("Check your email so we can get started.", result)
+        first_confirmation_url = self.get_confirmation_url_from_outbox(email)
+        self.assertEqual(PreregistrationUser.objects.filter(email=email, status=0).count(), 1)
+
+        # Get a second realm creation link.
+        result = self.client_post("/new/", {"email": email})
+        self.assertEqual(result.status_code, 302)
+        self.assertTrue(result["Location"].endswith(f"/accounts/new/send_confirm/{email}"))
+        result = self.client_get(result["Location"])
+        self.assert_in_response("Check your email so we can get started.", result)
+        second_confirmation_url = self.get_confirmation_url_from_outbox(email)
+
+        self.assertNotEqual(first_confirmation_url, second_confirmation_url)
+        self.assertEqual(PreregistrationUser.objects.filter(email=email, status=0).count(), 2)
+
+        # Create and verify the first realm
+        result = self.client_get(first_confirmation_url)
+        self.assertEqual(result.status_code, 200)
+        result = self.submit_reg_form_for_user(
+            email,
+            password,
+            realm_subdomain=first_string_id,
+            realm_name=first_realm_name,
+            key=first_confirmation_url.split("/")[-1],
+        )
+        self.assertEqual(result.status_code, 302)
+        # Make sure the realm is created
+        realm = get_realm(first_string_id)
+        self.assertEqual(realm.string_id, first_string_id)
+        self.assertEqual(realm.name, first_realm_name)
+
+        # One of the PreregistrationUsers should have been used up:
+        self.assertEqual(PreregistrationUser.objects.filter(email=email, status=0).count(), 1)
+
+        # Create and verify the second realm
+        result = self.client_get(second_confirmation_url)
+        self.assertEqual(result.status_code, 200)
+        result = self.submit_reg_form_for_user(
+            email,
+            password,
+            realm_subdomain=second_string_id,
+            realm_name=second_realm_name,
+            key=second_confirmation_url.split("/")[-1],
+        )
+        self.assertEqual(result.status_code, 302)
+        # Make sure the realm is created
+        realm = get_realm(second_string_id)
+        self.assertEqual(realm.string_id, second_string_id)
+        self.assertEqual(realm.name, second_realm_name)
+
+        # The remaining PreregistrationUser should have been used up:
+        self.assertEqual(PreregistrationUser.objects.filter(email=email, status=0).count(), 0)
+
     @override_settings(OPEN_REALM_CREATION=True)
     def test_mailinator_signup(self) -> None:
         result = self.client_post("/new/", {"email": "hi@mailinator.com"})