Repository: zulip/zulip

Issue:
Fix live update for moving messages to a stream the user isn't able to see
Following 7990676583b5094e1a12572a782ece7ed57b401e (Part of #6427 / #13912), we have nicely working real-time sync for moving a group of messages in a topic to a new stream.  (The UI hasn't quite merged yet, but I don't intend to block merging it on finishing this).

There's a bug where if a client receives an event notifying it to move messages to a stream ID it doesn't recognize, it'll throw an exception, because the client doesn't know anything about the new stream ID.

The right fix properly involves somewhat tricky changes in the `stream_changed` code paths inside `message_events.js` to basically just discard the messages if the target stream ID is unknown (rather than moving them to a stream_id the client knows nothing about and/or trying to renarrow).  Hopefully we can find a clean way to do this.

Fortunately, it's pretty easy to test this by just using the feature with a second browser logged in as Polonius and narrowed to the messages about to be moved; the one thing to be careful about when reproducing it is to make sure the public stream is one the guest user has never been subscribed to in the past (I'd just make a new stream).

Once we've fixed this issue, which sounds like it's only for guest users, we can look at relaxing the rules that currently limit moving messages to another stream to just:
(1) Organization administrators can do it.
(2) Where both streams are public.

Which are almost certainly too strict, but are necessary because of this implementation limitations (and changing them also requires some thought on policy).

Tagging as a release-blocker since it's a known exception for installations using guest users.


Discussion:
Hello @zulip/server-message-view members, this issue was labeled with the "area: message-editing" label, so you may want to check it out!

<!-- areaLabelAddition -->
Steve mentions that the right fix for this is probably to check if the stream ID is unknown to the client, and if so, treat it as a deletion of the group of message IDs, rather than going through the edit code path at all.  This should work pretty well, be clean, and avoid code duplication.

Resolving patch:
diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py
--- a/zerver/lib/actions.py
+++ b/zerver/lib/actions.py
@@ -4521,6 +4521,29 @@ def do_update_message(user_profile: UserProfile, message: Message,
         event["new_stream_id"] = new_stream.id
         event["propagate_mode"] = propagate_mode
 
+        # When messages are moved from one stream to another, some
+        # users may lose access to those messages, including guest
+        # users and users not subscribed to the new stream (if it is a
+        # private stream).  For those users, their experience is as
+        # though the messages were deleted, and we should send a
+        # delete_message event to them instead.
+
+        subscribers = get_active_subscriptions_for_stream_id(
+            stream_id).select_related("user_profile")
+        subs_to_new_stream = list(get_active_subscriptions_for_stream_id(
+            new_stream.id).select_related("user_profile"))
+
+        new_stream_sub_ids = [user.user_profile_id for user in subs_to_new_stream]
+
+        # Get guest users who aren't subscribed to the new_stream.
+        guest_subs_losing_access = [
+            sub for sub in subscribers
+            if sub.user_profile.is_guest
+            and sub.user_profile_id not in new_stream_sub_ids
+        ]
+        ums = ums.exclude(user_profile_id__in=[
+            sub.user_profile_id for sub in guest_subs_losing_access])
+
     if topic_name is not None:
         topic_name = truncate_topic(topic_name)
         message.set_topic_name(topic_name)
@@ -4542,6 +4565,29 @@ def do_update_message(user_profile: UserProfile, message: Message,
         )
         changed_messages += messages_list
 
+        if new_stream is not None:
+            assert stream_being_edited is not None
+
+            message_ids = [msg.id for msg in changed_messages]
+            # Delete UserMessage objects from guest users who will no
+            # longer have access to these messages.  Note: This could be
+            # very expensive, since it's N guest users x M messages.
+            UserMessage.objects.filter(
+                user_profile_id__in=[sub.user_profile_id for sub in
+                                     guest_subs_losing_access],
+                message_id__in=message_ids,
+            ).delete()
+
+            delete_event: DeleteMessagesEvent = {
+                'type': 'delete_message',
+                'message_ids': message_ids,
+                'message_type': 'stream',
+                'stream_id': stream_being_edited.id,
+                'topic': orig_topic_name,
+            }
+            delete_event_notify_user_ids = [sub.user_profile_id for sub in guest_subs_losing_access]
+            send_event(user_profile.realm, delete_event, delete_event_notify_user_ids)
+
     if message.edit_history is not None:
         edit_history = ujson.loads(message.edit_history)
         edit_history.insert(0, edit_history_event)
@@ -4586,8 +4632,31 @@ def user_info(um: UserMessage) -> Dict[str, Any]:
             # Remove duplicates by excluding the id of users already in users_to_be_notified list.
             # This is the case where a user both has a UserMessage row and is a current Subscriber
             subscribers = subscribers.exclude(user_profile_id__in=[um.user_profile_id for um in ums])
+
+            if new_stream is not None:
+                assert guest_subs_losing_access is not None
+                # Exclude guest users who are not subscribed to the new stream from receing this event.
+                subscribers = subscribers.exclude(user_profile_id__in=[sub.user_profile_id for sub in guest_subs_losing_access])
+
             # All users that are subscribed to the stream must be notified when a message is edited
             subscribers_ids = [user.user_profile_id for user in subscribers]
+
+            if new_stream is not None:
+                # TODO: Guest users don't see the new moved topic unless breadcrumb message for
+                # new stream is enabled. Excluding these users from receiving this event helps
+                # us avoid a error trackeback for our clients. We should figure out a way to
+                # inform the guest users of this new topic if sending a 'message' event for these messages
+                # is not an option.
+                # Don't send this event to guest subs who are not subscrbied to the old stream but
+                # are subscribed to the new stream
+                old_stream_unsubed_guests = [
+                    sub for sub in subs_to_new_stream
+                    if sub.user_profile.is_guest
+                    and sub.user_profile_id not in subscribers_ids
+                ]
+                subscribers = subscribers.exclude(user_profile_id__in=[sub.user_profile_id for sub in old_stream_unsubed_guests])
+                subscribers_ids = [user.user_profile_id for user in subscribers]
+
             users_to_be_notified += list(map(subscriber_info, subscribers_ids))
 
     send_event(user_profile.realm, event, users_to_be_notified)


Test patch:
diff --git a/zerver/tests/test_messages.py b/zerver/tests/test_messages.py
--- a/zerver/tests/test_messages.py
+++ b/zerver/tests/test_messages.py
@@ -56,6 +56,7 @@
     get_first_visible_message_id,
     get_raw_unread_data,
     get_recent_private_conversations,
+    has_message_access,
     maybe_update_first_visible_message_id,
     messages_for_ids,
     render_markdown,
@@ -3559,7 +3560,7 @@ def test_move_message_to_stream_and_topic(self) -> None:
                 'propagate_mode': 'change_all',
                 'topic': 'new topic',
             })
-        self.assertEqual(len(queries), 49)
+        self.assertEqual(len(queries), 52)
 
         messages = get_topic_messages(user_profile, old_stream, "test")
         self.assertEqual(len(messages), 1)
@@ -3570,6 +3571,29 @@ def test_move_message_to_stream_and_topic(self) -> None:
         self.assertEqual(messages[3].content, f"This topic was moved here from #**test move stream>test** by @_**Iago|{user_profile.id}**")
         self.assert_json_success(result)
 
+    def test_inaccessible_msg_after_stream_change(self) -> None:
+        """Simulates the case where message is moved to a stream where user is not a subscribed"""
+        (user_profile, old_stream, new_stream, msg_id, msg_id_lt) = self.prepare_move_topics(
+            "iago", "test move stream", "new stream", "test")
+
+        guest_user = self.example_user('polonius')
+        self.subscribe(guest_user, old_stream.name)
+        msg_id_to_test_acesss = self.send_stream_message(user_profile, old_stream.name,
+                                                         topic_name='test', content="fourth")
+
+        self.assertEqual(has_message_access(guest_user, Message.objects.get(id=msg_id_to_test_acesss), None), True)
+
+        result = self.client_patch("/json/messages/" + str(msg_id), {
+            'message_id': msg_id,
+            'stream_id': new_stream.id,
+            'propagate_mode': 'change_all',
+            'topic': 'new topic'
+        })
+        self.assert_json_success(result)
+
+        self.assertEqual(has_message_access(guest_user, Message.objects.get(id=msg_id_to_test_acesss), None), False)
+        self.assertEqual(has_message_access(self.example_user('iago'), Message.objects.get(id=msg_id_to_test_acesss), None), True)
+
     def test_no_notify_move_message_to_stream(self) -> None:
         (user_profile, old_stream, new_stream, msg_id, msg_id_lt) = self.prepare_move_topics(
             "iago", "test move stream", "new stream", "test")