Files
upstream-zulip/zerver/tests/test_slack_importer.py
upstream-mirror 64898b2c79
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
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

3955 lines
167 KiB
Python

import os
import re
import shutil
import tempfile
import zipfile
from collections import defaultdict
from collections.abc import Iterator
from io import BytesIO
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from unittest import mock
from unittest.mock import ANY
from urllib.parse import parse_qs, urlsplit
import orjson
import responses
from attr import dataclass
from django.conf import settings
from django.db import IntegrityError
from django.http import HttpResponse
from django.utils.timezone import now as timezone_now
from requests.models import PreparedRequest
from confirmation import settings as confirmation_settings
from confirmation.models import Confirmation, create_confirmation_link, get_object_from_key
from zerver.actions.create_realm import do_create_realm, get_email_address_visibility_default
from zerver.actions.create_user import do_create_user
from zerver.actions.data_import import import_slack_data
from zerver.actions.users import do_deactivate_user
from zerver.data_import.import_util import (
AttachmentRecordData,
UploadFileRequest,
UploadRecordData,
ZerverFieldsT,
build_defaultstream,
build_usermessages,
build_zerver_realm,
download_and_export_upload_file,
get_emojis,
)
from zerver.data_import.sequencer import NEXT_ID
from zerver.data_import.slack import (
MAIN_SLACK_IMPORT_TOPIC,
SLACK_IMPORT_TOKEN_SCOPES,
AddedChannelsT,
AddedDMsT,
AddedMPIMsT,
MessageConversionResult,
SlackBotEmail,
SlackBotNotFoundError,
SlackTokenValidationError,
ThreadMetadata,
channel_message_to_zerver_message,
channels_to_zerver_stream,
check_slack_token_access,
convert_slack_workspace_messages,
count_thread_replies,
do_convert_zipfile,
fetch_shared_channel_users,
get_admin,
get_guest,
get_message_sending_user,
get_owner,
get_slack_api_data,
get_subscription,
get_user_timezone,
process_message_files,
slack_emoji_name_to_codepoint,
slack_workspace_to_realm,
thread_parent_map,
users_to_zerver_userprofile,
)
from zerver.lib.exceptions import SlackImportInvalidFileError
from zerver.lib.import_realm import do_import_realm
from zerver.lib.mime_types import INLINE_MIME_TYPES
from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import find_key_by_email, read_test_image_file
from zerver.lib.thumbnail import THUMBNAIL_ACCEPT_IMAGE_TYPES, BadImageError
from zerver.lib.topic import EXPORT_TOPIC_NAME
from zerver.lib.topic_link_util import get_stream_topic_link_syntax
from zerver.models import Message, PreregistrationRealm, Realm, RealmAuditLog, UserProfile
from zerver.models.realm_audit_logs import AuditLogEventType
from zerver.models.realms import get_realm
if TYPE_CHECKING:
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
def remove_folder(path: str) -> None:
if os.path.exists(path):
shutil.rmtree(path)
def request_callback(request: PreparedRequest) -> tuple[int, dict[str, str], bytes]:
valid_endpoint = False
endpoints = [
"https://slack.com/api/users.list",
"https://slack.com/api/users.info",
"https://slack.com/api/team.info",
"https://slack.com/api/bots.info",
"https://slack.com/api/api.test",
]
for endpoint in endpoints:
if request.url and endpoint in request.url:
valid_endpoint = True
break
if not valid_endpoint:
return (404, {}, b"")
if request.headers.get("Authorization") != "Bearer xoxb-valid-token":
return (200, {}, orjson.dumps({"ok": False, "error": "invalid_auth"}))
if request.url == "https://slack.com/api/users.list":
return (200, {}, orjson.dumps({"ok": True, "members": "user_data"}))
query_from_url = str(urlsplit(request.url).query)
qs = parse_qs(query_from_url)
if request.url and "https://slack.com/api/users.info" in request.url:
user2team_dict = {
"U061A3E0G": "T6LARQE2Z",
"U061A8H1G": "T7KJRQE8Y",
"U8X25EBAB": "T5YFFM2QY",
"U11111111": "T7KJRQE8Y",
"U22222222": "T7KJRQE8Y",
"U33333333": "T7KJRQE8Y",
}
try:
user_id = qs["user"][0]
team_id = user2team_dict[user_id]
except KeyError:
return (200, {}, orjson.dumps({"ok": False, "error": "user_not_found"}))
return (200, {}, orjson.dumps({"ok": True, "user": {"id": user_id, "team_id": team_id}}))
if request.url and "https://slack.com/api/bots.info" in request.url:
bot_info_dict = {
"B06NWMNUQ3W": {
"id": "B06NWMNUQ3W",
"deleted": False,
"name": "ClickUp",
"updated": 1714669546,
"app_id": "A3G4A68V9",
"icons": {
"image_36": "https://avatars.slack-edge.com/2024-05-01/7057208497908_a4351f6deb91094eac4c_36.png",
"image_48": "https://avatars.slack-edge.com/2024-05-01/7057208497908_a4351f6deb91094eac4c_48.png",
"image_72": "https://avatars.slack-edge.com/2024-05-01/7057208497908_a4351f6deb91094eac4c_72.png",
},
}
}
try:
bot_id = qs["bot"][0]
bot_info = bot_info_dict[bot_id]
except KeyError:
return (200, {}, orjson.dumps({"ok": False, "error": "bot_not_found"}))
return (200, {}, orjson.dumps({"ok": True, "bot": bot_info}))
if request.url and "https://slack.com/api/api.test" in request.url:
return (200, {}, orjson.dumps({"ok": True}))
# Else, https://slack.com/api/team.info
team_not_found: tuple[int, dict[str, str], bytes] = (
200,
{},
orjson.dumps({"ok": False, "error": "team_not_found"}),
)
try:
team_id = qs["team"][0]
except KeyError:
return team_not_found
team_dict = {
"T6LARQE2Z": "foreignteam1",
"T7KJRQE8Y": "foreignteam2",
}
try:
team_domain = team_dict[team_id]
except KeyError:
return team_not_found
return (200, {}, orjson.dumps({"ok": True, "team": {"id": team_id, "domain": team_domain}}))
class FakeUniqueViolationError(Exception):
"""Stand-in for the psycopg2 UniqueViolation that Django exposes as
IntegrityError.__cause__, carrying the diagnostics (diag.constraint_name)
that import_slack_data inspects."""
def __init__(self, message: str, constraint_name: str) -> None:
super().__init__(message)
self.diag = SimpleNamespace(constraint_name=constraint_name)
def slack_import_integrity_error(constraint_name: str) -> IntegrityError:
"""Build the IntegrityError Django raises when an INSERT violates a unique
constraint, so import_slack_data can recognize a taken subdomain from the
constraint name."""
message = f'duplicate key value violates unique constraint "{constraint_name}"'
error = IntegrityError(message)
error.__cause__ = FakeUniqueViolationError(message, constraint_name)
return error
class SlackImporter(ZulipTestCase):
def run_channel_message_to_zerver_message_with_fixtures(
self, fixture_names: list[str], **kwargs: Any
) -> MessageConversionResult:
"""
This is a wrapper for `channel_message_to_zerver_message`, it
comes with default values for most of the method's parameters.
The `fixture_names` parameter specifies which Slack exported
messages fixture(s) in the `slack_fixtures/exported_messages_fixtures`
directory will be used as input for conversion.
"""
slack_message_fixture_directory = "slack_fixtures/exported_messages_fixtures"
user_data = kwargs.get(
"user_data",
orjson.loads(self.fixture_data("users.json", type=slack_message_fixture_directory)),
)
slack_user_id_to_zulip_user_id = kwargs.get(
"slack_user_id_to_zulip_user_id", {"U066MTL5U": 5, "U061A5N1G": 24, "U061A1R2R": 43}
)
slack_recipient_name_to_zulip_recipient_id = kwargs.get(
"slack_recipient_name_to_zulip_recipient_id",
{
"random": 2,
"general": 1,
},
)
all_messages: list[dict[str, Any]]
if "all_messages" in kwargs:
all_messages = kwargs["all_messages"]
else:
all_messages = []
for filename in fixture_names:
all_messages.extend(
orjson.loads(
self.fixture_data(f"{filename}.json", type=slack_message_fixture_directory)
)
)
added_channels: dict[str, tuple[str, int]] = kwargs.get(
"added_channels",
{
"random": ("c5", slack_recipient_name_to_zulip_recipient_id["random"]),
"general": ("c6", slack_recipient_name_to_zulip_recipient_id["general"]),
},
)
convert_slack_threads = kwargs.get("convert_slack_threads", True)
assert isinstance(convert_slack_threads, bool)
thread_counter: dict[str, int] = kwargs.get("thread_counter", defaultdict(int))
thread_map: dict[str, ThreadMetadata] = kwargs.get("thread_map", {})
# Callers exercising cross-chunk threads pass the counts computed over the
# whole conversation; otherwise derive them from this call's messages.
thread_reply_counts: dict[str, int]
if "thread_reply_counts" in kwargs:
thread_reply_counts = kwargs["thread_reply_counts"]
else:
thread_reply_counts = count_thread_replies(iter(all_messages), convert_slack_threads)
with mock.patch("zerver.data_import.slack.build_usermessages", return_value=(2, 4)):
return channel_message_to_zerver_message(
realm_id=1,
users=user_data,
slack_user_id_to_zulip_user_id=slack_user_id_to_zulip_user_id,
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
all_messages=all_messages,
zerver_realmemoji=[],
subscriber_map={},
added_channels=added_channels,
domain_name="domain",
long_term_idle=set(),
convert_slack_threads=convert_slack_threads,
do_download_and_export_upload_file=lambda request: None,
thread_counter=thread_counter,
thread_map=thread_map,
thread_reply_counts=thread_reply_counts,
)
@responses.activate
def test_get_slack_api_data_with_pagination(self) -> None:
token = "xoxb-valid-token"
pagination_limit = 40
api_users_list = [f"user{i}" for i in range(100)]
count = 0
def paginated_request_callback(
request: PreparedRequest,
) -> tuple[int, dict[str, str], bytes]:
"""
A callback that in a very simple way simulates Slack's /users.list API
with support for Pagination and some rate-limiting behavior.
"""
assert request.url is not None
assert request.url.startswith("https://slack.com/api/users.list")
# Otherwise mypy complains about PreparedRequest not having params attribute:
assert hasattr(request, "params")
nonlocal count
count += 1
self.assertEqual(request.params["limit"], str(pagination_limit))
cursor = int(request.params.get("cursor", 0))
next_cursor = cursor + pagination_limit
if count % 3 == 0:
# Simulate a rate limit hit on every third request.
return (
429,
{"retry-after": "30"},
orjson.dumps({"ok": False, "error": "rate_limit_hit"}),
)
result_user_data = api_users_list[cursor:next_cursor]
if next_cursor >= len(api_users_list):
# The fetch is completed.
response_metadata = {}
else:
response_metadata = {"next_cursor": str(next_cursor)}
return (
200,
{},
orjson.dumps(
{
"ok": True,
"members": result_user_data,
"response_metadata": response_metadata,
}
),
)
responses.add_callback(
responses.GET, "https://slack.com/api/users.list", callback=paginated_request_callback
)
with (
mock.patch("zerver.data_import.slack.time.sleep", return_value=None) as mock_sleep,
self.assertLogs(level="INFO") as mock_log,
):
result = get_slack_api_data(
"https://slack.com/api/users.list",
"members",
token=token,
pagination_limit=pagination_limit,
)
self.assertEqual(result, api_users_list)
self.assertEqual(mock_sleep.call_count, 1)
self.assertIn("INFO:root:Rate limit exceeded. Retrying in 30 seconds...", mock_log.output)
@responses.activate
def test_get_slack_api_data(self) -> None:
token = "xoxb-valid-token"
# Users list
slack_user_list_url = "https://slack.com/api/users.list"
responses.add_callback(responses.GET, slack_user_list_url, callback=request_callback)
self.assertEqual(
get_slack_api_data(slack_user_list_url, "members", token=token), "user_data"
)
# Users info
slack_users_info_url = "https://slack.com/api/users.info"
user_id = "U8X25EBAB"
responses.add_callback(responses.GET, slack_users_info_url, callback=request_callback)
self.assertEqual(
get_slack_api_data(slack_users_info_url, "user", token=token, user=user_id),
{"id": user_id, "team_id": "T5YFFM2QY"},
)
# Should error if the required user argument is not specified
with self.assertRaises(Exception) as invalid:
get_slack_api_data(slack_users_info_url, "user", token=token)
self.assertEqual(invalid.exception.args, ("Error accessing Slack API: user_not_found",))
# Should error if the required user is not found
with self.assertRaises(Exception) as invalid:
get_slack_api_data(slack_users_info_url, "user", token=token, user="idontexist")
self.assertEqual(invalid.exception.args, ("Error accessing Slack API: user_not_found",))
# Bot info
slack_bots_info_url = "https://slack.com/api/bots.info"
responses.add_callback(responses.GET, slack_bots_info_url, callback=request_callback)
with self.assertRaises(SlackBotNotFoundError):
get_slack_api_data(slack_bots_info_url, "XXXYYYZZZ", token=token)
# Api test
api_test_info_url = "https://slack.com/api/api.test"
responses.add_callback(responses.GET, api_test_info_url, callback=request_callback)
self.assertTrue(get_slack_api_data(api_test_info_url, "ok", token=token))
# Team info
slack_team_info_url = "https://slack.com/api/team.info"
responses.add_callback(responses.GET, slack_team_info_url, callback=request_callback)
with self.assertRaises(Exception) as invalid:
get_slack_api_data(slack_team_info_url, "team", token=token, team="wedontexist")
self.assertEqual(invalid.exception.args, ("Error accessing Slack API: team_not_found",))
# Should error if the required user argument is not specified
with self.assertRaises(Exception) as invalid:
get_slack_api_data(slack_team_info_url, "team", token=token)
self.assertEqual(invalid.exception.args, ("Error accessing Slack API: team_not_found",))
token = "xoxb-invalid-token"
with self.assertRaises(Exception) as invalid:
get_slack_api_data(slack_user_list_url, "members", token=token)
self.assertEqual(invalid.exception.args, ("Error accessing Slack API: invalid_auth",))
with self.assertRaises(Exception) as invalid:
get_slack_api_data(slack_user_list_url, "members")
self.assertEqual(invalid.exception.args, ("Slack token missing in kwargs",))
token = "xoxb-status404"
wrong_url = "https://slack.com/api/wrong"
responses.add_callback(responses.GET, wrong_url, callback=request_callback)
with self.assertRaises(Exception) as invalid, self.assertLogs(level="INFO") as mock_log:
get_slack_api_data(wrong_url, "members", token=token)
self.assertEqual(invalid.exception.args, ("HTTP error accessing the Slack API.",))
self.assertEqual(mock_log.output, ["INFO:root:HTTP error: 404, Response: "])
def test_build_zerver_realm(self) -> None:
realm_id = 2
realm_subdomain = "test-realm"
time = float(timezone_now().timestamp())
test_realm: list[dict[str, Any]] = build_zerver_realm(
realm_id, realm_subdomain, time, "Slack"
)
test_zerver_realm_dict = test_realm[0]
self.assertEqual(test_zerver_realm_dict["id"], realm_id)
self.assertEqual(test_zerver_realm_dict["string_id"], realm_subdomain)
self.assertEqual(test_zerver_realm_dict["name"], realm_subdomain)
self.assertEqual(test_zerver_realm_dict["date_created"], time)
self.assertNotIn("uuid", test_zerver_realm_dict)
self.assertNotIn("uuid_owner_secret", test_zerver_realm_dict)
@responses.activate
def test_check_slack_token_access(self) -> None:
def token_request_callback(request: PreparedRequest) -> tuple[int, dict[str, str], bytes]:
auth = request.headers.get("Authorization")
if auth == "Bearer xoxb-broken-request":
return (400, {}, orjson.dumps({"ok": False, "error": "invalid_auth"}))
if auth == "Bearer xoxb-invalid-token":
return (200, {}, orjson.dumps({"ok": False, "error": "invalid_auth"}))
if auth == "Bearer xoxb-very-limited-scopes":
return (
200,
{"x-oauth-scopes": "emoji:read,bogus:scope"},
orjson.dumps(
{
"ok": False,
"error": "missing_scope",
"needed": "team:read",
"provided": "emoji:read,bogus:scope",
}
),
)
if auth == "Bearer xoxb-limited-scopes":
return (
200,
{"x-oauth-scopes": "team:read,bogus:scope"},
orjson.dumps({"ok": True}),
)
if auth == "Bearer xoxb-valid-token":
return (
200,
{"x-oauth-scopes": "emoji:read,users:read,users:read.email,team:read"},
orjson.dumps({"ok": True}),
)
else: # nocoverage
raise Exception("Unknown token mock")
responses.add_callback(
responses.GET, "https://slack.com/api/api.test", callback=token_request_callback
)
def exception_for(token: str, required_scopes: set[str] = SLACK_IMPORT_TOKEN_SCOPES) -> str:
with self.assertRaises(SlackTokenValidationError) as invalid:
check_slack_token_access(token, required_scopes)
message = invalid.exception.args[0]
# The user-facing message must never leak the token.
self.assertNotIn(token, message)
return message
self.assertEqual(
exception_for("xoxq-unknown"),
"Invalid token. Valid tokens start with xoxb-.",
)
with self.assertLogs(level="INFO"):
self.assertEqual(
exception_for("xoxb-invalid-token"),
"Invalid token.",
)
self.assertEqual(
exception_for("xoxb-broken-request"),
"Failed to validate the token with Slack (HTTP status 400).",
)
self.assertEqual(
exception_for("xoxb-limited-scopes"),
"Slack token is missing the following required scopes: ['emoji:read', 'users:read', 'users:read.email']",
)
self.assertEqual(
exception_for("xoxb-very-limited-scopes"),
"Slack token is missing the following required scopes: ['team:read', 'users:read', 'users:read.email']",
)
# An empty required_scopes is a caller bug, not a token problem.
with self.assertRaises(ValueError) as empty_scopes:
check_slack_token_access("xoxb-valid-token", set())
self.assertEqual(empty_scopes.exception.args[0], "required_scopes shouldn't be empty!")
check_slack_token_access("xoxb-valid-token", required_scopes=SLACK_IMPORT_TOKEN_SCOPES)
def test_get_owner(self) -> None:
user_data = [
{"is_owner": False, "is_primary_owner": False},
{"is_owner": True, "is_primary_owner": False},
{"is_owner": False, "is_primary_owner": True},
{"is_owner": True, "is_primary_owner": True},
]
self.assertEqual(get_owner(user_data[0]), False)
self.assertEqual(get_owner(user_data[1]), True)
self.assertEqual(get_owner(user_data[2]), True)
self.assertEqual(get_owner(user_data[3]), True)
def test_get_admin(self) -> None:
user_data = [{"is_admin": True}, {"is_admin": False}]
self.assertEqual(get_admin(user_data[0]), True)
self.assertEqual(get_admin(user_data[1]), False)
def test_get_guest(self) -> None:
user_data = [
{"is_restricted": False, "is_ultra_restricted": False},
{"is_restricted": True, "is_ultra_restricted": False},
{"is_restricted": False, "is_ultra_restricted": True},
{"is_restricted": True, "is_ultra_restricted": True},
]
self.assertEqual(get_guest(user_data[0]), False)
self.assertEqual(get_guest(user_data[1]), True)
self.assertEqual(get_guest(user_data[2]), True)
self.assertEqual(get_guest(user_data[3]), True)
def test_get_timezone(self) -> None:
user_chicago_timezone = {"tz": "America/Chicago"}
user_timezone_none = {"tz": None}
user_no_timezone: dict[str, Any] = {}
self.assertEqual(get_user_timezone(user_chicago_timezone), "America/Chicago")
self.assertEqual(get_user_timezone(user_timezone_none), "America/New_York")
self.assertEqual(get_user_timezone(user_no_timezone), "America/New_York")
@mock.patch("zerver.data_import.slack.get_data_file")
@mock.patch("zerver.data_import.slack.get_messages_iterator")
@responses.activate
def test_fetch_shared_channel_users(
self, messages_mock: mock.Mock, data_file_mock: mock.Mock
) -> None:
users = [{"id": "U061A1R2R"}, {"id": "U061A5N1G"}, {"id": "U064KUGRJ"}]
data_file_mock.side_effect = [
[ # Channels
{"name": "general", "members": ["U061A1R2R", "U061A5N1G"]},
{"name": "sharedchannel", "members": ["U061A1R2R", "U061A3E0G"]},
],
[ # Private channels ("groups")
{"name": "private", "members": ["U061A1R2R", "U11111111"]},
],
[ # Direct message groups ("mpims")
{
"name": "mpdm-foo--bar--baz-1",
"members": ["U061A1R2R", "U061A5N1G", "U22222222"],
},
],
[ # DMs
{"id": "D123456", "members": ["U064KUGRJ", "U33333333"]},
],
]
messages_mock.return_value = [
{"user": "U061A1R2R", "team": "T6LARQE2Z"},
{"user": "U061A5N1G", "team": "T6LARQE2Z"},
{"user": "U061A8H1G", "team": "T6LARQE2Z"},
{"subtype": "bot_message", "text": "", "username": "ClickUp", "bot_id": "B06NWMNUQ3W"},
]
# Users info
slack_users_info_url = "https://slack.com/api/users.info"
responses.add_callback(responses.GET, slack_users_info_url, callback=request_callback)
# Team info
slack_team_info_url = "https://slack.com/api/team.info"
responses.add_callback(responses.GET, slack_team_info_url, callback=request_callback)
# Bot info
slack_bot_info_url = "https://slack.com/api/bots.info"
responses.add_callback(responses.GET, slack_bot_info_url, callback=request_callback)
slack_data_dir = self.fixture_file_name("", type="slack_fixtures")
fetch_shared_channel_users(users, slack_data_dir, "xoxb-valid-token")
# Normal users
self.assert_length(users, 9)
self.assertEqual(users[0]["id"], "U061A1R2R")
self.assertEqual(users[0]["is_mirror_dummy"], False)
self.assertFalse("team_domain" in users[0])
self.assertEqual(users[1]["id"], "U061A5N1G")
self.assertEqual(users[2]["id"], "U064KUGRJ")
# Shared channel users
# We need to do this because the outcome order of `users` list is
# not deterministic.
later_users = sorted(users[3:], key=lambda x: x["id"])
expected_users = [
("B06NWMNUQ3W", "ClickUp"),
("U061A3E0G", "foreignteam1"),
("U061A8H1G", "foreignteam2"),
("U11111111", "foreignteam2"),
("U22222222", "foreignteam2"),
("U33333333", "foreignteam2"),
]
for expected, found in zip(expected_users, later_users, strict=False):
self.assertEqual(found["id"], expected[0])
if "bot_id" in found.get("profile", {}):
self.assertEqual(found["real_name"], expected[1])
self.assertEqual(found["is_mirror_dummy"], False)
else:
self.assertEqual(found["team_domain"], expected[1])
self.assertEqual(found["is_mirror_dummy"], True)
@mock.patch("zerver.data_import.slack.get_data_file")
def test_users_to_zerver_userprofile(self, mock_get_data_file: mock.Mock) -> None:
custom_profile_field_user1 = {
"Xf06054BBB": {"value": "random1"},
"Xf023DSCdd": {"value": "employee"},
}
custom_profile_field_user2 = {
"Xf06054BBB": {"value": "random2"},
"Xf023DSCdd": {"value": "employer"},
}
user_data: list[dict[str, Any]] = [
{
"id": "U08RGD1RD",
"team_id": "T5YFFM2QY",
"name": "john",
"deleted": False,
"is_mirror_dummy": False,
"real_name": "John Doe",
"profile": {
"image_32": "",
"email": "jon@gmail.com",
"avatar_hash": "hash",
"phone": "+1-123-456-77-868",
"fields": custom_profile_field_user1,
},
},
{
"id": "U0CBK5KAT",
"team_id": "T5YFFM2QY",
"is_admin": True,
"is_bot": False,
"is_owner": True,
"is_primary_owner": True,
"name": "Jane",
"real_name": "Jane Doe",
"deleted": False,
"is_mirror_dummy": False,
"profile": {
"image_32": "https://secure.gravatar.com/avatar/random.png",
"fields": custom_profile_field_user2,
"email": "jane@foo.com",
"avatar_hash": "hash",
},
},
{
"id": "U09TYF5Sk",
"team_id": "T5YFFM2QY",
"name": "Bot",
"real_name": "Bot",
"is_bot": True,
"deleted": False,
"is_mirror_dummy": False,
"profile": {
"image_32": "https://secure.gravatar.com/avatar/random1.png",
"skype": "test_skype_name",
"email": "bot1@zulipchat.com",
"avatar_hash": "hash",
},
},
{
"id": "UHSG7OPQN",
"team_id": "T6LARQE2Z",
"name": "matt.perry",
"color": "9d8eee",
"is_bot": False,
"is_app_user": False,
"is_mirror_dummy": True,
"team_domain": "foreignteam",
"profile": {
"image_32": "https://secure.gravatar.com/avatar/random6.png",
"avatar_hash": "hash",
"first_name": "Matt",
"last_name": "Perry",
"real_name": "Matt Perry",
"display_name": "matt.perry",
"team": "T6LARQE2Z",
},
},
{
"id": "U8VAHEVUY",
"team_id": "T5YFFM2QY",
"name": "steviejacob34",
"real_name": "Steve Jacob",
"is_admin": False,
"is_owner": False,
"is_primary_owner": False,
"is_restricted": True,
"is_ultra_restricted": False,
"is_bot": False,
"is_mirror_dummy": False,
"profile": {
"email": "steviejacob34@yahoo.com",
"avatar_hash": "hash",
"image_32": "https://secure.gravatar.com/avatar/random6.png",
},
},
{
"id": "U8X25EBAB",
"team_id": "T5YFFM2QY",
"name": "pratikweb_0",
"real_name": "Pratik",
"is_admin": False,
"is_owner": False,
"is_primary_owner": False,
"is_restricted": True,
"is_ultra_restricted": True,
"is_bot": False,
"is_mirror_dummy": False,
"profile": {
"email": "pratik@mit.edu",
"avatar_hash": "hash",
"image_32": "https://secure.gravatar.com/avatar/random.png",
},
},
{
"id": "U015J7JSE",
"team_id": "T5YFFM2QY",
"name": "georgesm27",
"real_name": "George",
"is_admin": True,
"is_owner": True,
"is_primary_owner": False,
"is_restricted": False,
"is_ultra_restricted": False,
"is_bot": False,
"is_mirror_dummy": False,
"profile": {
"email": "george@yahoo.com",
"avatar_hash": "hash",
"image_32": "https://secure.gravatar.com/avatar/random5.png",
},
},
{
"id": "U1RDFEC80",
"team_id": "T5YFFM2QY",
"name": "daniel.smith",
"real_name": "Daniel Smith",
"is_admin": True,
"is_owner": False,
"is_primary_owner": False,
"is_restricted": False,
"is_ultra_restricted": False,
"is_bot": False,
"is_mirror_dummy": False,
"profile": {
"email": "daniel@gmail.com",
"avatar_hash": "hash",
"image_32": "https://secure.gravatar.com/avatar/random7.png",
},
},
# Unknown user data format.
{
"id": "U1ZYFEC91",
"team_id": "T5YFFM2QZ",
"name": "daniel.who",
"real_name": "Daniel Who",
"is_admin": True,
"is_owner": False,
"is_primary_owner": False,
"is_restricted": False,
"is_ultra_restricted": False,
"is_bot": False,
"is_mirror_dummy": False,
"profile": {
"email": "daniel.who@gmail.com",
},
# Missing the `avatar_hash` value.
},
{
"id": "U1MBOTC81",
"name": "Integration Bot",
"deleted": False,
"is_mirror_dummy": False,
"real_name": "Integration Bot",
"is_integration_bot": True,
"is_bot": True,
"profile": {
"image_72": "https://avatars.slack-edge.com/2024-05-01/7057208497908_a4351f6deb91094eac4c_512.png",
"bot_id": "B06NWMNUQ3W",
"first_name": "Integration Bot",
},
},
# Test error-handling for a bot user with an unknown avatar URL format.
# In reality, we expect the file to have a file extension type to be
# `.png` or within the range of `THUMBNAIL_ACCEPT_IMAGE_TYPES`.
{
"id": "U1RDFEC90",
"name": "Unknown Bot",
"deleted": False,
"is_mirror_dummy": False,
"real_name": "Unknown Bot",
"is_integration_bot": True,
"is_bot": True,
"profile": {
"image_72": "https://avatars.slack-edge.com/2024-05-01/dasdasdasdasdXXXXXX",
"bot_id": "B0DSAMNUQ3W",
"first_name": "Unknown Bot",
},
},
]
mock_get_data_file.return_value = user_data
# As user with slack_id 'U0CBK5KAT' is the primary owner, that user should be imported first
# and hence has zulip_id = 1
test_slack_user_id_to_zulip_user_id = {
"U08RGD1RD": 1,
"U0CBK5KAT": 0,
"U09TYF5Sk": 2,
"UHSG7OPQN": 3,
"U8VAHEVUY": 4,
"U8X25EBAB": 5,
"U015J7JSE": 6,
"U1RDFEC80": 7,
"U1ZYFEC91": 8,
"U1MBOTC81": 9,
"U1RDFEC90": 10,
}
slack_data_dir = "./random_path"
timestamp = int(timezone_now().timestamp())
mock_get_data_file.return_value = user_data
with self.assertLogs(level="INFO"):
(
zerver_userprofile,
avatar_list,
slack_user_id_to_zulip_user_id,
customprofilefield,
customprofilefield_value,
) = users_to_zerver_userprofile(
slack_data_dir, user_data, 1, timestamp, "testdomain.com"
)
# Test custom profile fields
self.assertEqual(customprofilefield[0]["field_type"], 1)
self.assertEqual(customprofilefield[3]["name"], "skype")
cpf_name = {cpf["name"] for cpf in customprofilefield}
self.assertIn("phone", cpf_name)
self.assertIn("skype", cpf_name)
cpf_name.remove("phone")
cpf_name.remove("skype")
for name in cpf_name:
self.assertTrue(name.startswith("Slack custom field "))
self.assert_length(customprofilefield_value, 6)
self.assertEqual(customprofilefield_value[0]["field"], 0)
self.assertEqual(customprofilefield_value[0]["user_profile"], 1)
self.assertEqual(customprofilefield_value[3]["user_profile"], 0)
self.assertEqual(customprofilefield_value[5]["value"], "test_skype_name")
# test that the primary owner should always be imported first
self.assertDictEqual(slack_user_id_to_zulip_user_id, test_slack_user_id_to_zulip_user_id)
self.assert_length(avatar_list, 9)
self.assert_length(zerver_userprofile, 11)
self.assertEqual(zerver_userprofile[0]["is_staff"], False)
self.assertEqual(zerver_userprofile[0]["is_bot"], False)
self.assertEqual(zerver_userprofile[0]["is_active"], True)
self.assertEqual(zerver_userprofile[0]["is_mirror_dummy"], False)
self.assertEqual(zerver_userprofile[0]["role"], UserProfile.ROLE_MEMBER)
self.assertEqual(zerver_userprofile[0]["enable_desktop_notifications"], True)
self.assertEqual(zerver_userprofile[0]["email"], "jon@gmail.com")
self.assertEqual(zerver_userprofile[0]["full_name"], "John Doe")
self.assertEqual(zerver_userprofile[0]["is_imported_stub"], True)
self.assertEqual(zerver_userprofile[0]["short_name"], "john")
self.assertEqual(zerver_userprofile[0]["last_login"], None)
self.assertEqual(
zerver_userprofile[1]["id"], test_slack_user_id_to_zulip_user_id["U0CBK5KAT"]
)
self.assertEqual(zerver_userprofile[1]["role"], UserProfile.ROLE_REALM_OWNER)
self.assertEqual(zerver_userprofile[1]["is_staff"], False)
self.assertEqual(zerver_userprofile[1]["is_active"], True)
self.assertEqual(zerver_userprofile[0]["is_mirror_dummy"], False)
self.assertEqual(zerver_userprofile[1]["is_imported_stub"], True)
self.assertEqual(
zerver_userprofile[2]["id"], test_slack_user_id_to_zulip_user_id["U09TYF5Sk"]
)
self.assertEqual(zerver_userprofile[2]["is_bot"], True)
self.assertEqual(zerver_userprofile[2]["is_active"], True)
self.assertEqual(zerver_userprofile[2]["is_mirror_dummy"], False)
self.assertEqual(zerver_userprofile[2]["email"], "bot1@zulipchat.com")
self.assertEqual(zerver_userprofile[2]["bot_type"], 1)
self.assertEqual(zerver_userprofile[2]["avatar_source"], "U")
self.assertEqual(zerver_userprofile[2]["is_imported_stub"], True)
self.assertEqual(
zerver_userprofile[3]["id"], test_slack_user_id_to_zulip_user_id["UHSG7OPQN"]
)
self.assertEqual(zerver_userprofile[3]["role"], UserProfile.ROLE_MEMBER)
self.assertEqual(zerver_userprofile[3]["is_staff"], False)
self.assertEqual(zerver_userprofile[3]["is_active"], False)
self.assertEqual(zerver_userprofile[3]["email"], "matt.perry@foreignteam.slack.com")
self.assertEqual(zerver_userprofile[3]["realm"], 1)
self.assertEqual(zerver_userprofile[3]["full_name"], "Matt Perry")
self.assertEqual(zerver_userprofile[3]["is_mirror_dummy"], True)
self.assertEqual(zerver_userprofile[3]["can_forge_sender"], False)
self.assertEqual(zerver_userprofile[3]["is_imported_stub"], True)
self.assertEqual(
zerver_userprofile[4]["id"], test_slack_user_id_to_zulip_user_id["U8VAHEVUY"]
)
self.assertEqual(zerver_userprofile[4]["role"], UserProfile.ROLE_GUEST)
self.assertEqual(zerver_userprofile[4]["is_staff"], False)
self.assertEqual(zerver_userprofile[4]["is_active"], True)
self.assertEqual(zerver_userprofile[4]["is_mirror_dummy"], False)
self.assertEqual(zerver_userprofile[4]["is_imported_stub"], True)
self.assertEqual(
zerver_userprofile[5]["id"], test_slack_user_id_to_zulip_user_id["U8X25EBAB"]
)
self.assertEqual(zerver_userprofile[5]["role"], UserProfile.ROLE_GUEST)
self.assertEqual(zerver_userprofile[5]["is_staff"], False)
self.assertEqual(zerver_userprofile[5]["is_active"], True)
self.assertEqual(zerver_userprofile[5]["is_mirror_dummy"], False)
self.assertEqual(zerver_userprofile[5]["is_imported_stub"], True)
self.assertEqual(
zerver_userprofile[6]["id"], test_slack_user_id_to_zulip_user_id["U015J7JSE"]
)
self.assertEqual(zerver_userprofile[6]["role"], UserProfile.ROLE_REALM_OWNER)
self.assertEqual(zerver_userprofile[6]["is_staff"], False)
self.assertEqual(zerver_userprofile[6]["is_active"], True)
self.assertEqual(zerver_userprofile[6]["is_mirror_dummy"], False)
self.assertEqual(zerver_userprofile[6]["is_imported_stub"], True)
self.assertEqual(
zerver_userprofile[7]["id"], test_slack_user_id_to_zulip_user_id["U1RDFEC80"]
)
self.assertEqual(zerver_userprofile[7]["role"], UserProfile.ROLE_REALM_ADMINISTRATOR)
self.assertEqual(zerver_userprofile[7]["is_staff"], False)
self.assertEqual(zerver_userprofile[7]["is_active"], True)
self.assertEqual(zerver_userprofile[7]["is_mirror_dummy"], False)
self.assertEqual(zerver_userprofile[7]["is_imported_stub"], True)
# Importer should raise error when user emails are malformed
bad_email1 = user_data[0]["profile"]["email"] = "jon@gmail,com"
bad_email2 = user_data[1]["profile"]["email"] = "jane@gmail.m"
with self.assertRaises(Exception) as e, self.assertLogs(level="INFO"):
users_to_zerver_userprofile(slack_data_dir, user_data, 1, timestamp, "test_domain")
error_message = str(e.exception)
expected_error_message = f"['Invalid email format, please fix the following email(s) and try again: {bad_email1}, {bad_email2}']"
self.assertEqual(error_message, expected_error_message)
# Test converting unknown user data format that doesn't have
# an `avatar_hash` in its user profile.
self.assertEqual(
zerver_userprofile[8]["id"], test_slack_user_id_to_zulip_user_id["U1ZYFEC91"]
)
self.assertEqual(zerver_userprofile[8]["is_active"], True)
self.assertEqual(zerver_userprofile[8]["avatar_source"], "J")
# Test converting Slack's integration bot
self.assertEqual(
zerver_userprofile[9]["id"], test_slack_user_id_to_zulip_user_id["U1MBOTC81"]
)
self.assertEqual(zerver_userprofile[9]["is_active"], True)
self.assertEqual(zerver_userprofile[9]["avatar_source"], "U")
self.assertEqual(zerver_userprofile[9]["is_bot"], True)
self.assertEqual(zerver_userprofile[9]["bot_type"], UserProfile.DEFAULT_BOT)
self.assertEqual(
zerver_userprofile[10]["id"], test_slack_user_id_to_zulip_user_id["U1RDFEC90"]
)
self.assertEqual(zerver_userprofile[10]["is_active"], True)
self.assertEqual(zerver_userprofile[10]["avatar_source"], "J")
self.assertEqual(zerver_userprofile[10]["is_bot"], True)
self.assertEqual(zerver_userprofile[10]["bot_type"], UserProfile.DEFAULT_BOT)
def test_build_defaultstream(self) -> None:
realm_id = 1
stream_id = 1
default_channel_general = build_defaultstream(realm_id, stream_id, 1)
test_default_channel = {"stream": 1, "realm": 1, "id": 1}
self.assertDictEqual(test_default_channel, default_channel_general)
default_channel_general = build_defaultstream(realm_id, stream_id, 1)
test_default_channel = {"stream": 1, "realm": 1, "id": 1}
self.assertDictEqual(test_default_channel, default_channel_general)
def test_build_subscription(self) -> None:
channel_members = ["U061A1R2R", "U061A3E0G", "U061A5N1G", "U064KUGRJ"]
slack_user_id_to_zulip_user_id = {
"U061A1R2R": 1,
"U061A3E0G": 8,
"U061A5N1G": 7,
"U064KUGRJ": 5,
}
subscription_id_count = 0
recipient_id = 12
zerver_subscription: list[dict[str, Any]] = []
final_subscription_id = get_subscription(
channel_members,
zerver_subscription,
recipient_id,
slack_user_id_to_zulip_user_id,
subscription_id_count,
)
# sanity checks
self.assertEqual(final_subscription_id, 4)
self.assertEqual(zerver_subscription[0]["recipient"], 12)
self.assertEqual(zerver_subscription[0]["id"], 0)
self.assertEqual(
zerver_subscription[0]["user_profile"],
slack_user_id_to_zulip_user_id[channel_members[0]],
)
self.assertEqual(
zerver_subscription[2]["user_profile"],
slack_user_id_to_zulip_user_id[channel_members[2]],
)
self.assertEqual(zerver_subscription[3]["id"], 3)
self.assertEqual(zerver_subscription[1]["recipient"], zerver_subscription[3]["recipient"])
self.assertEqual(zerver_subscription[1]["pin_to_top"], False)
def test_channels_to_zerver_stream(self) -> None:
slack_user_id_to_zulip_user_id = {
"U061A1R2R": 1,
"U061A3E0G": 8,
"U061A5N1G": 7,
"U064KUGRJ": 5,
}
zerver_userprofile = [{"id": 1}, {"id": 8}, {"id": 7}, {"id": 5}]
realm_id = 3
realm: ZerverFieldsT = {"zerver_userpresence": [], "zerver_realm": [dict()]}
zerver_realm = realm["zerver_realm"]
with (
self.assertLogs(level="INFO"),
mock.patch(
"zerver.data_import.slack.SLACK_DEFAULT_ANNOUNCEMENTS_CHANNEL_NAME", "random"
),
):
(
realm,
added_channels,
added_mpims,
added_dms,
slack_recipient_name_to_zulip_recipient_id,
) = channels_to_zerver_stream(
self.fixture_file_name("", "slack_fixtures"),
realm_id,
realm,
slack_user_id_to_zulip_user_id,
zerver_userprofile,
)
test_added_channels = {
"sharedchannel": ("C061A0HJG", 3),
"general": ("C061A0YJG", 1),
"general1": ("C061A0YJP", 2),
"random": ("C061A0WJG", 0),
}
test_added_mpims = {
"mpdm-user9--user2--user10-1": ("G9HBG2A5D", 0),
"mpdm-user6--user7--user4-1": ("G6H1Z0ZPS", 1),
"mpdm-user4--user1--user5-1": ("G6N944JPL", 2),
}
test_added_dms = {
"DJ47BL849": 3,
"DHX1UP7EG": 4,
"DK8HSJDHS": 5,
"DRS3PSLDK": 6,
}
slack_recipient_names = (
set(test_added_channels.keys())
| set(test_added_mpims.keys())
| set(test_added_dms.keys())
)
self.assertDictEqual(added_channels, test_added_channels)
# zerver defaultstream already tested in helper functions.
# Note that the `random` stream is archived and thus should
# not be created as a DefaultStream.
self.assertEqual(realm["zerver_defaultstream"], [{"id": 0, "realm": 3, "stream": 1}])
self.assertDictEqual(added_mpims, test_added_mpims)
self.assertDictEqual(added_dms, test_added_dms)
# We can't do an assertDictEqual since during the construction of personal
# recipients, slack_user_id_to_zulip_user_id are iterated in different order in Python 3.5 and 3.6.
self.assertEqual(
set(slack_recipient_name_to_zulip_recipient_id.keys()),
slack_recipient_names,
)
self.assertEqual(
set(slack_recipient_name_to_zulip_recipient_id.values()),
set(range(11)),
)
# functioning of zerver subscriptions are already tested in the helper functions
# This is to check the concatenation of the output lists from the helper functions
# subscriptions for stream
zerver_subscription = realm["zerver_subscription"]
zerver_recipient = realm["zerver_recipient"]
zerver_stream = realm["zerver_stream"]
self.assertEqual(self.get_set(zerver_subscription, "recipient"), set(range(11)))
self.assertEqual(self.get_set(zerver_subscription, "user_profile"), {1, 5, 7, 8})
self.assertEqual(
self.get_set(zerver_recipient, "id"), self.get_set(zerver_subscription, "recipient")
)
self.assertEqual(self.get_set(zerver_recipient, "type_id"), {0, 1, 2, 3, 4, 5, 6})
self.assertEqual(self.get_set(zerver_recipient, "type"), {2, 3})
# stream mapping
self.assertEqual(zerver_stream[0]["name"], "random")
self.assertEqual(zerver_stream[0]["deactivated"], True)
self.assertEqual(zerver_stream[0]["description"], "no purpose")
self.assertEqual(zerver_stream[0]["invite_only"], False)
self.assertEqual(zerver_stream[0]["history_public_to_subscribers"], True)
self.assertEqual(zerver_stream[0]["realm"], realm_id)
self.assertEqual(zerver_stream[2]["id"], test_added_channels[zerver_stream[2]["name"]][1])
self.assertEqual(
zerver_realm[0]["zulip_update_announcements_stream"], zerver_stream[0]["id"]
)
self.assertEqual(zerver_realm[0]["new_stream_announcements_stream"], zerver_stream[0]["id"])
self.assertEqual(self.get_set(realm["zerver_huddle"], "id"), {0, 1, 2, 3, 4, 5, 6})
self.assertEqual(realm["zerver_userpresence"], [])
@mock.patch(
"zerver.data_import.slack.users_to_zerver_userprofile", return_value=[[], [], {}, [], []]
)
@mock.patch(
"zerver.data_import.slack.channels_to_zerver_stream",
return_value=[{"zerver_stream": []}, {}, {}, {}, {}],
)
def test_slack_workspace_to_realm(
self, mock_channels_to_zerver_stream: mock.Mock, mock_users_to_zerver_userprofile: mock.Mock
) -> None:
realm_id = 1
user_list: list[dict[str, Any]] = []
(
realm,
slack_user_id_to_zulip_user_id,
slack_recipient_name_to_zulip_recipient_id,
added_channels,
added_mpims,
added_dms,
avatar_list,
_em,
) = slack_workspace_to_realm(
"testdomain", realm_id, user_list, "test-realm", "./random_path", {}
)
test_zerver_realmdomain = [
{"realm": realm_id, "allow_subdomains": False, "domain": "testdomain", "id": realm_id}
]
# Functioning already tests in helper functions
self.assertEqual(slack_user_id_to_zulip_user_id, {})
self.assertEqual(added_channels, {})
self.assertEqual(added_mpims, {})
self.assertEqual(added_dms, {})
self.assertEqual(slack_recipient_name_to_zulip_recipient_id, {})
self.assertEqual(avatar_list, [])
mock_channels_to_zerver_stream.assert_called_once_with("./random_path", 1, ANY, {}, [])
passed_realm = mock_channels_to_zerver_stream.call_args_list[0][0][2]
zerver_realmdomain = passed_realm["zerver_realmdomain"]
self.assertListEqual(zerver_realmdomain, test_zerver_realmdomain)
self.assertEqual(
passed_realm["zerver_realm"][0]["description"], "Organization imported from Slack!"
)
self.assertEqual(passed_realm["zerver_userpresence"], [])
self.assertEqual(passed_realm["import_source"], "slack")
self.assert_length(passed_realm.keys(), 17)
self.assertEqual(realm["zerver_stream"], [])
self.assertEqual(realm["zerver_userprofile"], [])
self.assertEqual(realm["zerver_realmemoji"], [])
self.assertEqual(realm["zerver_customprofilefield"], [])
self.assertEqual(realm["zerver_customprofilefieldvalue"], [])
self.assert_length(realm.keys(), 5)
def test_get_message_sending_user(self) -> None:
message_with_file = {"subtype": "file", "type": "message", "file": {"user": "U064KUGRJ"}}
message_without_file = {"subtype": "file", "type": "message", "user": "U064KUGRJ"}
user_file = get_message_sending_user(message_with_file)
self.assertEqual(user_file, "U064KUGRJ")
user_without_file = get_message_sending_user(message_without_file)
self.assertEqual(user_without_file, "U064KUGRJ")
def test_build_zerver_message(self) -> None:
zerver_usermessage: list[dict[str, Any]] = []
# recipient_id -> set of user_ids
subscriber_map = {
2: {3, 7, 15, 16}, # these we care about
4: {12},
6: {19, 21},
}
recipient_id = 2
mentioned_user_ids = [7]
message_id = 9
um_id = NEXT_ID("user_message")
build_usermessages(
zerver_usermessage=zerver_usermessage,
subscriber_map=subscriber_map,
recipient_id=recipient_id,
mentioned_user_ids=mentioned_user_ids,
message_id=message_id,
is_private=False,
)
self.assertEqual(zerver_usermessage[0]["id"], um_id + 1)
self.assertEqual(zerver_usermessage[0]["message"], message_id)
self.assertEqual(zerver_usermessage[0]["flags_mask"], 1)
self.assertEqual(zerver_usermessage[1]["id"], um_id + 2)
self.assertEqual(zerver_usermessage[1]["message"], message_id)
self.assertEqual(zerver_usermessage[1]["user_profile"], 7)
self.assertEqual(zerver_usermessage[1]["flags_mask"], 9) # mentioned
self.assertEqual(zerver_usermessage[2]["id"], um_id + 3)
self.assertEqual(zerver_usermessage[2]["message"], message_id)
self.assertEqual(zerver_usermessage[3]["id"], um_id + 4)
self.assertEqual(zerver_usermessage[3]["message"], message_id)
@mock.patch("zerver.data_import.slack.build_usermessages", return_value=(2, 4))
def test_channel_message_to_zerver_message(self, mock_build_usermessage: mock.Mock) -> None:
user_data = [
{"id": "U066MTL5U", "name": "john doe", "deleted": False, "real_name": "John"},
{"id": "U061A5N1G", "name": "jane doe", "deleted": False, "real_name": "Jane"},
{
"id": "U061A1R2R",
"name": "jon",
"deleted": False,
"real_name": "Jon",
"profile": {"email": "jon@example.com"},
},
]
slack_user_id_to_zulip_user_id = {"U066MTL5U": 5, "U061A5N1G": 24, "U061A1R2R": 43}
reactions = [{"name": "grinning", "users": ["U061A5N1G"], "count": 1}]
all_messages: list[dict[str, Any]] = [
{
"text": "<@U066MTL5U> has joined the channel",
"subtype": "channel_join",
"user": "U066MTL5U",
"ts": "1434139102.000002",
"channel_name": "random",
},
{
"text": "<@U061A5N1G>: hey!",
"user": "U061A1R2R",
"ts": "1437868294.000006",
"has_image": True,
"channel_name": "random",
},
{
"text": "random",
"user": "U061A5N1G",
"reactions": reactions,
"ts": "1439868294.000006",
"channel_name": "random",
},
{
"text": "without a user",
"user": None, # this message will be ignored as it has no user
"ts": "1239868294.000006",
"channel_name": "general",
},
{
"text": "<http://journals.plos.org/plosone/article>",
"user": "U061A1R2R",
"ts": "1463868370.000008",
"channel_name": "general",
},
{
"text": "added bot",
"user": "U061A5N1G",
"subtype": "bot_add",
"ts": "1433868549.000010",
"channel_name": "general",
},
# This message will be ignored since it has no user and file is None.
# See #9217 for the situation; likely file uploads on archived channels
{
"upload": False,
"file": None,
"text": "A file was shared",
"channel_name": "general",
"type": "message",
"ts": "1433868549.000011",
"subtype": "file_share",
},
{
"text": "random test",
"user": "U061A1R2R",
"ts": "1433868669.000012",
"channel_name": "general",
},
{
"text": "Hello everyone",
"user": "U061A1R2R",
"type": "message",
"ts": "1433868669.000015",
"mpim_name": "mpdm-user9--user2--user10-1",
},
{
"text": "Who is watching the World Cup",
"user": "U061A5N1G",
"type": "message",
"ts": "1433868949.000015",
"mpim_name": "mpdm-user6--user7--user4-1",
},
{
"client_msg_id": "998d9229-35aa-424f-8d87-99e00df27dc9",
"type": "message",
"text": "Who is coming for camping this weekend?",
"user": "U061A1R2R",
"ts": "1553607595.000700",
"pm_name": "DHX1UP7EG",
},
{
"client_msg_id": "998d9229-35aa-424f-8d87-99e00df27dc9",
"type": "message",
"text": "<@U061A5N1G>: Are you in Kochi?",
"user": "U066MTL5U",
"ts": "1553607595.000700",
"pm_name": "DJ47BL849",
},
{
"text": "Look!",
"user": "U061A1R2R",
"ts": "1553607596.000700",
"has_image": True,
"channel_name": "random",
"files": [
{
"url_private": "https://files.slack.com/apple.png",
"title": "Apple",
"name": "apple.png",
"mimetype": "image/png",
"timestamp": 9999,
"created": 8888,
"size": 3000000,
}
],
},
]
slack_recipient_name_to_zulip_recipient_id = {
"random": 2,
"general": 1,
"mpdm-user9--user2--user10-1": 5,
"mpdm-user6--user7--user4-1": 6,
"DJ47BL849": 3,
"DHX1UP7EG": 4,
"DK8HSJDHS": 5,
"DRS3PSLDK": 6,
}
subscriber_map: dict[int, set[int]] = {}
added_channels: dict[str, tuple[str, int]] = {"random": ("c5", 1), "general": ("c6", 2)}
realm_id = 1
conversion_result = channel_message_to_zerver_message(
realm_id,
user_data,
slack_user_id_to_zulip_user_id,
slack_recipient_name_to_zulip_recipient_id,
all_messages,
[],
subscriber_map,
added_channels,
"domain",
set(),
convert_slack_threads=False,
do_download_and_export_upload_file=lambda request: None,
thread_counter=defaultdict(int),
thread_map={},
thread_reply_counts=count_thread_replies(iter(all_messages), False),
)
zerver_message = conversion_result.zerver_message
# functioning already tested in helper function
self.assertEqual(conversion_result.zerver_usermessage, [])
# subtype: channel_join is filtered
self.assert_length(zerver_message, 10)
# Test reactions
self.assertEqual(conversion_result.reaction_list[0]["user_profile"], 24)
self.assertEqual(conversion_result.reaction_list[0]["emoji_name"], reactions[0]["name"])
# Message conversion already tested in tests.test_slack_message_conversion
self.assertEqual(zerver_message[0]["content"], "@**Jane**: hey!")
self.assertEqual(zerver_message[0]["has_link"], False)
self.assertEqual(zerver_message[2]["content"], "http://journals.plos.org/plosone/article")
self.assertEqual(zerver_message[2]["has_link"], True)
self.assertEqual(zerver_message[5]["has_link"], False)
self.assertEqual(zerver_message[7]["has_link"], False)
# Test that topic_name is set to '\x07' for direct messages and
# group direct messages.
self.assertEqual(zerver_message[6][EXPORT_TOPIC_NAME], Message.DM_TOPIC)
self.assertEqual(zerver_message[8][EXPORT_TOPIC_NAME], Message.DM_TOPIC)
self.assertEqual(zerver_message[3][EXPORT_TOPIC_NAME], "imported from Slack")
self.assertEqual(zerver_message[3]["content"], "/me added bot")
self.assertEqual(
zerver_message[4]["recipient"], slack_recipient_name_to_zulip_recipient_id["general"]
)
self.assertEqual(zerver_message[2][EXPORT_TOPIC_NAME], "imported from Slack")
self.assertEqual(
zerver_message[1]["recipient"], slack_recipient_name_to_zulip_recipient_id["random"]
)
self.assertEqual(
zerver_message[5]["recipient"],
slack_recipient_name_to_zulip_recipient_id["mpdm-user9--user2--user10-1"],
)
self.assertEqual(
zerver_message[6]["recipient"],
slack_recipient_name_to_zulip_recipient_id["mpdm-user6--user7--user4-1"],
)
self.assertEqual(
zerver_message[7]["recipient"], slack_recipient_name_to_zulip_recipient_id["DHX1UP7EG"]
)
self.assertEqual(
zerver_message[8]["recipient"], slack_recipient_name_to_zulip_recipient_id["DJ47BL849"]
)
self.assertEqual(zerver_message[3]["id"], zerver_message[0]["id"] + 3)
self.assertEqual(zerver_message[4]["id"], zerver_message[0]["id"] + 4)
self.assertEqual(zerver_message[5]["id"], zerver_message[0]["id"] + 5)
self.assertEqual(zerver_message[7]["id"], zerver_message[0]["id"] + 7)
self.assertIsNone(zerver_message[3]["rendered_content"])
self.assertEqual(zerver_message[0]["has_image"], False)
self.assertEqual(zerver_message[0]["date_sent"], float(all_messages[1]["ts"]))
self.assertEqual(zerver_message[2]["rendered_content_version"], 1)
self.assertEqual(zerver_message[0]["sender"], 43)
self.assertEqual(zerver_message[3]["sender"], 24)
self.assertEqual(zerver_message[5]["sender"], 43)
self.assertEqual(zerver_message[6]["sender"], 24)
self.assertEqual(zerver_message[7]["sender"], 43)
self.assertEqual(zerver_message[8]["sender"], 5)
# Test uploads
attachment = conversion_result.zerver_attachment
self.assert_length(conversion_result.uploads_list, 1)
expected_file_name = "apple.png"
self.assertRegex(
conversion_result.uploads_list[0].path, rf"{realm_id}/.*/.*/{expected_file_name}"
)
self.assert_length(attachment, 1)
self.assertEqual(attachment[0].file_name, "apple.png")
self.assertEqual(attachment[0].is_realm_public, True)
self.assertEqual(attachment[0].is_web_public, False)
self.assertEqual(attachment[0].content_type, "image/png")
self.assertEqual(zerver_message[9]["has_image"], True)
self.assertEqual(zerver_message[9]["has_attachment"], True)
self.assertTrue(zerver_message[9]["content"].startswith("Look!\n[Apple](/user_uploads/"))
def test_channel_message_to_zerver_message_with_threads(self) -> None:
slack_recipient_name_to_zulip_recipient_id = {
"random": 2,
"general": 1,
}
conversion_result = self.run_channel_message_to_zerver_message_with_fixtures(
["normal_messages", "normal_thread"],
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
)
zerver_message = conversion_result.zerver_message
zerver_usermessage = conversion_result.zerver_usermessage
attachment = conversion_result.zerver_attachment
uploads = conversion_result.uploads_list
# functioning already tested in helper function
self.assertEqual(zerver_usermessage, [])
# subtype: channel_join is filtered
self.assert_length(zerver_message, 5)
self.assert_length(uploads, 0)
self.assert_length(attachment, 0)
# Message conversion already tested in tests.test_slack_message_conversion
self.assertEqual(zerver_message[0]["content"], "@**Jane**: hey!")
self.assertEqual(zerver_message[0]["has_link"], False)
self.assertEqual(
zerver_message[1]["recipient"],
slack_recipient_name_to_zulip_recipient_id["random"],
)
### THREAD 1 CONVERSATION ###
# Test thread topic name contains message snippet
expected_thread_1_topic_name = "2015-06-12 message body text"
thread_1_topic_link_syntax = get_stream_topic_link_syntax(
slack_recipient_name_to_zulip_recipient_id["random"],
"random",
expected_thread_1_topic_name,
)
original_thread_1_message_1_content = "message body text"
expected_thread_1_message_1_content = f"""
{original_thread_1_message_1_content}
*1 reply in {thread_1_topic_link_syntax}*
""".strip()
self.assertEqual(zerver_message[1]["content"], expected_thread_1_message_1_content)
self.assertEqual(zerver_message[1][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
# Thread reply is in the correct thread topic
expected_thread_1_message_2_content = "random"
self.assertEqual(zerver_message[2]["content"], expected_thread_1_message_2_content)
self.assertEqual(zerver_message[2][EXPORT_TOPIC_NAME], expected_thread_1_topic_name)
### THREAD 2 CONVERSATION ###
# Test thread topic name contains message snippet
expected_thread_2_topic_name = "2018-10-18 another thread message"
thread_2_topic_link_syntax = get_stream_topic_link_syntax(
slack_recipient_name_to_zulip_recipient_id["random"],
"random",
expected_thread_2_topic_name,
)
original_thread_2_message_1_content = "another thread message"
expected_thread_2_message_1_content = f"""
{original_thread_2_message_1_content}
*1 reply in {thread_2_topic_link_syntax}*
""".strip()
self.assertEqual(zerver_message[3]["content"], expected_thread_2_message_1_content)
self.assertEqual(zerver_message[3][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
# Thread reply is in the correct thread topic
expected_thread_2_message_2_content = "another reply"
self.assertEqual(zerver_message[4]["content"], expected_thread_2_message_2_content)
self.assertEqual(zerver_message[4][EXPORT_TOPIC_NAME], expected_thread_2_topic_name)
def test_thread_cross_link_across_chunk_boundary(self) -> None:
# A thread's parent message and its replies are not necessarily
# converted in the same chunk (see convert_slack_workspace_messages).
# The parent must still be cross-linked with the correct reply count,
# which is why count_thread_replies pre-scans the whole conversation.
slack_recipient_name_to_zulip_recipient_id = {"random": 2, "general": 1}
thread_messages = orjson.loads(
self.fixture_data(
"normal_thread.json", type="slack_fixtures/exported_messages_fixtures"
)
)
parent_message, reply_message = thread_messages[0], thread_messages[1]
# thread_counter, thread_map, and the reply counts persist across the
# per-chunk calls, exactly as in convert_slack_workspace_messages.
thread_counter: dict[str, int] = defaultdict(int)
thread_map: dict[str, ThreadMetadata] = {}
thread_reply_counts = count_thread_replies(
iter([parent_message, reply_message]), convert_slack_threads=True
)
# First chunk holds only the parent; the reply arrives in the second.
first_chunk = self.run_channel_message_to_zerver_message_with_fixtures(
[],
all_messages=[parent_message],
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
thread_counter=thread_counter,
thread_map=thread_map,
thread_reply_counts=thread_reply_counts,
)
second_chunk = self.run_channel_message_to_zerver_message_with_fixtures(
[],
all_messages=[reply_message],
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
thread_counter=thread_counter,
thread_map=thread_map,
thread_reply_counts=thread_reply_counts,
)
expected_thread_topic_name = "2015-06-12 message body text"
thread_topic_link_syntax = get_stream_topic_link_syntax(
slack_recipient_name_to_zulip_recipient_id["random"],
"random",
expected_thread_topic_name,
)
expected_parent_content = f"""
message body text
*1 reply in {thread_topic_link_syntax}*
""".strip()
# The parent, converted in the first chunk, is cross-linked with the
# reply that is only converted in the second chunk.
self.assert_length(first_chunk.zerver_message, 1)
self.assertEqual(first_chunk.zerver_message[0]["content"], expected_parent_content)
self.assertEqual(first_chunk.zerver_message[0][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
# The reply, converted in the second chunk, is routed to the thread topic.
self.assert_length(second_chunk.zerver_message, 1)
self.assertEqual(second_chunk.zerver_message[0]["content"], "random")
self.assertEqual(
second_chunk.zerver_message[0][EXPORT_TOPIC_NAME], expected_thread_topic_name
)
def test_thread_cross_link_skipped_for_direct_messages(self) -> None:
# Direct-message threads aren't split into per-thread topics, so a
# DM thread's parent must not get a cross-linking notice (and looking
# one up must not raise, since no thread topic is recorded for it).
slack_recipient_name_to_zulip_recipient_id = {"random": 2, "general": 1, "dm": 3}
dm_thread = [
{
"text": "dm thread parent",
"user": "U061A5N1G",
"ts": "1434139102.000002",
"thread_ts": "1434139102.000002",
"pm_name": "dm",
},
{
"text": "dm thread reply",
"user": "U061A5N1G",
"ts": "1439868294.000007",
"parent_user_id": "U061A5N1G",
"thread_ts": "1434139102.000002",
"pm_name": "dm",
},
]
conversion_result = self.run_channel_message_to_zerver_message_with_fixtures(
[],
all_messages=dm_thread,
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
)
self.assert_length(conversion_result.zerver_message, 2)
# The content is unannotated and both messages use the DM topic.
self.assertEqual(conversion_result.zerver_message[0]["content"], "dm thread parent")
self.assertEqual(conversion_result.zerver_message[0][EXPORT_TOPIC_NAME], Message.DM_TOPIC)
self.assertEqual(conversion_result.zerver_message[1]["content"], "dm thread reply")
self.assertEqual(conversion_result.zerver_message[1][EXPORT_TOPIC_NAME], Message.DM_TOPIC)
def test_convert_thread_topic_name_cut_off(self) -> None:
slack_recipient_name_to_zulip_recipient_id = {
"random": 2,
"general": 1,
}
conversion_result = self.run_channel_message_to_zerver_message_with_fixtures(
["thread_with_long_topic_name"],
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
)
zerver_message = conversion_result.zerver_message
self.assert_length(zerver_message, 4)
# Test thread topic name cut off.
expected_thread_1_topic_name = (
"2015-08-18 random message but it is too long for the thread…"
)
thread_1_topic_link_syntax = get_stream_topic_link_syntax(
slack_recipient_name_to_zulip_recipient_id["random"],
"random",
expected_thread_1_topic_name,
)
original_thread_1_message_1_content = (
"random message but it is too long for the thread topic name"
)
expected_thread_1_message_1_content = f"""
{original_thread_1_message_1_content}
*1 reply in {thread_1_topic_link_syntax}*
""".strip()
self.assertEqual(zerver_message[0]["content"], expected_thread_1_message_1_content)
self.assertEqual(zerver_message[0][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
# Record that truncation should use the full maximum topic length.
expected_thread_1_topic_name = (
"2015-08-18 random message but it is too long for the thread…"
)
self.assertEqual(zerver_message[1][EXPORT_TOPIC_NAME], expected_thread_1_topic_name)
self.assert_length(zerver_message[1][EXPORT_TOPIC_NAME], 60)
### THREAD 2 CONVERSATION ###
self.assertEqual(zerver_message[2][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
# Test that two different thread topics, despite having unique
# original topic names, will collide if their truncated names
# are identical.
expected_thread_2_topic_name = (
"2015-08-18 random message but it is too long for the th… (2)"
)
self.assertEqual(zerver_message[3][EXPORT_TOPIC_NAME], expected_thread_2_topic_name)
self.assert_length(zerver_message[3][EXPORT_TOPIC_NAME], 60)
def test_convert_colliding_thread_topic_names(self) -> None:
slack_recipient_name_to_zulip_recipient_id = {
"random": 2,
"general": 1,
}
conversion_result = self.run_channel_message_to_zerver_message_with_fixtures(
["threads_with_colliding_topic_names"],
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
)
zerver_message = conversion_result.zerver_message
self.assert_length(zerver_message, 6)
### THREAD 1 CONVERSATION ###
expected_thread_1_topic_name = "2015-06-12 message body text"
thread_1_topic_link_syntax = get_stream_topic_link_syntax(
slack_recipient_name_to_zulip_recipient_id["random"],
"random",
expected_thread_1_topic_name,
)
original_thread_1_message_1_content = "message body text"
expected_thread_1_message_1_content = f"""
{original_thread_1_message_1_content}
*1 reply in {thread_1_topic_link_syntax}*
""".strip()
self.assertEqual(zerver_message[0]["content"], expected_thread_1_message_1_content)
self.assertEqual(zerver_message[0][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
self.assertEqual(zerver_message[1][EXPORT_TOPIC_NAME], expected_thread_1_topic_name)
### THREAD 2 CONVERSATION ###
# Test thread topic name collision.
expected_thread_2_topic_name = "2015-06-12 message body text (2)"
self.assertEqual(zerver_message[2][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
self.assertEqual(zerver_message[3][EXPORT_TOPIC_NAME], expected_thread_2_topic_name)
### THREAD 3 CONVERSATION ###
# Test two thread topic names with the same message
# snippet don't collide if they're on different days.
expected_thread_3_topic_name = "1974-07-27 message body text"
self.assertEqual(zerver_message[4][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
self.assertEqual(zerver_message[5][EXPORT_TOPIC_NAME], expected_thread_3_topic_name)
def test_convert_thread_topic_name_with_mention_syntax(self) -> None:
slack_recipient_name_to_zulip_recipient_id = {
"random": 2,
"general": 1,
}
conversion_result = self.run_channel_message_to_zerver_message_with_fixtures(
["thread_with_mention_syntax_in_topic_name"],
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
)
zerver_message = conversion_result.zerver_message
self.assert_length(zerver_message, 2)
# Test mention syntax in thread topic name.
expected_thread_topic_name = "2015-07-17 @**Jon** please reply to this message"
thread_1_topic_link_syntax = get_stream_topic_link_syntax(
slack_recipient_name_to_zulip_recipient_id["random"],
"random",
expected_thread_topic_name,
)
original_thread_message_1_content = "@**Jon** please reply to this message"
expected_thread_message_1_content = f"""
{original_thread_message_1_content}
*1 reply in {thread_1_topic_link_syntax}*
""".strip()
self.assertEqual(zerver_message[0]["content"], expected_thread_message_1_content)
self.assertEqual(zerver_message[0][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
self.assertEqual(zerver_message[1][EXPORT_TOPIC_NAME], expected_thread_topic_name)
def test_convert_thread_topic_name_with_file_link_formatting(self) -> None:
conversion_result = self.run_channel_message_to_zerver_message_with_fixtures(
["thread_with_file_link_formatting_in_topic_name"],
)
zerver_message = conversion_result.zerver_message
self.assert_length(zerver_message, 2)
self.assert_length(conversion_result.uploads_list, 1)
self.assert_length(conversion_result.zerver_attachment, 1)
# The thread topic name is generated from the message's original
# content, before the file's Markdown link is appended, so the topic
# stays clean instead of embedding the upload URL.
expected_thread_message_1_content = "Look!\n[Apple](/user_uploads/"
self.assertTrue(zerver_message[0]["content"].startswith(expected_thread_message_1_content))
self.assertEqual(zerver_message[0][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
self.assertEqual(zerver_message[1][EXPORT_TOPIC_NAME], "2018-09-16 Look!")
self.assertNotIn("/user_uploads/", zerver_message[1][EXPORT_TOPIC_NAME])
def test_convert_thread_topic_name_with_text_formattings(self) -> None:
slack_recipient_name_to_zulip_recipient_id = {
"random": 2,
"general": 1,
}
conversion_result = self.run_channel_message_to_zerver_message_with_fixtures(
["thread_with_text_formattings_in_topic_name"],
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
)
zerver_message = conversion_result.zerver_message
self.assert_length(zerver_message, 2)
expected_thread_1_topic_name = (
"2019-01-10 **foo** *bar* ~~baz~~ [qux](https://chat.zulip.o…"
)
thread_1_topic_link_syntax = get_stream_topic_link_syntax(
slack_recipient_name_to_zulip_recipient_id["random"],
"random",
expected_thread_1_topic_name,
)
original_thread_message_1_content = "**foo** *bar* ~~baz~~ [qux](https://chat.zulip.org)"
expected_thread_message_1_content = f"""
{original_thread_message_1_content}
*1 reply in {thread_1_topic_link_syntax}*
""".strip()
self.assertEqual(zerver_message[0]["content"], expected_thread_message_1_content)
self.assertEqual(zerver_message[0][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
self.assertEqual(zerver_message[1][EXPORT_TOPIC_NAME], expected_thread_1_topic_name)
def test_convert_thread_topic_name_with_whitespace(self) -> None:
slack_recipient_name_to_zulip_recipient_id = {
"random": 2,
"general": 1,
}
conversion_result = self.run_channel_message_to_zerver_message_with_fixtures(
["threads_with_whitespace_in_topic_name"],
slack_recipient_name_to_zulip_recipient_id=slack_recipient_name_to_zulip_recipient_id,
)
zerver_message = conversion_result.zerver_message
self.assert_length(zerver_message, 4)
### THREAD 1 CONVERSATION ###
# Newlines and tabs are not valid topic name characters, so each run
# of whitespace in the message snippet becomes a single space.
expected_thread_1_topic_name = "2015-08-18 Hi there friend"
thread_1_topic_link_syntax = get_stream_topic_link_syntax(
slack_recipient_name_to_zulip_recipient_id["random"],
"random",
expected_thread_1_topic_name,
)
original_thread_1_message_1_content = "Hi\n\nthere\tfriend"
expected_thread_1_message_1_content = f"""
{original_thread_1_message_1_content}
*1 reply in {thread_1_topic_link_syntax}*
""".strip()
# The message itself keeps its original whitespace; only the topic
# name derived from it is collapsed.
self.assertEqual(zerver_message[0]["content"], expected_thread_1_message_1_content)
self.assertEqual(zerver_message[0][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
self.assertEqual(zerver_message[1][EXPORT_TOPIC_NAME], expected_thread_1_topic_name)
### THREAD 2 CONVERSATION ###
# Collapsing happens before truncation, so a long run of blank lines
# in the middle of the message doesn't consume the whole snippet.
expected_thread_2_topic_name = "2019-01-10 Release notes: - fixed the thing"
self.assertEqual(zerver_message[2][EXPORT_TOPIC_NAME], MAIN_SLACK_IMPORT_TOPIC)
self.assertEqual(zerver_message[3][EXPORT_TOPIC_NAME], expected_thread_2_topic_name)
@mock.patch("zerver.data_import.slack.build_usermessages", return_value=(2, 4))
def test_channel_message_to_zerver_message_with_integration_bots(
self, mock_build_usermessage: mock.Mock
) -> None:
"""
Most of the core logic for converting Slack blocks is in slack_incoming/test.py,
so the purpose for this test is to verify the import tool portion of this system.
"""
user_data = [
{"id": "U066MTL5U", "name": "john doe", "deleted": False, "real_name": "John"},
{"id": "U061A5N1G", "name": "jane doe", "deleted": False, "real_name": "Jane"},
{
"id": "B06NWMNUQ3W", # Bot user
"name": "ClickUp",
"deleted": False,
"real_name": "ClickUp",
},
]
slack_user_id_to_zulip_user_id = {"U066MTL5U": 5, "U061A5N1G": 24, "B06NWMNUQ3W": 43}
all_messages: list[dict[str, Any]] = [
{
"subtype": "bot_message",
"text": "",
"username": "ClickUp",
"attachments": [
{
"id": 1,
"color": "008844",
"fallback": "dsdaddsa",
"text": "Added assignee Pieter\n\nby Pieter\n_<https://app.clickup.com/25567147/v/s/43687023|Task one> &gt; <https://app.clickup.com/25567147/v/li/901601846060|dsad>_",
"title": "dsdaddsa",
"title_link": "https://app.clickup.com/t/86cv0v5my",
"mrkdwn_in": ["text"],
}
],
"type": "message",
"ts": "1712027355.830689",
"bot_id": "B06NWMNUQ3W",
"app_id": "A3G4A68V9",
"channel_name": "general",
},
{
"subtype": "bot_message",
"text": "",
"username": "ClickUp",
"attachments": [
{
"id": 1,
"blocks": [
{
"type": "section",
"block_id": "qvtCh",
"text": {
"type": "mrkdwn",
"text": "<https://app.clickup.com/t/86cv0v5my| dsda>",
"verbatim": False,
},
},
{
"type": "section",
"block_id": "7LtRa",
"text": {
"type": "mrkdwn",
"text": "Task Created\nBy Pieter\n",
"verbatim": False,
},
},
{
"type": "section",
"block_id": "XgOBF",
"fields": [
{
"type": "mrkdwn",
"text": "_<https://app.clickup.com/25567147/v/s/43687023|Task one> &gt; <https://app.clickup.com/25567147/v/li/901601846060|dsad>_",
"verbatim": False,
}
],
},
],
"color": "#656f7d",
"fallback": "[no preview available]",
}
],
"type": "message",
"ts": "1712026462.647119",
"bot_id": "B06NWMNUQ3W",
"app_id": "A3G4A68V9",
"channel_name": "general",
},
{
"subtype": "bot_message",
"text": "",
"username": "ClickUp",
"attachments": [
{
"id": 1,
"color": "656f7d",
"fallback": "Buy Ingredients!",
"text": "New comment: where can I buy it?\n\nby Pieter\n_<https://app.clickup.com/25567147/v/s/43687023|Task one> &gt; <https://app.clickup.com/25567147/v/li/901601846060|dsad>_",
"title": "Buy Ingredients!",
"title_link": "https://app.clickup.com/t/86cuy8e4y?comment=90160026391938",
"mrkdwn_in": ["text"],
}
],
"type": "message",
"ts": "1711032565.114789",
"bot_id": "B06NWMNUQ3W",
"app_id": "A3G4A68V9",
"channel_name": "general",
},
{
"subtype": "bot_message",
"text": "Hello there, I am a basic rich text block!",
"username": "ClickUp",
"attachments": [
{
"id": 1,
"color": "656f7d",
"fallback": "Buy Ingredients!",
"text": "New comment: where can I buy it?\n\nby Pieter\n_<https://app.clickup.com/25567147/v/s/43687023|Task one> &gt; <https://app.clickup.com/25567147/v/li/901601846060|dsad>_",
"title": "Buy Ingredients!",
"title_link": "https://app.clickup.com/t/86cuy8e4y?comment=90160026391938",
"mrkdwn_in": ["text"],
}
],
"blocks": [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Hello there, I am a basic rich text block!",
}
],
}
],
}
],
"type": "message",
"ts": "1811032565.114789",
"bot_id": "B06NWMNUQ3W",
"app_id": "A3G4A68V9",
"channel_name": "general",
},
{
"subtype": "bot_message",
"text": "`render_block` will delete me, help!\nHello, `render_block` recognizes me.",
"username": "ClickUp",
"attachments": [
{
"id": 1,
"blocks": [
{
"type": "section",
"block_id": "bZ4XB",
"text": {
"type": "mrkdwn",
"text": "*<https://app.clickup.com/t/25567147/86cw30wf2|asd>*",
"verbatim": False,
},
},
{
"type": "context",
"block_id": "TEfLM",
"elements": [
{
"type": "image",
"image_url": "https://search.clickup-au.com/media/app-icons/clickup/logo_alpha.png",
"alt_text": "Cookie / The Goodiest of Cstdddddsdd / dds",
},
{"type": "mrkdwn", "text": "in dds", "verbatim": False},
{
"type": "image",
"image_url": "https://search.clickup-au.com/media/app-icons/clickup/status.png",
"alt_text": "Status",
},
{"type": "mrkdwn", "text": "To Do", "verbatim": False},
],
},
{
"type": "actions",
"block_id": "cg8fg",
"elements": [
{
"type": "button",
"action_id": "NOOP",
"text": {
"type": "plain_text",
"text": "View task",
"emoji": False,
},
"url": "https://app.clickup.com/t/25567147/86cw30wf2",
},
{
"type": "button",
"action_id": "EDIT_TASK_ACTION",
"text": {
"type": "plain_text",
"text": "Edit task",
"emoji": False,
},
"value": '{"taskID":"86cw30wf2","teamID":"25567147","taskUrl":"https://app.clickup.com/t/25567147/86cw30wf2"}',
},
],
},
],
"color": "#656f7d",
"fallback": "[no preview available]",
}
],
"type": "message",
"ts": "1723609070.703489",
"bot_id": "B06NWMNUQ3W",
"app_id": "A3G4A68V9",
"blocks": [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "`render_block` will delete me, help!",
}
],
}
],
},
{
"type": "section",
"text": {
"type": "plain_text",
"text": "Hello, `render_block` recognizes me.",
"emoji": True,
},
},
],
"channel": "C06P6T3QGD7",
"event_ts": "1723609070.703489",
"channel_type": "channel",
"channel_name": "general",
},
]
slack_recipient_name_to_zulip_recipient_id = {
"random": 2,
"general": 1,
}
zerver_usermessage: list[dict[str, Any]] = []
subscriber_map: dict[int, set[int]] = {}
added_channels: dict[str, tuple[str, int]] = {"random": ("c5", 1), "general": ("c6", 2)}
conversion_result = channel_message_to_zerver_message(
1,
user_data,
slack_user_id_to_zulip_user_id,
slack_recipient_name_to_zulip_recipient_id,
all_messages,
[],
subscriber_map,
added_channels,
"domain",
set(),
convert_slack_threads=True,
do_download_and_export_upload_file=lambda request: None,
thread_counter=defaultdict(int),
thread_map={},
thread_reply_counts=count_thread_replies(iter(all_messages), True),
)
zerver_message = conversion_result.zerver_message
zerver_usermessage = conversion_result.zerver_usermessage
attachment = conversion_result.zerver_attachment
uploads = conversion_result.uploads_list
# functioning already tested in helper function
self.assertEqual(zerver_usermessage, [])
# subtype: channel_join is filtered
self.assert_length(zerver_message, 5)
self.assertEqual(uploads, [])
self.assertEqual(attachment, [])
expected_message_attachment = """## [dsdaddsa](https://app.clickup.com/t/86cv0v5my)
Added assignee Pieter
by Pieter
*[Task one](https://app.clickup.com/25567147/v/s/43687023) &gt; [dsad](https://app.clickup.com/25567147/v/li/901601846060)*
""".strip()
self.assertEqual(zerver_message[0]["content"], expected_message_attachment)
self.assertEqual(zerver_message[0]["sender"], slack_user_id_to_zulip_user_id["B06NWMNUQ3W"])
expected_message_block = """[ dsda](https://app.clickup.com/t/86cv0v5my)
Task Created
By Pieter
*[Task one](https://app.clickup.com/25567147/v/s/43687023) &gt; [dsad](https://app.clickup.com/25567147/v/li/901601846060)*
""".strip()
self.assertEqual(zerver_message[1]["content"], expected_message_block)
self.assertEqual(zerver_message[1]["sender"], slack_user_id_to_zulip_user_id["B06NWMNUQ3W"])
expected_message_block_2 = """## [Buy Ingredients!](https://app.clickup.com/t/86cuy8e4y?comment=90160026391938)
New comment: where can I buy it?
by Pieter
*[Task one](https://app.clickup.com/25567147/v/s/43687023) &gt; [dsad](https://app.clickup.com/25567147/v/li/901601846060)*
""".strip()
self.assertEqual(zerver_message[2]["content"], expected_message_block_2)
self.assertEqual(zerver_message[2]["sender"], slack_user_id_to_zulip_user_id["B06NWMNUQ3W"])
expected_message_block_3 = """
Hello there, I am a basic rich text block!
## [Buy Ingredients!](https://app.clickup.com/t/86cuy8e4y?comment=90160026391938)
New comment: where can I buy it?
by Pieter
*[Task one](https://app.clickup.com/25567147/v/s/43687023) &gt; [dsad](https://app.clickup.com/25567147/v/li/901601846060)*
""".strip()
self.assertEqual(zerver_message[3]["content"], expected_message_block_3)
self.assertEqual(zerver_message[3]["sender"], slack_user_id_to_zulip_user_id["B06NWMNUQ3W"])
expected_message_block_4 = """
`render_block` will delete me, help!
Hello, `render_block` recognizes me.
**[asd](https://app.clickup.com/t/25567147/86cw30wf2)**
[Cookie / The Goodiest of Cstdddddsdd / dds](https://search.clickup-au.com/media/app-icons/clickup/logo_alpha.png)
in dds
[Status](https://search.clickup-au.com/media/app-icons/clickup/status.png)
To Do
""".strip()
self.assertEqual(zerver_message[4]["content"], expected_message_block_4)
self.assertEqual(zerver_message[4]["sender"], slack_user_id_to_zulip_user_id["B06NWMNUQ3W"])
@mock.patch("zerver.data_import.slack.channel_message_to_zerver_message")
@mock.patch("zerver.data_import.slack.get_messages_iterator")
def test_convert_slack_workspace_messages(
self, mock_get_messages_iterator: mock.Mock, mock_message: mock.Mock
) -> None:
output_dir = os.path.join(settings.TEST_WORKER_DIR, "test-slack-import")
os.makedirs(output_dir, exist_ok=True)
added_channels: dict[str, tuple[str, int]] = {"random": ("c5", 1), "general": ("c6", 2)}
time = float(timezone_now().timestamp())
zerver_message = [{"id": 1, "ts": time}, {"id": 5, "ts": time}]
def fake_get_messages_iter(
slack_data_dir: str,
added_channels: AddedChannelsT,
added_mpims: AddedMPIMsT,
added_dms: AddedDMsT,
) -> Iterator[ZerverFieldsT]:
import copy
return iter(copy.deepcopy(zerver_message))
realm: dict[str, Any] = {"zerver_subscription": []}
user_list: list[dict[str, Any]] = []
reactions = [{"name": "grinning", "users": ["U061A5N1G"], "count": 1}]
attachments: list[AttachmentRecordData] = []
uploads: list[UploadRecordData] = []
zerver_usermessage = [{"id": 3}, {"id": 5}, {"id": 6}, {"id": 9}]
mock_get_messages_iterator.side_effect = fake_get_messages_iter
mock_message.side_effect = [
MessageConversionResult(
zerver_message=zerver_message[:1],
zerver_usermessage=zerver_usermessage[:2],
zerver_attachment=attachments,
uploads_list=uploads,
reaction_list=reactions[:1],
),
MessageConversionResult(
zerver_message=zerver_message[1:2],
zerver_usermessage=zerver_usermessage[2:5],
zerver_attachment=attachments,
uploads_list=uploads,
reaction_list=reactions[1:1],
),
]
with self.assertLogs(level="INFO"):
# Hacky: We should include a zerver_userprofile, not the empty []
test_reactions, _upload_list, _zerver_attachment = convert_slack_workspace_messages(
"./random_path",
user_list,
2,
{},
{},
added_channels,
{},
{},
realm,
[],
[],
"domain",
output_dir=output_dir,
convert_slack_threads=False,
do_download_and_export_upload_file=lambda request: None,
chunk_size=1,
)
messages_file_1 = os.path.join(output_dir, "messages-000001.json")
self.assertTrue(os.path.exists(messages_file_1))
messages_file_2 = os.path.join(output_dir, "messages-000002.json")
self.assertTrue(os.path.exists(messages_file_2))
with open(messages_file_1, "rb") as f:
message_json = orjson.loads(f.read())
self.assertEqual(message_json["zerver_message"], zerver_message[:1])
self.assertEqual(message_json["zerver_usermessage"], zerver_usermessage[:2])
with open(messages_file_2, "rb") as f:
message_json = orjson.loads(f.read())
self.assertEqual(message_json["zerver_message"], zerver_message[1:2])
self.assertEqual(message_json["zerver_usermessage"], zerver_usermessage[2:5])
self.assertEqual(test_reactions, reactions)
@responses.activate
@mock.patch("zerver.data_import.slack.build_attachment", return_value=[])
@mock.patch("zerver.data_import.slack.build_avatar_url", return_value=("", ""))
@mock.patch("zerver.data_import.slack.build_avatar")
@mock.patch("zerver.data_import.slack.get_slack_api_data")
@mock.patch("zerver.data_import.slack.check_slack_token_access")
def test_slack_import_to_existing_database(
self,
mock_check_slack_token_access: mock.Mock,
mock_get_slack_api_data: mock.Mock,
mock_build_avatar_url: mock.Mock,
mock_build_avatar: mock.Mock,
mock_attachment: mock.Mock,
) -> None:
test_slack_dir = os.path.join(
settings.DEPLOY_ROOT, "zerver", "tests", "fixtures", "slack_fixtures"
)
test_slack_zip_file = os.path.join(test_slack_dir, "test_slack_importer.zip")
test_slack_unzipped_file = os.path.join(test_slack_dir, "test_slack_importer")
test_realm_subdomain = "test-slack-import"
output_dir = os.path.join(settings.DEPLOY_ROOT, "var", "test-slack-importer-data")
token = "xoxb-valid-token"
# If the test fails, the 'output_dir' would not be deleted and hence it would give an
# error when we run the tests next time, as 'do_convert_data' expects an empty 'output_dir'
# hence we remove it before running 'do_convert_data'
self.rm_tree(output_dir)
# Also the unzipped data file should be removed if the test fails at 'do_convert_data'
self.rm_tree(test_slack_unzipped_file)
user_data_fixture = orjson.loads(self.fixture_data("user_data.json", type="slack_fixtures"))
# Mark a non-primary-owner, non-slackbot member as deleted in our copy
# of the fixture so that the import produces an inactive UserProfile.
deleted_slack_user_id = "U8VAHEVUY"
for member in user_data_fixture["members"]:
if member["id"] == deleted_slack_user_id:
self.assertFalse(member["is_primary_owner"])
self.assertFalse(member["is_bot"])
self.assertFalse(member["deleted"])
member["deleted"] = True
break
else:
raise AssertionError(f"Slack user {deleted_slack_user_id} not found in fixture")
team_info_fixture = orjson.loads(self.fixture_data("team_info.json", type="slack_fixtures"))
mock_get_slack_api_data.side_effect = [
user_data_fixture["members"],
{},
team_info_fixture["team"],
]
responses.add(
responses.GET,
re.compile(r"https://.*\.slack-edge\.com/.*"),
body=read_test_image_file("img.png"),
content_type="image/png",
status=200,
)
responses.add(
responses.GET,
re.compile(r"https://files\.slack\.com/.*"),
body=read_test_image_file("img.png"),
content_type="image/png",
status=200,
)
# A conversion must not read thread state cached by an earlier
# import in the same process; see reset_import_state.
thread_parent_map["0000000000.000000"] = "USTALEPARENT"
with self.assertLogs(level="INFO"), self.settings(EXTERNAL_HOST="zulip.example.com"):
# We need to mock EXTERNAL_HOST to be a valid domain because Slack's importer
# uses it to generate email addresses for users without an email specified.
do_convert_zipfile(test_slack_zip_file, output_dir, token, processes=1)
self.assertNotIn("0000000000.000000", thread_parent_map)
realm_id = 0
uploads_folder = os.path.join(output_dir, "uploads", str(realm_id))
self.assertTrue(os.path.exists(output_dir))
self.assertTrue(os.path.exists(output_dir + "/realm.json"))
self.assertTrue(os.path.exists(output_dir + "/migration_status.json"))
self.assertTrue(os.path.exists(uploads_folder))
self.assertTrue(os.listdir(uploads_folder) != [])
realm_icons_path = os.path.join(output_dir, "realm_icons")
realm_icon_records_path = os.path.join(realm_icons_path, "records.json")
self.assertTrue(os.path.exists(realm_icon_records_path))
with open(realm_icon_records_path, "rb") as f:
records = orjson.loads(f.read())
self.assert_length(records, 2)
self.assertEqual(records[0]["path"], "0/icon.original")
self.assertTrue(os.path.exists(os.path.join(realm_icons_path, records[0]["path"])))
self.assertEqual(records[1]["path"], "0/icon.png")
self.assertTrue(os.path.exists(os.path.join(realm_icons_path, records[1]["path"])))
# test import of the converted slack data into an existing database
with self.settings(BILLING_ENABLED=False), self.assertLogs(level="INFO"):
do_import_realm(output_dir, test_realm_subdomain)
realm = get_realm(test_realm_subdomain)
self.assertTrue(realm.name, test_realm_subdomain)
self.assertEqual(realm.icon_source, Realm.ICON_UPLOADED)
# test RealmAuditLog
realmauditlog = RealmAuditLog.objects.filter(realm=realm)
realmauditlog_event_type = {log.event_type for log in realmauditlog}
self.assertEqual(
realmauditlog_event_type,
{
AuditLogEventType.SUBSCRIPTION_CREATED,
AuditLogEventType.REALM_PLAN_TYPE_CHANGED,
AuditLogEventType.REALM_PROPERTY_CHANGED,
AuditLogEventType.REALM_CREATED,
AuditLogEventType.REALM_IMPORTED,
AuditLogEventType.USER_CREATED,
AuditLogEventType.USER_DEACTIVATED,
AuditLogEventType.USER_GROUP_CREATED,
AuditLogEventType.USER_GROUP_DIRECT_USER_MEMBERSHIP_ADDED,
AuditLogEventType.USER_GROUP_DIRECT_SUBGROUP_MEMBERSHIP_ADDED,
AuditLogEventType.USER_GROUP_DIRECT_SUPERGROUP_MEMBERSHIP_ADDED,
},
)
self.assertEqual(Message.objects.filter(realm=realm).count(), 95)
# All auth backends are enabled initially.
self.assertTrue(all(realm.authentication_methods_dict().values()))
Realm.objects.filter(name=test_realm_subdomain).delete()
remove_folder(output_dir)
self.assertFalse(os.path.exists(output_dir))
def test_message_files(self) -> None:
alice_id = 7
alice = dict(
id=alice_id,
profile=dict(
email="alice@example.com",
),
)
files = [
dict(
url_private="https://files.slack.com/apple.png",
title="Apple",
name="apple.png",
mimetype="image/png",
timestamp=9999,
created=8888,
size=3000000,
),
dict(
url_private="https://example.com/banana.zip",
title="banana",
),
# A Slack-hosted non-image after the image guards against
# has_image getting reassigned to False.
dict(
url_private="https://files.slack.com/notes.pdf",
title="Notes",
name="notes.pdf",
mimetype="application/pdf",
timestamp=9999,
created=8888,
size=1000,
),
]
message = dict(
user=alice_id,
files=files,
)
domain_name = "example.com"
realm_id = 5
message_id = 99
slack_user_id = "alice"
users = [alice]
slack_user_id_to_zulip_user_id = {
"alice": alice_id,
}
zerver_attachment: list[AttachmentRecordData] = []
uploads_list: list[UploadRecordData] = []
info = process_message_files(
message=message,
domain_name=domain_name,
realm_id=realm_id,
message_id=message_id,
slack_user_id=slack_user_id,
users=users,
slack_user_id_to_zulip_user_id=slack_user_id_to_zulip_user_id,
zerver_attachment=zerver_attachment,
uploads_list=uploads_list,
do_download_and_export_upload_file=lambda request: None,
)
self.assert_length(zerver_attachment, 2)
self.assert_length(uploads_list, 2)
image_path = zerver_attachment[0].path_id
pdf_path = zerver_attachment[1].path_id
expected_content = (
f"[Apple](/user_uploads/{image_path})\n"
f"[banana](https://example.com/banana.zip)\n"
f"[Notes](/user_uploads/{pdf_path})"
)
self.assertEqual(info["content"], expected_content)
self.assertTrue(info["has_link"])
self.assertTrue(info["has_image"])
self.assertEqual(uploads_list[0].s3_path, image_path)
self.assertEqual(uploads_list[0].realm_id, realm_id)
self.assertEqual(uploads_list[0].user_profile_id, alice_id)
def test_bot_duplicates(self) -> None:
self.assertEqual(
SlackBotEmail.get_email(
{"real_name_normalized": "Real Bot", "bot_id": "foo"}, "example.com"
),
"real-bot@example.com",
)
# SlackBotEmail keeps state -- doing it again appends a "2", "3", etc
self.assertEqual(
SlackBotEmail.get_email(
{"real_name_normalized": "Real Bot", "bot_id": "bar"}, "example.com"
),
"real-bot-2@example.com",
)
self.assertEqual(
SlackBotEmail.get_email(
{"real_name_normalized": "Real Bot", "bot_id": "baz"}, "example.com"
),
"real-bot-3@example.com",
)
# But caches based on the bot_id
self.assertEqual(
SlackBotEmail.get_email(
{"real_name_normalized": "Real Bot", "bot_id": "foo"}, "example.com"
),
"real-bot@example.com",
)
self.assertEqual(
SlackBotEmail.get_email({"first_name": "Other Name", "bot_id": "other"}, "example.com"),
"othername-bot@example.com",
)
def test_slack_emoji_name_to_codepoint(self) -> None:
self.assertEqual(slack_emoji_name_to_codepoint["thinking_face"], "1f914")
self.assertEqual(slack_emoji_name_to_codepoint["tophat"], "1f3a9")
self.assertEqual(slack_emoji_name_to_codepoint["dog2"], "1f415")
self.assertEqual(slack_emoji_name_to_codepoint["dog"], "1f436")
def test_slack_zip_path_traversal_rejected(self) -> None:
output_dir = os.path.join(settings.DEPLOY_ROOT, "var", "test-slack-path-traversal")
remove_folder(output_dir)
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
zip_path = f.name
with zipfile.ZipFile(f, "w") as zf:
zf.writestr("../traversal.json", "{}")
unzipped_path = zip_path.removesuffix(".zip")
try:
with self.assertRaises(SlackImportInvalidFileError):
do_convert_zipfile(zip_path, output_dir, "xoxb-fake-token", processes=1)
finally:
os.unlink(zip_path)
remove_folder(output_dir)
remove_folder(unzipped_path)
@mock.patch("zerver.data_import.slack.requests.get")
@mock.patch("zerver.data_import.slack.build_attachment", return_value=[])
@mock.patch("zerver.data_import.slack.build_avatar_url", return_value=("", ""))
@mock.patch("zerver.data_import.slack.build_avatar")
@mock.patch("zerver.data_import.slack.get_slack_api_data")
@mock.patch("zerver.data_import.slack.check_slack_token_access")
def test_slack_import_unicode_filenames(
self,
mock_check_slack_token_access: mock.Mock,
mock_get_slack_api_data: mock.Mock,
mock_build_avatar_url: mock.Mock,
mock_build_avatar: mock.Mock,
mock_attachment: mock.Mock,
mock_requests_get: mock.Mock,
) -> None:
test_slack_dir = os.path.join(
settings.DEPLOY_ROOT, "zerver", "tests", "fixtures", "slack_fixtures"
)
test_slack_zip_file = os.path.join(test_slack_dir, "test_unicode_slack_importer.zip")
test_slack_unzipped_file = os.path.join(test_slack_dir, "test_unicode_slack_importer")
output_dir = os.path.join(settings.DEPLOY_ROOT, "var", "test-unicode-slack-importer-data")
token = "xoxb-valid-token"
# If the test fails, the 'output_dir' would not be deleted and hence it would give an
# error when we run the tests next time, as 'do_convert_data' expects an empty 'output_dir'
# hence we remove it before running 'do_convert_data'
self.rm_tree(output_dir)
# Also the unzipped data file should be removed if the test fails at 'do_convert_data'
self.rm_tree(test_slack_unzipped_file)
user_data_fixture = orjson.loads(
self.fixture_data("unicode_user_data.json", type="slack_fixtures")
)
team_info_fixture = orjson.loads(
self.fixture_data("unicode_team_info.json", type="slack_fixtures")
)
mock_get_slack_api_data.side_effect = [
user_data_fixture["members"],
{},
team_info_fixture["team"],
]
mock_requests_get.return_value.raw = BytesIO(read_test_image_file("img.png"))
with self.assertLogs(level="INFO"), self.settings(EXTERNAL_HOST="zulip.example.com"):
# We need to mock EXTERNAL_HOST to be a valid domain because Slack's importer
# uses it to generate email addresses for users without an email specified.
do_convert_zipfile(test_slack_zip_file, output_dir, token, processes=1)
@mock.patch("zerver.data_import.slack.check_slack_token_access")
@responses.activate
def test_end_to_end_slack_import(
self,
mock_check_slack_token_access: mock.Mock,
) -> None:
# Choose import from slack
email = "ete-slack-import@zulip.com"
string_id = "ete-slack-import"
result = self.submit_realm_creation_form(
email,
realm_subdomain=string_id,
realm_name="Slack import end to end",
import_from="slack",
)
# Confirm email
self.assertEqual(result.status_code, 302)
self.assertTrue(
result["Location"].endswith(
"/accounts/new/send_confirm/?email=ete-slack-import%40zulip.com&realm_name=Slack+import+end+to+end&realm_type=10&realm_default_language=en&realm_subdomain=ete-slack-import"
)
)
result = self.client_get(result["Location"])
self.assert_in_response("check your email", result)
prereg_realm = PreregistrationRealm.objects.get(email=email)
self.assertEqual(prereg_realm.name, "Slack import end to end")
self.assertEqual(prereg_realm.data_import_metadata["import_from"], "slack")
# Redirect to slack data import form
confirmation_url = self.get_confirmation_url_from_outbox(email)
result = self.client_get(confirmation_url)
self.assert_in_success_response(["new/import/slack"], result)
confirmation_key = find_key_by_email(email)
assert confirmation_key is not None
# Check that the we show an error message if the token is invalid.
mock_check_slack_token_access.side_effect = SlackTokenValidationError("Invalid slack token")
result = self.client_post(
"/new/import/slack/",
{
"key": confirmation_key,
"slack_access_token": "xoxb-invalid-token",
},
)
self.assert_in_response("Invalid slack token", result)
mock_check_slack_token_access.side_effect = None
# Mock slack API response and mark token as valid
access_token = "xoxb-valid-token"
slack_team_info_url = "https://slack.com/api/team.info"
responses.add_callback(
responses.GET,
slack_team_info_url,
callback=lambda _: (
200,
{"x-oauth-scopes": "emoji:read,users:read,users:read.email,team:read"},
orjson.dumps({"ok": True}),
),
)
result = self.client_post(
"/new/import/slack/",
{
"key": confirmation_key,
"slack_access_token": access_token,
},
)
self.assertEqual(result.status_code, 200)
prereg_realm.refresh_from_db()
self.assertEqual(prereg_realm.data_import_metadata["import_from"], "slack")
self.assertEqual(prereg_realm.data_import_metadata["slack_access_token"], access_token)
# Assume user uploaded a file.
prereg_realm.data_import_metadata["uploaded_import_file_name"] = "test_slack_importer.zip"
prereg_realm.save()
# Set by the user performing the import.
importer_set_email_address_visibility = UserProfile.EMAIL_ADDRESS_VISIBILITY_NOBODY
# Check that deferred_work for import is queued.
with mock.patch("zerver.views.registration.queue_json_publish_rollback_unsafe") as m:
result = self.client_post(
"/new/import/slack/",
{
"key": confirmation_key,
"start_slack_import": "true",
"email_address_visibility": importer_set_email_address_visibility,
},
)
self.assert_in_success_response(["Import progress"], result)
prereg_realm.refresh_from_db()
self.assertTrue(prereg_realm.data_import_metadata["is_import_work_queued"])
self.assertTrue(
prereg_realm.data_import_metadata["email_address_visibility"],
importer_set_email_address_visibility,
)
m.assert_called_once_with(
"deferred_work",
{
"type": "import_slack_data",
"preregistration_realm_id": prereg_realm.id,
"filename": f"import/{prereg_realm.id}/slack.zip",
"slack_access_token": access_token,
},
)
# Test that clicking on the confirmation link redirects to status page
# which helps user resume the import process if they closed the tab.
result = self.client_get(confirmation_url)
self.assert_in_success_response(["new/import/slack"], result)
result = self.client_post(
"/new/import/slack/",
{
"key": confirmation_key,
"slack_access_token": access_token,
},
)
self.assert_in_success_response(["Import progress"], result)
# We don't want to test to whole realm import process here but only that
# realm import calls are made with correct arguments and different cases
# are handled well. do_import_realm creates the realm (and its users) as
# part of the import, so mock it to do so here -- matching production,
# where no realm with this subdomain exists until the import creates it.
def fake_do_import_realm(*args: object, **kwargs: object) -> Realm:
realm = do_create_realm(
string_id=prereg_realm.string_id,
name=prereg_realm.name,
)
do_create_user("email1", "password", realm, "full_name", acting_user=None)
do_create_user(
"bot_email",
"password",
realm,
"bot_full_name",
bot_type=UserProfile.DEFAULT_BOT,
acting_user=None,
)
self.assertEqual(
get_email_address_visibility_default(realm.org_type),
UserProfile.EMAIL_ADDRESS_VISIBILITY_ADMINS,
)
return realm
with (
mock.patch(
"zerver.actions.data_import.save_attachment_contents"
) as mocked_save_attachment,
mock.patch("zerver.actions.data_import.do_convert_zipfile") as mocked_convert_zipfile,
mock.patch(
"zerver.actions.data_import.do_import_realm", side_effect=fake_do_import_realm
) as mocked_import_realm,
):
from zerver.lib.queue import queue_json_publish_rollback_unsafe
queue_json_publish_rollback_unsafe(
"deferred_work",
{
"type": "import_slack_data",
"preregistration_realm_id": prereg_realm.id,
"filename": f"import/{prereg_realm.id}/slack.zip",
"slack_access_token": access_token,
},
)
self.assertTrue(mocked_save_attachment.called)
self.assertTrue(mocked_convert_zipfile.called)
self.assertTrue(mocked_import_realm.called)
realm = Realm.objects.get(string_id=prereg_realm.string_id)
self.assertEqual(realm.org_type, prereg_realm.org_type)
self.assertEqual(realm.default_language, prereg_realm.default_language)
prereg_realm.refresh_from_db()
self.assertTrue(prereg_realm.data_import_metadata["need_select_realm_owner"])
# Check that imported non-bot users have the importer-provided email
# visibility setting.
test_user = UserProfile.objects.get(realm=realm, delivery_email="email1")
self.assertEqual(
test_user.email_address_visibility,
importer_set_email_address_visibility,
)
# Check that bots were not impacted by this setting.
test_bot_user = UserProfile.objects.get(realm=realm, delivery_email="bot_email")
self.assertEqual(
test_bot_user.email_address_visibility, UserProfile.EMAIL_ADDRESS_VISIBILITY_EVERYONE
)
# Confirmation key at this point is marked, used but since we
# are mocking the process, we need to do it manually here.
get_object_from_key(
confirmation_key, [Confirmation.NEW_REALM_USER_REGISTRATION], mark_object_used=True
)
result = self.client_get(f"/json/realm/import/status/{confirmation_key}")
self.assert_in_success_response(["No users matching provided email"], result)
# Create a user who become the realm owner, ideally this will be created
# as part of the import process or we will add form for user to do so.
imported_user_to_be_owner = UserProfile.objects.create(realm=realm, delivery_email=email)
imported_user_to_be_owner.set_unusable_password()
imported_user_to_be_owner.save()
def post_process_request(key: str | None = confirmation_key) -> "TestHttpResponse":
return self.client_post(
f"/realm/import/post_process/{key}",
{
"user_id": str(imported_user_to_be_owner.id),
},
)
# Show error on using wrong confirmation key.
with mock.patch(
"zerver.views.registration.render_confirmation_key_error",
return_value=HttpResponse(status=200),
) as m:
post_process_request("malformed_key")
m.assert_called_once()
# Check if we cannot find the realm, preregistration_realm is revoked.
prereg_realm.string_id = "non_existent_realm"
prereg_realm.save()
result = post_process_request()
prereg_realm.refresh_from_db()
self.assertEqual(prereg_realm.status, confirmation_settings.STATUS_REVOKED)
# Reset status for further tests.
prereg_realm.string_id = string_id
prereg_realm.status = 0
prereg_realm.save()
# Redirect user to password reset page on successful import.
result = post_process_request()
self.assertEqual(result.status_code, 302)
self.assertTrue(
result["Location"].startswith(
"http://ete-slack-import.testserver/accounts/password/reset/"
)
)
# If user refreshes the page, redirect to login page if the import was successful.
result = self.client_get(f"/realm/import/post_process/{confirmation_key}")
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "http://ete-slack-import.testserver/accounts/login/")
# Check if we render a form for user to select a user if there
# are no users matching the provided email.
prereg_realm.data_import_metadata["need_select_realm_owner"] = True
prereg_realm.save()
result = self.client_get(f"/realm/import/post_process/{confirmation_key}")
self.assert_in_success_response(["Select your account"], result)
# Check that user is redirected to this form using email confirmation link.
result = self.client_get(confirmation_url)
self.assert_in_success_response(["new/import/slack"], result)
result = self.client_post(
"/new/import/slack/",
{
"key": confirmation_key,
"slack_access_token": access_token,
},
)
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], f"/realm/import/post_process/{confirmation_key}")
result = self.client_get(f"/realm/import/post_process/{confirmation_key}")
self.assert_in_success_response(["Select your account"], result)
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_found_user_matching_email_of_importer(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm-slack-import",
name="Test Realm",
email="test_import_slack_data_user@example.com",
data_import_metadata={"import_from": "slack"},
)
# do_import_realm creates the realm (with the importing user) during
# the import; mock it to do so, matching production where the realm
# does not exist until the import creates it.
def fake_do_import_realm(*args: object, **kwargs: object) -> Realm:
realm = do_create_realm(
string_id=prereg_realm.string_id,
name=prereg_realm.name,
)
UserProfile.objects.create(
realm=realm,
delivery_email=prereg_realm.email,
)
return realm
mock_do_import_realm.side_effect = fake_do_import_realm
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
import_slack_data(event)
mock_save_attachment_contents.assert_called_once()
mock_do_convert_zipfile.assert_called_once_with(
mock.ANY, mock.ANY, event["slack_access_token"], convert_slack_threads=True
)
mock_do_import_realm.assert_called_once_with(
mock.ANY, prereg_realm.string_id, on_realm_created=mock.ANY
)
prereg_realm.refresh_from_db()
self.assertEqual(prereg_realm.status, 1) # STATUS_USED
realm = prereg_realm.created_realm
assert realm is not None
self.assertEqual(realm.string_id, prereg_realm.string_id)
self.assertFalse(prereg_realm.data_import_metadata["is_import_work_queued"])
self.assertFalse(prereg_realm.data_import_metadata.get("need_select_realm_owner"))
# Check that the importing user was made the realm owner
importing_user = UserProfile.objects.get(realm=realm, delivery_email=prereg_realm.email)
self.assertEqual(importing_user.role, UserProfile.ROLE_REALM_OWNER)
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_reactivates_deactivated_importing_user(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
# If the importing user's account was deactivated in the export, the
# import must not fail at the end (throwing away the imported realm);
# instead reactivate the account and make it the realm owner.
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm-inactive-owner",
name="Test Realm",
email="importer@example.com",
data_import_metadata={"import_from": "slack"},
)
def fake_do_import_realm(*args: object, **kwargs: object) -> Realm:
realm = do_create_realm(string_id=prereg_realm.string_id, name=prereg_realm.name)
importing_user = do_create_user(
prereg_realm.email,
"password",
realm,
"Importing user",
acting_user=None,
)
do_deactivate_user(importing_user, acting_user=None)
return realm
mock_do_import_realm.side_effect = fake_do_import_realm
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
import_slack_data(event)
prereg_realm.refresh_from_db()
realm = prereg_realm.created_realm
assert realm is not None
# The imported realm is preserved and its owner reactivated.
self.assertEqual(prereg_realm.status, confirmation_settings.STATUS_USED)
self.assertFalse(prereg_realm.data_import_metadata.get("need_select_realm_owner"))
importing_user = UserProfile.objects.get(realm=realm, delivery_email=prereg_realm.email)
self.assertTrue(importing_user.is_active)
self.assertEqual(importing_user.role, UserProfile.ROLE_REALM_OWNER)
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_importing_user_is_bot_prompts_owner_selection(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
# If the importer's email maps to a bot account, it cannot own the
# realm; fall back to the "select your account" flow rather than
# crashing and discarding the imported realm.
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm-bot-owner",
name="Test Realm",
email="bot-importer@example.com",
data_import_metadata={"import_from": "slack"},
)
def fake_do_import_realm(*args: object, **kwargs: object) -> Realm:
realm = do_create_realm(string_id=prereg_realm.string_id, name=prereg_realm.name)
owner = do_create_user(
"human-owner@example.com", "password", realm, "Human", acting_user=None
)
do_create_user(
prereg_realm.email,
"password",
realm,
"Bot account",
bot_type=UserProfile.DEFAULT_BOT,
bot_owner=owner,
acting_user=None,
)
return realm
mock_do_import_realm.side_effect = fake_do_import_realm
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
import_slack_data(event)
prereg_realm.refresh_from_db()
realm = prereg_realm.created_realm
assert realm is not None
# The realm is preserved; the user is asked to select an owner.
self.assertTrue(prereg_realm.data_import_metadata["need_select_realm_owner"])
self.assertNotEqual(prereg_realm.status, confirmation_settings.STATUS_USED)
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_create_new_user_for_importer(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
# Choose import from slack
email = "ete-slack-import@zulip.com"
string_id = "ete-slack-import"
result = self.submit_realm_creation_form(
email,
realm_subdomain=string_id,
realm_name="Slack import create new user",
import_from="slack",
)
# Redirect to slack data import form
confirmation_url = self.get_confirmation_url_from_outbox(email)
result = self.client_get(confirmation_url)
self.assert_in_success_response(["new/import/slack"], result)
confirmation_key = find_key_by_email(email)
assert confirmation_key is not None
prereg_realm = PreregistrationRealm.objects.get(email=email)
# do_import_realm creates the realm during the import; mock it to do
# so, matching production where the realm does not exist until the
# import creates it.
def fake_do_import_realm(*args: object, **kwargs: object) -> Realm:
return do_create_realm(
string_id=prereg_realm.string_id,
name=prereg_realm.name,
)
mock_do_import_realm.side_effect = fake_do_import_realm
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
import_slack_data(event)
mock_save_attachment_contents.assert_called_once()
mock_do_convert_zipfile.assert_called_once_with(
mock.ANY, mock.ANY, event["slack_access_token"], convert_slack_threads=True
)
mock_do_import_realm.assert_called_once_with(
mock.ANY, prereg_realm.string_id, on_realm_created=mock.ANY
)
prereg_realm.refresh_from_db()
self.assertTrue(prereg_realm.data_import_metadata.get("need_select_realm_owner"))
confirmation_key = find_key_by_email(prereg_realm.email)
result = self.client_get(f"/realm/import/post_process/{confirmation_key}")
self.assert_in_success_response(["Select your account"], result)
# Choose to create new account for the importer.
result = self.client_post(
f"/realm/import/post_process/{confirmation_key}",
{
"user_id": "",
},
)
self.assertEqual(result.status_code, 302)
prereg_realm.refresh_from_db()
self.assertIsNotNone(prereg_realm.data_import_metadata["user_activation_url"])
self.assertEqual(
result["Location"], prereg_realm.data_import_metadata["user_activation_url"]
)
# Test that importer can reach the user registration page from realm creation email
# confirmation link.
result = self.client_get(confirmation_url)
self.assert_in_success_response(["new/import/slack"], result)
result = self.client_post(
"/new/import/slack/",
{
"key": confirmation_key,
"from_confirmation": "1",
},
)
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"], prereg_realm.data_import_metadata["user_activation_url"]
)
# Get key from activation url.
key = urlsplit(prereg_realm.data_import_metadata["user_activation_url"]).path.split("/")[-1]
self.client_post(
"/accounts/register/",
{
"password": "testpassword",
"key": key,
"terms": True,
"full_name": "Realm importer",
"email": prereg_realm.email,
},
subdomain=prereg_realm.string_id,
)
prereg_realm.refresh_from_db()
self.assertEqual(prereg_realm.status, confirmation_settings.STATUS_USED)
assert prereg_realm.created_realm is not None
self.assertEqual(prereg_realm.created_realm.string_id, prereg_realm.string_id)
self.assertFalse(prereg_realm.data_import_metadata["is_import_work_queued"])
self.assertFalse(prereg_realm.data_import_metadata.get("need_select_realm_owner"))
self.assertIsNone(prereg_realm.data_import_metadata.get("user_activation_url"))
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_failure_cleanup_realm_not_created(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm",
name="Test Realm",
email="test@example.com",
data_import_metadata={"import_from": "slack"},
)
mock_do_import_realm.side_effect = SlackImportInvalidFileError("Invalid file")
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
with (
self.assertRaises(SlackImportInvalidFileError),
self.assertLogs("zerver.actions.data_import", "ERROR"),
):
import_slack_data(event)
prereg_realm.refresh_from_db()
self.assertIsNone(prereg_realm.created_realm)
self.assertFalse(prereg_realm.data_import_metadata["is_import_work_queued"])
self.assertEqual(
prereg_realm.data_import_metadata["invalid_file_error_message"], "Invalid file"
)
self.assertFalse(Realm.objects.filter(string_id="test-realm").exists())
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_failure_cleanup_realm_created(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
# If do_import_realm durably creates the realm and then fails during a
# later step, the failure handler must delete the half-imported realm
# that this import created.
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm",
name="Test Realm",
email="test@example.com",
data_import_metadata={"import_from": "slack"},
)
def create_realm_then_fail(*args: object, **kwargs: object) -> Realm:
do_create_realm(string_id=prereg_realm.string_id, name=prereg_realm.name)
raise AssertionError("Import failed")
mock_do_import_realm.side_effect = create_realm_then_fail
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
with (
self.assertRaises(AssertionError),
self.assertLogs("zerver.actions.data_import", "ERROR"),
):
import_slack_data(event)
prereg_realm.refresh_from_db()
self.assertIsNone(prereg_realm.created_realm)
self.assertFalse(prereg_realm.data_import_metadata["is_import_work_queued"])
self.assertFalse(Realm.objects.filter(string_id=prereg_realm.string_id).exists())
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_failure_keeps_concurrently_created_realm(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
# A concurrent import of the same subdomain wins the race to create
# the realm while this import runs; do_import_realm then fails with an
# IntegrityError on the unique string_id. This import created nothing,
# so it must not delete the concurrently created realm -- doing so
# would destroy that realm's data and can deadlock against its
# in-progress work.
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm",
name="Test Realm",
email="test@example.com",
data_import_metadata={"import_from": "slack"},
)
def create_realm_then_conflict(*args: object, **kwargs: object) -> Realm:
other_realm = do_create_realm(string_id=prereg_realm.string_id, name=prereg_realm.name)
UserProfile.objects.create(
realm=other_realm,
delivery_email="someone@example.com",
email="someone@example.com",
)
raise slack_import_integrity_error("zerver_realm_string_id_key")
mock_do_import_realm.side_effect = create_realm_then_conflict
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
with (
self.assertLogs("zulip.registration", "ERROR") as logs,
self.assertRaises(IntegrityError),
):
import_slack_data(event)
self.assertIn("zerver_realm_string_id_key", logs.output[0])
# The concurrently created realm and its data must be untouched.
other_realm = Realm.objects.get(string_id=prereg_realm.string_id)
self.assertTrue(
UserProfile.objects.filter(
realm=other_realm, delivery_email="someone@example.com"
).exists()
)
prereg_realm.refresh_from_db()
self.assertIsNone(prereg_realm.created_realm)
self.assertFalse(prereg_realm.data_import_metadata["is_import_work_queued"])
# The conflict is recorded so the status poll can surface it.
self.assertTrue(prereg_realm.data_import_metadata["subdomain_unavailable"])
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_failure_unrelated_integrity_error(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
# An IntegrityError that is not the unique violation on Realm.string_id
# -- e.g. a genuine bug in the import code hitting some other constraint
# -- must be treated like any other failure: the half-imported realm
# this import created is cleaned up, and the subdomain is not reported
# as unavailable.
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm",
name="Test Realm",
email="test@example.com",
data_import_metadata={"import_from": "slack"},
)
def create_realm_then_unrelated_conflict(*args: object, **kwargs: object) -> Realm:
do_create_realm(string_id=prereg_realm.string_id, name=prereg_realm.name)
raise slack_import_integrity_error("zerver_useractivityinterval_uniq")
mock_do_import_realm.side_effect = create_realm_then_unrelated_conflict
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
with (
self.assertLogs("zulip.registration", "ERROR") as logs,
self.assertRaises(IntegrityError),
):
import_slack_data(event)
self.assertIn("zerver_useractivityinterval_uniq", logs.output[0])
prereg_realm.refresh_from_db()
self.assertIsNone(prereg_realm.created_realm)
self.assertFalse(prereg_realm.data_import_metadata["is_import_work_queued"])
# The realm this import created is cleaned up, since this is not a
# subdomain conflict.
self.assertFalse(Realm.objects.filter(string_id=prereg_realm.string_id).exists())
# A non-string_id error must not be reported as a taken subdomain.
self.assertNotIn("subdomain_unavailable", prereg_realm.data_import_metadata)
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_aborts_when_subdomain_taken(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
# If a realm with this subdomain already exists when the import job
# starts -- e.g. an earlier import of the same subdomain finished
# first -- abort without attempting the (doomed) import and without
# touching the existing realm.
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm",
name="Test Realm",
email="test@example.com",
data_import_metadata={"import_from": "slack", "is_import_work_queued": True},
)
other_realm = do_create_realm(
string_id=prereg_realm.string_id,
name=prereg_realm.name,
)
other_user = UserProfile.objects.create(
realm=other_realm,
delivery_email="someone@example.com",
email="someone@example.com",
)
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
with self.assertLogs("zulip.registration", "ERROR") as logs:
import_slack_data(event)
self.assertIn("subdomain is already in use", logs.output[0])
# The import was not attempted, and the existing realm is untouched.
mock_save_attachment_contents.assert_not_called()
mock_do_convert_zipfile.assert_not_called()
mock_do_import_realm.assert_not_called()
self.assertTrue(Realm.objects.filter(id=other_realm.id).exists())
self.assertTrue(UserProfile.objects.filter(id=other_user.id).exists())
prereg_realm.refresh_from_db()
self.assertIsNone(prereg_realm.created_realm)
self.assertFalse(prereg_realm.data_import_metadata["is_import_work_queued"])
# The conflict is recorded so the status poll can surface it.
self.assertTrue(prereg_realm.data_import_metadata["subdomain_unavailable"])
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_recovers_own_orphaned_realm_on_redelivery(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
# do_import_realm durably commits the realm partway through, so a
# worker that dies before finishing leaves a half-imported realm; the
# event is then redelivered. On redelivery the import must recognize
# the realm as its own (via the import_created_realm_id stamp) and
# clean it up and re-import, rather than treating it as a foreign
# conflict and orphaning the realm forever.
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm",
name="Test Realm",
email="test@example.com",
data_import_metadata={"import_from": "slack", "is_import_work_queued": True},
)
# The orphan left behind by the crashed prior attempt, stamped as ours.
orphan = do_create_realm(string_id=prereg_realm.string_id, name=prereg_realm.name)
orphan_user = UserProfile.objects.create(
realm=orphan,
delivery_email="someone@example.com",
email="someone@example.com",
)
prereg_realm.data_import_metadata["import_created_realm_id"] = orphan.id
prereg_realm.save(update_fields=["data_import_metadata"])
def reimport(*args: object, on_realm_created: object = None, **kwargs: object) -> Realm:
new_realm = do_create_realm(string_id=prereg_realm.string_id, name=prereg_realm.name)
assert callable(on_realm_created)
on_realm_created(new_realm)
return new_realm
mock_do_import_realm.side_effect = reimport
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
with self.assertLogs("zulip.registration", "INFO") as logs:
import_slack_data(event)
self.assertIn(
"WARNING:zulip.registration:"
"(test-realm) Cleaning up our own orphaned realm from a prior import attempt",
logs.output,
)
# The orphan and its data are gone, and a fresh realm was imported in
# its place.
self.assertFalse(Realm.objects.filter(id=orphan.id).exists())
self.assertFalse(UserProfile.objects.filter(id=orphan_user.id).exists())
mock_do_import_realm.assert_called_once()
new_realm = Realm.objects.get(string_id=prereg_realm.string_id)
self.assertNotEqual(new_realm.id, orphan.id)
prereg_realm.refresh_from_db()
self.assertEqual(prereg_realm.created_realm_id, new_realm.id)
self.assertFalse(prereg_realm.data_import_metadata["is_import_work_queued"])
@mock.patch("zerver.actions.data_import.do_import_realm")
@mock.patch("zerver.actions.data_import.do_convert_zipfile")
@mock.patch("zerver.actions.data_import.save_attachment_contents")
def test_import_slack_data_skips_redelivery_after_success(
self,
mock_save_attachment_contents: mock.Mock,
mock_do_convert_zipfile: mock.Mock,
mock_do_import_realm: mock.Mock,
) -> None:
# If a successful import's event is redelivered (the worker died after
# the import finished but before acking), the import must be a no-op
# rather than re-running and clobbering the created realm.
prereg_realm = PreregistrationRealm.objects.create(
string_id="test-realm",
name="Test Realm",
email="test@example.com",
data_import_metadata={"import_from": "slack"},
)
created_realm = do_create_realm(string_id=prereg_realm.string_id, name=prereg_realm.name)
prereg_realm.created_realm = created_realm
prereg_realm.save(update_fields=["created_realm"])
event = {
"preregistration_realm_id": prereg_realm.id,
"filename": "import/test/slack.zip",
"slack_access_token": "xoxb-valid-token",
}
with self.assertLogs("zulip.registration", "INFO") as logs:
import_slack_data(event)
self.assertEqual(
logs.output,
[
(
"INFO:zulip.registration:"
"(test-realm) Slack import already completed; skipping redelivery"
)
],
)
mock_save_attachment_contents.assert_not_called()
mock_do_convert_zipfile.assert_not_called()
mock_do_import_realm.assert_not_called()
self.assertTrue(Realm.objects.filter(id=created_realm.id).exists())
def test_realm_import_status_failed_import_does_not_use_other_realm(self) -> None:
# A failed import must report its own failure, even when a
# different registration's realm happens to share the
# subdomain. Otherwise the failed import's status poll would
# read the other realm and report misleading progress (or even
# "Done").
other_realm = do_create_realm(string_id="shared-subdomain", name="Other realm")
self.assertFalse(other_realm.deactivated)
prereg_realm = PreregistrationRealm.objects.create(
string_id="shared-subdomain",
name="Test Realm",
email="test@example.com",
data_import_metadata={
"import_from": "slack",
"is_import_work_queued": False,
"subdomain_unavailable": True,
},
)
confirmation_key = create_confirmation_link(
prereg_realm,
Confirmation.NEW_REALM_USER_REGISTRATION,
no_associated_realm_object=True,
).split("/")[-1]
result = self.client_get(f"/json/realm/import/status/{confirmation_key}")
response_dict = self.assert_json_success(result)
self.assertIn("no longer available", response_dict["status"])
# The other realm must not be reported as this import's result.
self.assertNotIn("Done", response_dict["status"])
def test_realm_import_status_clears_stale_subdomain_unavailable_flag(self) -> None:
# If the import that won the race for the subdomain later failed and
# freed it, this import's stored subdomain_unavailable flag is stale.
# The status poll must clear the flag and report this import's own
# failure, rather than telling the user the URL is still taken.
prereg_realm = PreregistrationRealm.objects.create(
string_id="freed-subdomain",
name="Test Realm",
email="freed@example.com",
data_import_metadata={
"import_from": "slack",
"is_import_work_queued": False,
"subdomain_unavailable": True,
},
)
self.assertFalse(Realm.objects.filter(string_id="freed-subdomain").exists())
confirmation_key = create_confirmation_link(
prereg_realm,
Confirmation.NEW_REALM_USER_REGISTRATION,
no_associated_realm_object=True,
).split("/")[-1]
result = self.client_get(f"/json/realm/import/status/{confirmation_key}")
response_dict = self.assert_json_success(result)
# The freed subdomain is not reported as taken; this import's own
# failure surfaces instead.
self.assertNotIn("no longer available", response_dict["status"])
self.assertEqual(response_dict["status"], "unexpected_import_failure")
# The stale flag has been cleared from the stored metadata.
prereg_realm.refresh_from_db()
self.assertNotIn("subdomain_unavailable", prereg_realm.data_import_metadata)
def test_start_slack_import_without_uploaded_file_renders_page(self) -> None:
# POSTing "start import" before the export file finished uploading must
# re-render the import page, not 500 on an assertion.
prereg_realm = PreregistrationRealm.objects.create(
string_id="start-no-file",
name="Test Realm",
email="nofile@zulip.com",
data_import_metadata={"import_from": "slack", "slack_access_token": "xoxb-token"},
)
confirmation_key = create_confirmation_link(
prereg_realm,
Confirmation.NEW_REALM_USER_REGISTRATION,
no_associated_realm_object=True,
).split("/")[-1]
with mock.patch(
"zerver.views.registration.queue_json_publish_rollback_unsafe"
) as queue_mock:
result = self.client_post(
"/new/import/slack/",
{"key": confirmation_key, "start_slack_import": "true"},
)
self.assertEqual(result.status_code, 200)
queue_mock.assert_not_called()
prereg_realm.refresh_from_db()
self.assertIsNot(prereg_realm.data_import_metadata.get("is_import_work_queued"), True)
def test_start_slack_import_with_taken_subdomain_shows_error(self) -> None:
# Starting an import for a subdomain that has since been taken -- e.g.
# a concurrent import of the same subdomain finished first -- must show
# a clear error rather than enqueueing a doomed import.
do_create_realm(string_id="taken-subdomain", name="Existing realm")
prereg_realm = PreregistrationRealm.objects.create(
string_id="taken-subdomain",
name="Test Realm",
email="taken@zulip.com",
data_import_metadata={
"import_from": "slack",
"slack_access_token": "xoxb-token",
"uploaded_import_file_name": "export.zip",
},
)
confirmation_key = create_confirmation_link(
prereg_realm,
Confirmation.NEW_REALM_USER_REGISTRATION,
no_associated_realm_object=True,
).split("/")[-1]
with mock.patch(
"zerver.views.registration.queue_json_publish_rollback_unsafe"
) as queue_mock:
result = self.client_post(
"/new/import/slack/",
{"key": confirmation_key, "start_slack_import": "true"},
)
self.assertEqual(result.status_code, 200)
self.assert_in_response("no longer available", result)
# The page offers a clear way out and hides the now-useless import form.
self.assert_in_response("Start over", result)
self.assert_not_in_success_response(["Slack bot user OAuth token"], result)
queue_mock.assert_not_called()
prereg_realm.refresh_from_db()
self.assertIsNone(prereg_realm.created_realm)
self.assertIsNot(prereg_realm.data_import_metadata.get("is_import_work_queued"), True)
def test_slack_import_page_warns_when_subdomain_taken(self) -> None:
# When the subdomain is already taken (e.g. a concurrent import of the
# same subdomain got there first), the import setup page itself shows
# the error, rather than leaving the user filling out a form for an
# import that can't succeed.
do_create_realm(string_id="taken-subdomain-2", name="Existing realm")
prereg_realm = PreregistrationRealm.objects.create(
string_id="taken-subdomain-2",
name="Test Realm",
email="taken2@zulip.com",
data_import_metadata={"import_from": "slack", "slack_access_token": "xoxb-token"},
)
confirmation_key = create_confirmation_link(
prereg_realm,
Confirmation.NEW_REALM_USER_REGISTRATION,
no_associated_realm_object=True,
).split("/")[-1]
# Render the setup page (no start_slack_import).
result = self.client_post("/new/import/slack/", {"key": confirmation_key})
self.assertEqual(result.status_code, 200)
self.assert_in_response("no longer available", result)
# The page offers a clear way out and hides the now-useless import form.
self.assert_in_response("Start over", result)
self.assert_not_in_success_response(["Slack bot user OAuth token"], result)
@responses.activate
def test_cancel_realm_import(self) -> None:
# Choose import from slack
email = "ete-slack-import@zulip.com"
self.submit_realm_creation_form(
email,
realm_subdomain="ete-slack-import",
realm_name="Slack import end to end",
import_from="slack",
)
prereg_realm = PreregistrationRealm.objects.get(email=email)
self.assertEqual(prereg_realm.data_import_metadata["import_from"], "slack")
# If the import is already in process, don't allow import cancellation.
prereg_realm.data_import_metadata["is_import_work_queued"] = True
prereg_realm.save()
confirmation_key = find_key_by_email(email)
assert confirmation_key is not None
response = self.client_post(
"/new/import/slack/",
{
"key": confirmation_key,
"cancel_import": "true",
},
)
self.assert_in_success_response(["Unable to cancel import"], response)
prereg_realm.refresh_from_db()
self.assertTrue(prereg_realm.data_import_metadata["is_import_work_queued"])
# Allow cancellation if the import work is not queued.
prereg_realm.data_import_metadata["is_import_work_queued"] = False
prereg_realm.save()
response = self.client_post(
"/new/import/slack/",
{
"key": confirmation_key,
"cancel_import": "true",
},
)
prereg_realm.refresh_from_db()
self.assertIsNone(prereg_realm.data_import_metadata.get("import_from"))
@responses.activate
def test_cancel_realm_import_realm_created(self) -> None:
# If user cancelled import after realm was created,
# clean up the created realm.
email = "ete-slack-import@zulip.com"
self.submit_realm_creation_form(
email,
realm_subdomain="ete-slack-import",
realm_name="Slack import end to end",
import_from="slack",
)
prereg_realm = PreregistrationRealm.objects.get(email=email)
self.assertEqual(prereg_realm.data_import_metadata["import_from"], "slack")
realm = do_create_realm(
string_id=prereg_realm.string_id,
name=prereg_realm.name,
)
self.assertTrue(Realm.objects.filter(string_id=prereg_realm.string_id).exists())
prereg_realm.created_realm = realm
prereg_realm.save()
confirmation_key = find_key_by_email(email)
assert confirmation_key is not None
# We don't allow cancellation if the complete import work is done.
with self.assertRaises(AssertionError), self.assertLogs("django.request", "ERROR"):
self.client_post(
"/new/import/slack/",
{
"key": confirmation_key,
"cancel_import": "true",
},
)
@responses.activate
def test_download_and_export_upload_file_bad_requests(self) -> None:
request_url = "https://files.slack.com/files-pri/ABC/DEF.png?token=xoxe-AAA-BBB-CCC-DDD"
error_message = "The requested file could not be found."
responses.add(responses.GET, request_url, status=404, json=error_message)
with self.assertLogs(level="INFO") as logs, self.assertRaises(Exception) as e:
download_and_export_upload_file(
"",
UploadFileRequest(
output_file_path_id="text.txt",
request_url=request_url,
params=None,
headers=None,
kwargs={},
),
)
self.assertIn(
'INFO:root:HTTP error: 404, Response: "The requested file could not be found."',
logs.output,
)
self.assertEqual("Failed downloading file.", str(e.exception))
@responses.activate
def test_slack_get_emojis(self) -> None:
@dataclass
class FileFixture:
content_type: str
file_type_extension: str
url: str
file_fixtures = [
FileFixture(
content_type="image/png",
file_type_extension=".png",
url="https://emoji.slack-edge.com/T06NRA6HM3P/bowtie/f3ec6f2bb0.png",
),
FileFixture(
content_type="image/gif",
file_type_extension=".gif",
url="https://emoji.slack-edge.com/T06NRA6HM3P/bowtie/f3ec6f2bb0.gif",
),
]
realm_id = 0
output_dir = tempfile.mkdtemp()
emoji_name = "bow_tie"
for file_fixture in file_fixtures:
with self.subTest(content_type=file_fixture.content_type):
responses.add(
responses.GET,
file_fixture.url,
body=read_test_image_file("img.png"),
content_type=file_fixture.content_type,
status=200,
)
try:
emoji_result = get_emojis(
output_dir,
file_fixture.url,
emoji_name,
10,
realm_id,
)
assert emoji_result.filename is not None
self.assertFalse(emoji_result.filename.startswith(emoji_name))
self.assertRegex(emoji_result.filename, r"^[a-f0-9]{8}\.\w+$")
self.assertTrue(
emoji_result.filename.endswith(file_fixture.file_type_extension)
)
emoji_dir = os.path.join(output_dir, str(realm_id), "emoji", "images")
emoji_files = os.listdir(emoji_dir)
self.assert_length(emoji_files, 1)
finally:
shutil.rmtree(output_dir)
@responses.activate
def test_slack_get_emojis_unknown_content_type(self) -> None:
realm_id = 0
output_dir = tempfile.mkdtemp()
emoji_name = "bow_tie"
emoji_url = "https://emoji.slack-edge.com/T06NRA6HM3P/bowtie/f3ec6f2bb0.png"
unknown_image_type = "image/unknown"
self.assertTrue(
unknown_image_type not in THUMBNAIL_ACCEPT_IMAGE_TYPES
or unknown_image_type not in INLINE_MIME_TYPES
)
responses.add(
responses.GET,
emoji_url,
body=read_test_image_file("img.png"),
content_type=unknown_image_type,
status=200,
)
with self.assertRaises(BadImageError) as e:
get_emojis(
output_dir,
emoji_url,
emoji_name,
10,
realm_id,
)
self.assertEqual(
f"Emoji {emoji_name} is not an image file. Content type: {unknown_image_type}",
str(e.exception),
)
emoji_dir = os.path.join(output_dir, str(realm_id), "emoji", "images")
self.assertFalse(os.path.exists(emoji_dir))
@responses.activate
def test_guess_emoji_content_type(self) -> None:
realm_id = 0
output_dir = tempfile.mkdtemp()
emoji_name = "unknown_content_type"
emoji_url = "https://emoji.slack-edge.com/T06NRA6HM3P/slack_zulip/3d93d672c2ba4dfc.gif"
responses.add(
responses.GET,
emoji_url,
body=read_test_image_file("img.png"),
content_type=None,
status=200,
)
try:
with self.assertLogs(level="WARN") as logs:
emoji_result = get_emojis(
output_dir,
emoji_url,
emoji_name,
10,
realm_id,
)
self.assertIn(
f"WARNING:root:Emoji {emoji_name} has an unspecified content type. Guessing from the file extension of {emoji_url}",
logs.output,
)
assert emoji_result.filename is not None
self.assertFalse(emoji_result.filename.startswith(emoji_name))
self.assertRegex(emoji_result.filename, r"^[a-f0-9]{8}\.\w+$")
self.assertTrue(emoji_result.filename.endswith(".gif"))
emoji_dir = os.path.join(output_dir, str(realm_id), "emoji", "images")
emoji_files = os.listdir(emoji_dir)
self.assert_length(emoji_files, 1)
finally:
shutil.rmtree(output_dir)
@responses.activate
def test_failed_to_guess_emoji_content_type(self) -> None:
realm_id = 0
output_dir = tempfile.mkdtemp()
emoji_name = "unknown_content_type_and_not_guessable"
emoji_url = "https://emoji.slack-edge.com/T06NRA6HM3P/bowtie/f3ec6f2bb0.unknown"
responses.add(
responses.GET,
emoji_url,
body=read_test_image_file("img.png"),
content_type=None,
status=200,
)
with self.assertLogs(level="WARN") as logs, self.assertRaises(BadImageError) as e:
get_emojis(
output_dir,
emoji_url,
emoji_name,
10,
realm_id,
)
self.assertIn(
f"WARNING:root:Emoji {emoji_name} has an unspecified content type. Guessing from the file extension of {emoji_url}",
logs.output,
)
self.assertIn(
f"Unknown content type for: {emoji_name}",
str(e.exception),
)
emoji_dir = os.path.join(output_dir, str(realm_id), "emoji", "images")
self.assertFalse(os.path.exists(emoji_dir))