# This module is a collection of testing helpers for validating the # schema of "events" sent by Zulip's server-to-client push system. # # By policy, every event generated by Zulip's API should be validated # by a test in test_events.py with a schema checker here. # # See https://zulip.readthedocs.io/en/latest/subsystems/events-system.html import inspect from collections.abc import Callable from enum import Enum from pprint import PrettyPrinter from typing import cast from pydantic import BaseModel from zerver.lib.event_types import ( AlertWordsEvent, AllowMessageEditingData, AttachmentAddEvent, AttachmentRemoveEvent, AttachmentUpdateEvent, AuthenticationData, BaseEvent, BotServicesEmbedded, BotServicesOutgoing, ChannelFolderAddEvent, ChannelFolderReorderEvent, ChannelFolderUpdateEvent, CustomProfileFieldsEvent, DefaultStreamGroupsEvent, DefaultStreamsEvent, DeleteMessageEvent, DeviceAddEvent, DeviceRemoveEvent, DeviceUpdateEvent, DirectMessageEvent, DraftsAddEvent, DraftsRemoveEvent, DraftsUpdateEvent, GroupSettingUpdateData, HasWebexTokenEvent, HasZoomTokenEvent, HeartbeatEvent, IconData, InvitesChangedEvent, LegacyPresenceEvent, LogoData, MessageContentEditLimitSecondsData, MessageEvent, ModernPresenceEvent, MutedTopicsEvent, MutedUsersEvent, NavigationViewAddEvent, NavigationViewRemoveEvent, NavigationViewUpdateEvent, NightLogoData, OnboardingStepsEvent, PersonAvatarFields, PersonBotOwnerId, PersonCustomProfileField, PersonDateJoined, PersonDeliveryEmail, PersonEmail, PersonFullName, PersonIsActive, PersonIsImportedStub, PersonRole, PersonTimezone, PlanTypeData, ReactionAddEvent, ReactionRemoveEvent, RealmBotAddEvent, RealmBotDeleteEvent, RealmBotUpdateEvent, RealmDeactivatedEvent, RealmDescriptionData, RealmDomainsAddEvent, RealmDomainsChangeEvent, RealmDomainsRemoveEvent, RealmEmojiAddEvent, RealmEmojiUpdateEvent, RealmEmojiUpdateOneEvent, RealmExportConsentEvent, RealmExportEvent, RealmLinkifiersEvent, RealmPlaygroundsEvent, RealmTopicsPolicyData, RealmUpdateDictEvent, RealmUpdateEvent, RealmUserAddEvent, RealmUserRemoveEvent, RealmUserSettingsDefaultsUpdateEvent, RealmUserUpdateEvent, RemindersAddEvent, RemindersRemoveEvent, RestartEvent, SavedSnippetsAddEvent, SavedSnippetsRemoveEvent, SavedSnippetsUpdateEvent, ScheduledMessagesAddEvent, ScheduledMessagesRemoveEvent, ScheduledMessagesUpdateEvent, StreamCreateEvent, StreamDeleteEvent, StreamUpdateEvent, SubmessageEvent, SubscriptionAddEvent, SubscriptionPeerAddEvent, SubscriptionPeerRemoveEvent, SubscriptionRemoveEvent, SubscriptionUpdateEvent, TypingEditMessageStartEvent, TypingEditMessageStopEvent, TypingStartEvent, TypingStopEvent, UpdateMessageEvent, UpdateMessageFlagsAddEvent, UpdateMessageFlagsRemoveEvent, UserGroupAddEvent, UserGroupAddMembersEvent, UserGroupAddSubgroupsEvent, UserGroupRemoveEvent, UserGroupRemoveMembersEvent, UserGroupRemoveSubgroupsEvent, UserGroupUpdateEvent, UserSettingsUpdateEvent, UserStatusEvent, UserTopicEvent, WebReloadClientEvent, ) from zerver.lib.topic import ORIG_TOPIC, TOPIC_NAME from zerver.lib.types import UserGroupMembersDict from zerver.models import Realm, RealmUserDefault, Stream, UserProfile from zerver.models.streams import StreamTopicsPolicyEnum def validate_with_model(data: dict[str, object], model: type[BaseModel]) -> None: allowed_fields = set(model.model_fields.keys()) if not set(data.keys()).issubset(allowed_fields): # nocoverage raise ValueError(f"Extra fields not allowed: {set(data.keys()) - allowed_fields}") model.model_validate(data, strict=True) def make_checker(base_model: type[BaseEvent]) -> Callable[[str, dict[str, object]], None]: def f(label: str, event: dict[str, object]) -> None: assert isinstance(event["id"], int) event = dict(event) del event["id"] try: validate_with_model(event, base_model) except Exception: # nocoverage print(f""" FAILURE: The event below fails the check to make sure it has the correct "shape" of data: {label} Often this is a symptom that the following type definition is either broken or needs to be updated due to other changes that you have made: {base_model} A traceback should follow to help you debug this problem. Here is the event: """) PrettyPrinter(indent=4).pprint(event) raise return f check_alert_words = make_checker(AlertWordsEvent) check_attachment_add = make_checker(AttachmentAddEvent) check_attachment_remove = make_checker(AttachmentRemoveEvent) check_attachment_update = make_checker(AttachmentUpdateEvent) check_channel_folder_add = make_checker(ChannelFolderAddEvent) check_channel_folder_reorder = make_checker(ChannelFolderReorderEvent) check_custom_profile_fields = make_checker(CustomProfileFieldsEvent) check_default_stream_groups = make_checker(DefaultStreamGroupsEvent) check_default_streams = make_checker(DefaultStreamsEvent) check_device_add = make_checker(DeviceAddEvent) check_device_remove = make_checker(DeviceRemoveEvent) check_device_update = make_checker(DeviceUpdateEvent) check_direct_message = make_checker(DirectMessageEvent) check_draft_add = make_checker(DraftsAddEvent) check_draft_remove = make_checker(DraftsRemoveEvent) check_draft_update = make_checker(DraftsUpdateEvent) check_heartbeat = make_checker(HeartbeatEvent) check_invites_changed = make_checker(InvitesChangedEvent) check_message = make_checker(MessageEvent) check_muted_users = make_checker(MutedUsersEvent) check_navigation_view_add = make_checker(NavigationViewAddEvent) check_navigation_view_remove = make_checker(NavigationViewRemoveEvent) check_navigation_view_update = make_checker(NavigationViewUpdateEvent) check_onboarding_steps = make_checker(OnboardingStepsEvent) check_reaction_add = make_checker(ReactionAddEvent) check_reaction_remove = make_checker(ReactionRemoveEvent) check_realm_bot_delete = make_checker(RealmBotDeleteEvent) check_realm_deactivated = make_checker(RealmDeactivatedEvent) check_realm_domains_add = make_checker(RealmDomainsAddEvent) check_realm_domains_change = make_checker(RealmDomainsChangeEvent) check_realm_domains_remove = make_checker(RealmDomainsRemoveEvent) check_realm_emoji_add = make_checker(RealmEmojiAddEvent) check_realm_emoji_update_one = make_checker(RealmEmojiUpdateOneEvent) check_realm_export_consent = make_checker(RealmExportConsentEvent) check_realm_linkifiers = make_checker(RealmLinkifiersEvent) check_realm_playgrounds = make_checker(RealmPlaygroundsEvent) check_realm_user_add = make_checker(RealmUserAddEvent) check_realm_user_remove = make_checker(RealmUserRemoveEvent) check_reminder_add = make_checker(RemindersAddEvent) check_reminder_remove = make_checker(RemindersRemoveEvent) check_restart = make_checker(RestartEvent) check_saved_snippets_add = make_checker(SavedSnippetsAddEvent) check_saved_snippets_remove = make_checker(SavedSnippetsRemoveEvent) check_saved_snippets_update = make_checker(SavedSnippetsUpdateEvent) check_scheduled_message_add = make_checker(ScheduledMessagesAddEvent) check_scheduled_message_remove = make_checker(ScheduledMessagesRemoveEvent) check_scheduled_message_update = make_checker(ScheduledMessagesUpdateEvent) check_stream_create = make_checker(StreamCreateEvent) check_stream_delete = make_checker(StreamDeleteEvent) check_submessage = make_checker(SubmessageEvent) check_subscription_add = make_checker(SubscriptionAddEvent) check_subscription_peer_add = make_checker(SubscriptionPeerAddEvent) check_subscription_peer_remove = make_checker(SubscriptionPeerRemoveEvent) check_subscription_remove = make_checker(SubscriptionRemoveEvent) check_typing_start = make_checker(TypingStartEvent) check_typing_stop = make_checker(TypingStopEvent) check_typing_edit_message_start = make_checker(TypingEditMessageStartEvent) check_typing_edit_message_stop = make_checker(TypingEditMessageStopEvent) check_update_message_flags_add = make_checker(UpdateMessageFlagsAddEvent) check_update_message_flags_remove = make_checker(UpdateMessageFlagsRemoveEvent) check_user_group_add = make_checker(UserGroupAddEvent) check_user_group_add_members = make_checker(UserGroupAddMembersEvent) check_user_group_add_subgroups = make_checker(UserGroupAddSubgroupsEvent) check_user_group_remove = make_checker(UserGroupRemoveEvent) check_user_group_remove_members = make_checker(UserGroupRemoveMembersEvent) check_user_group_remove_subgroups = make_checker(UserGroupRemoveSubgroupsEvent) check_user_topic = make_checker(UserTopicEvent) check_web_reload_client_event = make_checker(WebReloadClientEvent) # Now for the slightly more tricky bits. All the following functions # get wrapped with more stringent checkers. Some of the wrappers are # reasonably sane functions that just check the data, not the shape of # the data. # # But there are some ugly wrappers that work around the fact that # a) our events are not very consistent and/or b) our types aren't # robust. # # TODO: work through the bottom of this file to try to find ways to # simplify our types or make them more robust _check_channel_folder_update = make_checker(ChannelFolderUpdateEvent) _check_delete_message = make_checker(DeleteMessageEvent) _check_has_zoom_token = make_checker(HasZoomTokenEvent) _check_has_webex_token = make_checker(HasWebexTokenEvent) _check_legacy_presence = make_checker(LegacyPresenceEvent) _check_modern_presence = make_checker(ModernPresenceEvent) _check_muted_topics = make_checker(MutedTopicsEvent) _check_realm_bot_add = make_checker(RealmBotAddEvent) _check_realm_bot_update = make_checker(RealmBotUpdateEvent) _check_realm_default_update = make_checker(RealmUserSettingsDefaultsUpdateEvent) _check_realm_emoji_update = make_checker(RealmEmojiUpdateEvent) _check_realm_export = make_checker(RealmExportEvent) _check_realm_update = make_checker(RealmUpdateEvent) _check_realm_update_dict = make_checker(RealmUpdateDictEvent) _check_realm_user_update = make_checker(RealmUserUpdateEvent) _check_stream_update = make_checker(StreamUpdateEvent) _check_subscription_update = make_checker(SubscriptionUpdateEvent) _check_update_message = make_checker(UpdateMessageEvent) _check_user_group_update = make_checker(UserGroupUpdateEvent) _check_user_settings_update = make_checker(UserSettingsUpdateEvent) _check_user_status = make_checker(UserStatusEvent) PERSON_TYPES: dict[str, type[BaseModel]] = dict( avatar_fields=PersonAvatarFields, bot_owner_id=PersonBotOwnerId, custom_profile_field=PersonCustomProfileField, date_joined=PersonDateJoined, delivery_email=PersonDeliveryEmail, email=PersonEmail, full_name=PersonFullName, role=PersonRole, timezone=PersonTimezone, is_active=PersonIsActive, is_imported_stub=PersonIsImportedStub, ) def check_channel_folder_update(var_name: str, event: dict[str, object], fields: set[str]) -> None: _check_channel_folder_update(var_name, event) assert isinstance(event["data"], dict) assert set(event["data"].keys()) == fields def check_delete_message( var_name: str, event: dict[str, object], message_type: str, num_message_ids: int, is_legacy: bool, ) -> None: _check_delete_message(var_name, event) keys = {"id", "type", "message_type"} assert event["message_type"] == message_type if message_type == "stream": keys |= {"stream_id", "topic"} elif message_type == "private": pass else: raise AssertionError("unexpected message_type") if is_legacy: assert num_message_ids == 1 keys.add("message_id") else: assert isinstance(event["message_ids"], list) assert num_message_ids == len(event["message_ids"]) keys.add("message_ids") assert set(event.keys()) == keys def check_has_zoom_token( var_name: str, event: dict[str, object], value: bool, ) -> None: _check_has_zoom_token(var_name, event) assert event["value"] == value def check_has_webex_token( var_name: str, event: dict[str, object], value: bool, ) -> None: _check_has_webex_token(var_name, event) assert event["value"] == value def check_muted_topics( var_name: str, event: dict[str, object], ) -> None: _check_muted_topics(var_name, event) muted_topics = event["muted_topics"] assert isinstance(muted_topics, list) for muted_topic in muted_topics: muted_topic_tuple = tuple(muted_topic) assert list(map(type, muted_topic_tuple)) == [str, str, int] def check_legacy_presence( var_name: str, event: dict[str, object], has_email: bool, presence_key: str, status: str, ) -> None: _check_legacy_presence(var_name, event) assert ("email" in event) == has_email assert isinstance(event["presence"], dict) # Our tests only have one presence value. [(event_presence_key, event_presence_value)] = event["presence"].items() assert event_presence_key == presence_key assert event_presence_value["status"] == status def check_modern_presence(var_name: str, event: dict[str, object], user_id: int) -> None: _check_modern_presence(var_name, event) assert isinstance(event["presences"], dict) [(event_presences_key, _event_presences_value)] = event["presences"].items() assert event_presences_key == str(user_id) def check_realm_bot_add( var_name: str, event: dict[str, object], bot_type: int, ) -> None: _check_realm_bot_add(var_name, event) assert isinstance(event["bot"], dict) services = event["bot"]["services"] if bot_type == UserProfile.DEFAULT_BOT: assert services == [] elif bot_type == UserProfile.OUTGOING_WEBHOOK_BOT: assert len(services) == 1 validate_with_model(services[0], BotServicesOutgoing) elif bot_type == UserProfile.EMBEDDED_BOT: assert len(services) == 1 validate_with_model(services[0], BotServicesEmbedded) else: raise AssertionError(f"Unknown bot_type: {bot_type}") def check_realm_bot_update( # Check schema plus the field. var_name: str, event: dict[str, object], field: str, ) -> None: # Check the overall schema first. _check_realm_bot_update(var_name, event) assert isinstance(event["bot"], dict) assert {"user_id", field} == set(event["bot"].keys()) def check_realm_emoji_update(var_name: str, event: dict[str, object]) -> None: """ The way we send realm emojis is kinda clumsy--we send a dict mapping the emoji id to a sub_dict with the fields (including the id). Ideally we can streamline this and just send a list of dicts. The clients can make a Map as needed. """ _check_realm_emoji_update(var_name, event) assert isinstance(event["realm_emoji"], dict) for k, v in event["realm_emoji"].items(): assert v["id"] == k def check_realm_export( var_name: str, event: dict[str, object], has_export_url: bool, has_deleted_timestamp: bool, has_failed_timestamp: bool, ) -> None: # Check the overall event first, knowing it has some # optional types. _check_realm_export(var_name, event) # It's possible to have multiple data exports, but the events tests do not # exercise that case, so we do strict validation for a single export here. assert isinstance(event["exports"], list) assert len(event["exports"]) == 1 export = event["exports"][0] # Now verify which fields have non-None values. assert has_export_url == (export["export_url"] is not None) assert has_deleted_timestamp == (export["deleted_timestamp"] is not None) assert has_failed_timestamp == (export["failed_timestamp"] is not None) def check_realm_update( var_name: str, event: dict[str, object], prop: str, ) -> None: """ Realm updates have these two fields: property value We check not only the basic schema, but also that the value people actually matches the type from Realm.property_types that we have configured for the property. For certain properties, there are extra fields. For example, when property is "description", there's also "rendered_description". """ _check_realm_update(var_name, event) assert prop == event["property"] value = event["value"] if prop in [ "moderation_request_channel_id", "new_stream_announcements_stream_id", "signup_announcements_stream_id", "zulip_update_announcements_stream_id", "org_type", ]: assert isinstance(value, int) return property_type = Realm.property_types[prop] if inspect.isclass(property_type) and issubclass(property_type, Enum): assert isinstance(value, str) property_type[value] else: assert isinstance(value, property_type) def check_realm_default_update( var_name: str, event: dict[str, object], prop: str, ) -> None: _check_realm_default_update(var_name, event) assert prop == event["property"] assert prop != "default_language" assert prop in RealmUserDefault.property_types prop_type = RealmUserDefault.property_types[prop] value = event["value"] if inspect.isclass(prop_type) and issubclass(prop_type, Enum): assert isinstance(value, str) prop_type[value] else: assert isinstance(value, prop_type) def check_realm_update_dict( # handle union types var_name: str, event: dict[str, object], ) -> None: _check_realm_update_dict(var_name, event) if event["property"] == "default": assert isinstance(event["data"], dict) if "allow_message_editing" in event["data"]: sub_type: type[BaseModel] = AllowMessageEditingData elif "message_content_edit_limit_seconds" in event["data"]: sub_type = MessageContentEditLimitSecondsData elif "authentication_methods" in event["data"]: sub_type = AuthenticationData elif any( setting_name in event["data"] for setting_name in Realm.REALM_PERMISSION_GROUP_SETTINGS ): sub_type = GroupSettingUpdateData elif "plan_type" in event["data"]: sub_type = PlanTypeData elif "topics_policy" in event["data"]: sub_type = RealmTopicsPolicyData elif "description" in event["data"]: sub_type = RealmDescriptionData else: raise AssertionError("unhandled fields in data") elif event["property"] == "icon": sub_type = IconData elif event["property"] == "logo": sub_type = LogoData elif event["property"] == "night_logo": sub_type = NightLogoData else: raise AssertionError("unhandled property: {event['property']}") validate_with_model(cast(dict[str, object], event["data"]), sub_type) def check_realm_user_update( # person_flavor tells us which extra fields we need var_name: str, event: dict[str, object], person_flavor: str, ) -> None: _check_realm_user_update(var_name, event) sub_type = PERSON_TYPES[person_flavor] validate_with_model(cast(dict[str, object], event["person"]), sub_type) def check_stream_update( var_name: str, event: dict[str, object], ) -> None: _check_stream_update(var_name, event) prop = event["property"] value = event["value"] extra_keys = set(event.keys()) - { "id", "type", "op", "property", "value", "name", "stream_id", "first_message_id", "is_archived", "folder_id", } if prop == "description": assert extra_keys == {"rendered_description"} assert isinstance(value, str) elif prop == "invite_only": assert extra_keys == {"history_public_to_subscribers", "is_web_public"} assert isinstance(value, bool) elif prop == "message_retention_days": assert extra_keys == set() if value is not None: assert isinstance(value, int) elif prop == "name": assert extra_keys == set() assert isinstance(value, str) elif prop == "stream_post_policy": assert extra_keys == set() assert value in Stream.STREAM_POST_POLICY_TYPES elif prop in Stream.stream_permission_group_settings: assert extra_keys == set() assert isinstance(value, int | dict) # We cannot validate a TypedDict using isinstance, thus # requiring this check. if isinstance(value, dict): expected_keys = set(inspect.get_annotations(UserGroupMembersDict).keys()) keys = set(value.keys()) assert expected_keys == keys elif prop == "first_message_id": assert extra_keys == set() assert isinstance(value, int) elif prop == "topics_policy": assert extra_keys == set() assert value in [e.name for e in StreamTopicsPolicyEnum] elif prop == "is_recently_active": assert extra_keys == set() assert isinstance(value, bool) elif prop == "is_announcement_only": assert extra_keys == set() assert isinstance(value, bool) elif prop == "is_archived": assert extra_keys == set() assert isinstance(value, bool) elif prop == "folder_id": assert extra_keys == set() assert value is None or isinstance(value, int) elif prop == "default_push_notifications": assert extra_keys == set() assert isinstance(value, bool) else: raise AssertionError(f"Unknown property: {prop}") def check_subscription_update( var_name: str, event: dict[str, object], property: str, value: bool ) -> None: _check_subscription_update(var_name, event) assert event["property"] == property assert event["value"] == value def check_user_settings_update( var_name: str, event: dict[str, object], ) -> None: _check_user_settings_update(var_name, event) setting_name = event["property"] value = event["value"] assert isinstance(setting_name, str) if setting_name == "timezone": assert isinstance(value, str) else: setting_type = UserProfile.property_types[setting_name] if inspect.isclass(setting_type) and issubclass(setting_type, Enum): assert isinstance(value, str) setting_type[value] else: assert isinstance(value, setting_type) if setting_name == "default_language": assert "language_name" in event else: assert "language_name" not in event def check_update_message( var_name: str, event: dict[str, object], is_stream_message: bool, has_content: bool, has_topic: bool, has_new_stream_id: bool, is_embedded_update_only: bool, ) -> None: # Always check the basic schema first. _check_update_message(var_name, event) actual_keys = set(event.keys()) expected_keys = { "id", "type", "user_id", "edit_timestamp", "message_id", "flags", "message_ids", "rendering_only", } if is_stream_message: expected_keys |= { "stream_id", "stream_name", } if has_content: expected_keys |= { "is_me_message", "orig_content", "orig_rendered_content", "content", "rendered_content", } if has_topic: expected_keys |= { "topic_links", ORIG_TOPIC, TOPIC_NAME, "propagate_mode", } if has_new_stream_id: expected_keys |= { "new_stream_id", ORIG_TOPIC, "propagate_mode", } if is_embedded_update_only: expected_keys |= { "content", "rendered_content", } assert event["user_id"] is None else: assert isinstance(event["user_id"], int) assert event["rendering_only"] == is_embedded_update_only assert expected_keys == actual_keys def check_user_group_update(var_name: str, event: dict[str, object], fields: set[str]) -> None: _check_user_group_update(var_name, event) assert isinstance(event["data"], dict) assert set(event["data"].keys()) == fields def check_user_status(var_name: str, event: dict[str, object], fields: set[str]) -> None: _check_user_status(var_name, event) assert set(event.keys()) == {"id", "type", "user_id"} | fields