diff --git a/server/Makefile b/server/Makefile index f1f226e0a88..f21099a6c7d 100644 --- a/server/Makefile +++ b/server/Makefile @@ -158,7 +158,7 @@ PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.1 PLUGIN_PACKAGES += mattermost-plugin-github-v2.6.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.0 PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.1 -PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.7.0 +PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.8.0 PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.12.0 PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2 @@ -174,7 +174,7 @@ PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0 # download the package from to work. This will no longer be needed when we unify # the way we pre-package FIPS and non-FIPS plugins. ifeq ($(FIPS_ENABLED),true) - PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.7.0%2B1031c5e-fips + PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.8.0%2Bc4449ac-fips PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2%2B866e2dd-fips PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.2%2B4282c63-fips endif diff --git a/server/channels/app/slack.go b/server/channels/app/slack.go index a5ded6b004c..392e43a539a 100644 --- a/server/channels/app/slack.go +++ b/server/channels/app/slack.go @@ -39,6 +39,14 @@ func (a *App) SlackImport(rctx request.CTX, fileData multipart.File, fileSize in GeneratePreviewImage: a.generatePreviewImage, InvalidateAllCaches: func() *model.AppError { return a.ch.srv.platform.InvalidateAllCaches() }, MaxPostSize: func() int { return a.ch.srv.platform.MaxPostSize() }, + SendPasswordReset: func(email string) (bool, *model.AppError) { + sent, err := a.SendPasswordReset(rctx, email, a.GetSiteURL()) + if err != nil { + return false, err + } + + return sent, nil + }, PrepareImage: func(fileData []byte) (image.Image, string, func(), error) { img, imgType, release, err := prepareImage(rctx, a.ch.imgDecoder, bytes.NewReader(fileData)) if err != nil { diff --git a/server/i18n/en.json b/server/i18n/en.json index 5c6f274f64f..467dfd71edb 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -3279,8 +3279,8 @@ "translation": "Could not uninvite remote to channel" }, { - "id": "api.slackimport.slack_add_bot_user.email_pwd", - "translation": "The Integration/Slack Bot user with email {{.Email}} and password {{.Password}} has been imported.\r\n" + "id": "api.slackimport.slack_add_bot_user.email", + "translation": "The Integration/Slack Bot user with email {{.Email}} has been imported.\r\n" }, { "id": "api.slackimport.slack_add_bot_user.unable_import", @@ -3307,8 +3307,8 @@ "translation": "\r\nUsers created:\r\n" }, { - "id": "api.slackimport.slack_add_users.email_pwd", - "translation": "Slack user with email {{.Email}} and password {{.Password}} has been imported.\r\n" + "id": "api.slackimport.slack_add_users.email", + "translation": "Slack user with email {{.Email}} has been imported.\r\n" }, { "id": "api.slackimport.slack_add_users.merge_existing", @@ -3322,6 +3322,10 @@ "id": "api.slackimport.slack_add_users.missing_email_address", "translation": "User {{.Username}} does not have an email address in the Slack export. Used {{.Email}} as a placeholder. The user should update their email address once logged in to the system.\r\n" }, + { + "id": "api.slackimport.slack_add_users.send_reset_email_failed", + "translation": "Unable to send password reset email to {{.Username}} at {{.Email}}.\r\n" + }, { "id": "api.slackimport.slack_add_users.unable_import", "translation": "Unable to import Slack user: {{.Username}}.\r\n" diff --git a/server/platform/services/slackimport/slackimport.go b/server/platform/services/slackimport/slackimport.go index 6e7a1ae12a6..92dc95da487 100644 --- a/server/platform/services/slackimport/slackimport.go +++ b/server/platform/services/slackimport/slackimport.go @@ -95,6 +95,7 @@ type Actions struct { GeneratePreviewImage func(request.CTX, image.Image, string, string) InvalidateAllCaches func() *model.AppError MaxPostSize func() int + SendPasswordReset func(string) (bool, *model.AppError) PrepareImage func(fileData []byte) (image.Image, string, func(), error) } @@ -268,8 +269,6 @@ func (si *SlackImporter) slackAddUsers(rctx request.CTX, teamId string, slackuse rctx.Logger().Warn("Slack Import: User does not have an email address in the Slack export. Used username as a placeholder. The user should update their email address once logged in to the system.", mlog.String("user_email", email), mlog.String("user_name", sUser.Username)) } - password := model.NewId() - // Check for email conflict and use existing user if found if existingUser, err := si.store.User().GetByEmail(email); err == nil { addedUsers[sUser.Id] = existingUser @@ -287,7 +286,7 @@ func (si *SlackImporter) slackAddUsers(rctx request.CTX, teamId string, slackuse FirstName: firstName, LastName: lastName, Email: email, - Password: password, + Password: "", } mUser := si.oldImportUser(rctx, team, &newUser) @@ -295,8 +294,18 @@ func (si *SlackImporter) slackAddUsers(rctx request.CTX, teamId string, slackuse importerLog.WriteString(i18n.T("api.slackimport.slack_add_users.unable_import", map[string]any{"Username": sUser.Username})) continue } + + sent, err := si.actions.SendPasswordReset(email) + if err != nil { + rctx.Logger().Warn("Slack Import: Cannot send password reset email to user. An admin should update their email address once logged in to the system.", mlog.String("user_email", email), mlog.String("user_name", sUser.Username)) + } + + if !sent { + importerLog.WriteString(i18n.T("api.slackimport.slack_add_users.send_reset_email_failed", map[string]any{"Username": sUser.Username, "Email": newUser.Email})) + } + addedUsers[sUser.Id] = mUser - importerLog.WriteString(i18n.T("api.slackimport.slack_add_users.email_pwd", map[string]any{"Email": newUser.Email, "Password": password})) + importerLog.WriteString(i18n.T("api.slackimport.slack_add_users.email", map[string]any{"Email": newUser.Email})) } return addedUsers @@ -327,7 +336,7 @@ func (si *SlackImporter) slackAddBotUser(rctx request.CTX, teamId string, log *b return nil } - log.WriteString(i18n.T("api.slackimport.slack_add_bot_user.email_pwd", map[string]any{"Email": botUser.Email, "Password": password})) + log.WriteString(i18n.T("api.slackimport.slack_add_bot_user.email", map[string]any{"Email": botUser.Email})) return mUser } diff --git a/server/platform/services/slackimport/slackimport_test.go b/server/platform/services/slackimport/slackimport_test.go index 30e82e56c3e..1872475730e 100644 --- a/server/platform/services/slackimport/slackimport_test.go +++ b/server/platform/services/slackimport/slackimport_test.go @@ -6,6 +6,7 @@ package slackimport import ( "archive/zip" "bytes" + "fmt" "os" "path/filepath" "strings" @@ -760,3 +761,114 @@ func TestSlackImportEnhancedSecurityBackwardsCompatibility(t *testing.T) { // Verify VerifyEmail was NOT called userStore.AssertNotCalled(t, "VerifyEmail") } + +type slackAddUsersTestSetup struct { + store *mocks.Store + teamStore *mocks.TeamStore + userStore *mocks.UserStore + team *model.Team + savedUser *model.User +} + +func newSlackAddUsersTestSetup(t *testing.T) *slackAddUsersTestSetup { + t.Helper() + + s := &slackAddUsersTestSetup{} + s.store = &mocks.Store{} + s.teamStore = &mocks.TeamStore{} + s.userStore = &mocks.UserStore{} + s.store.On("Team").Return(s.teamStore) + s.store.On("User").Return(s.userStore) + + s.team = &model.Team{Id: "test-team-id", Name: "test-team"} + s.teamStore.On("Get", "test-team-id").Return(s.team, nil) + s.userStore.On("GetByEmail", mock.AnythingOfType("string")).Return(nil, fmt.Errorf("not found")) + + s.savedUser = &model.User{Id: "test-user-id", Username: "testuser", Email: "testuser@example.com"} + s.userStore.On("Save", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.User")).Return(s.savedUser, nil) + + return s +} + +func (s *slackAddUsersTestSetup) newImporter(actions Actions) *SlackImporter { + config := &model.Config{} + config.SetDefaults() + return New(s.store, actions, config) +} + +func (s *slackAddUsersTestSetup) defaultActions() Actions { + return Actions{ + JoinUserToTeam: func(team *model.Team, user *model.User, userRequestorId string) (*model.TeamMember, *model.AppError) { + return &model.TeamMember{}, nil + }, + SendPasswordReset: func(email string) (bool, *model.AppError) { + return true, nil + }, + } +} + +func defaultSlackUsers() []slackUser { + return []slackUser{ + {Id: "U001", Username: "testuser", Profile: slackProfile{FirstName: "Test", LastName: "User", Email: "testuser@example.com"}}, + } +} + +func TestSlackAddUsersLogContainsProperUserCreationMessage(t *testing.T) { + rctx := request.TestContext(t) + s := newSlackAddUsersTestSetup(t) + + importer := s.newImporter(s.defaultActions()) + importerLog := new(bytes.Buffer) + importer.slackAddUsers(rctx, "test-team-id", defaultSlackUsers(), importerLog) + + logOutput := importerLog.String() + assert.Contains(t, logOutput, "api.slackimport.slack_add_users.email", "import log should contain the user creation message") + assert.NotContains(t, logOutput, "api.slackimport.slack_add_users.email_pwd", "import log must not use the old user creation message") +} + +func TestSlackAddUsersLogsSendResetEmailFailure(t *testing.T) { + rctx := request.TestContext(t) + s := newSlackAddUsersTestSetup(t) + + actions := s.defaultActions() + actions.SendPasswordReset = func(email string) (bool, *model.AppError) { + return false, nil + } + + importer := s.newImporter(actions) + importerLog := new(bytes.Buffer) + importer.slackAddUsers(rctx, "test-team-id", defaultSlackUsers(), importerLog) + + assert.Contains(t, importerLog.String(), "api.slackimport.slack_add_users.send_reset_email_failed") +} + +func TestSlackAddUsersGeneratesUserWithEmptyPassword(t *testing.T) { + rctx := request.TestContext(t) + s := newSlackAddUsersTestSetup(t) + + importer := s.newImporter(s.defaultActions()) + importerLog := new(bytes.Buffer) + importer.slackAddUsers(rctx, "test-team-id", defaultSlackUsers(), importerLog) + + s.userStore.AssertCalled(t, "Save", mock.AnythingOfType("*request.Context"), mock.MatchedBy(func(u *model.User) bool { + return u.Password == "" + })) +} + +func TestSlackAddUsersTriggersPasswordResetFlow(t *testing.T) { + rctx := request.TestContext(t) + s := newSlackAddUsersTestSetup(t) + + passwordResetCalled := false + actions := s.defaultActions() + actions.SendPasswordReset = func(email string) (bool, *model.AppError) { + passwordResetCalled = true + return true, nil + } + + importer := s.newImporter(actions) + importerLog := new(bytes.Buffer) + importer.slackAddUsers(rctx, "test-team-id", defaultSlackUsers(), importerLog) + + assert.True(t, passwordResetCalled, "SendPasswordReset should be called for each imported user") +}