// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package app import ( "bytes" _ "embed" "encoding/json" "errors" "fmt" "image" "image/color" "image/png" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/plugin/utils" "github.com/mattermost/mattermost/server/public/shared/i18n" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8" "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" ) func getDefaultPluginSettingsSchema() string { ret, _ := json.Marshal(model.PluginSettingsSchema{ Settings: []*model.PluginSetting{ {Key: "BasicChannelName", Type: "text"}, {Key: "BasicChannelId", Type: "text"}, {Key: "BasicTeamDisplayName", Type: "text"}, {Key: "BasicTeamName", Type: "text"}, {Key: "BasicTeamId", Type: "text"}, {Key: "BasicUserEmail", Type: "text"}, {Key: "BasicUserId", Type: "text"}, {Key: "BasicUser2Email", Type: "text"}, {Key: "BasicUser2Id", Type: "text"}, {Key: "BasicPostMessage", Type: "text"}, }, }) return string(ret) } func setDefaultPluginConfig(th *TestHelper, pluginID string) { th.App.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.Plugins[pluginID] = map[string]any{ "BasicChannelName": th.BasicChannel.Name, "BasicChannelId": th.BasicChannel.Id, "BasicTeamName": th.BasicTeam.Name, "BasicTeamId": th.BasicTeam.Id, "BasicTeamDisplayName": th.BasicTeam.DisplayName, "BasicUserEmail": th.BasicUser.Email, "BasicUserId": th.BasicUser.Id, "BasicUser2Email": th.BasicUser2.Email, "BasicUser2Id": th.BasicUser2.Id, "BasicPostMessage": th.BasicPost.Message, } }) } func setupMultiPluginAPITest(t *testing.T, pluginCodes []string, pluginManifests []string, pluginIDs []string, asMain bool, app *App, rctx request.CTX) string { pluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) t.Cleanup(func() { err = os.RemoveAll(pluginDir) if err != nil { t.Logf("Failed to cleanup pluginDir %s", err.Error()) } }) webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) t.Cleanup(func() { err = os.RemoveAll(webappPluginDir) if err != nil { t.Logf("Failed to cleanup webappPluginDir %s", err.Error()) } }) newPluginAPI := func(manifest *model.Manifest) plugin.API { return app.NewPluginAPI(rctx, manifest) } env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) require.NoError(t, err) require.Equal(t, len(pluginCodes), len(pluginIDs)) require.Equal(t, len(pluginManifests), len(pluginIDs)) for i, pluginID := range pluginIDs { backend := filepath.Join(pluginDir, pluginID, "backend.exe") if asMain { utils.CompileGo(t, pluginCodes[i], backend) } else { utils.CompileGoTest(t, pluginCodes[i], backend) } err := os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(pluginManifests[i]), 0600) require.NoError(t, err) manifest, activated, reterr := env.Activate(pluginID) require.NoError(t, reterr) require.NotNil(t, manifest) require.True(t, activated) app.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.PluginStates[pluginID] = &model.PluginState{ Enable: true, } }) } app.ch.SetPluginsEnvironment(env) return pluginDir } func setupPluginAPITest(t *testing.T, pluginCode string, pluginManifest string, pluginID string, app *App, rctx request.CTX) string { asMain := pluginID != "test_db_driver" return setupMultiPluginAPITest(t, []string{pluginCode}, []string{pluginManifest}, []string{pluginID}, asMain, app, rctx) } func TestPublicFilesPathConfiguration(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() pluginID := "com.mattermost.sample" pluginDir := setupPluginAPITest(t, ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" ) type MyPlugin struct { plugin.MattermostPlugin } func main() { plugin.ClientMain(&MyPlugin{}) } `, `{"id": "com.mattermost.sample", "server": {"executable": "backend.exe"}, "settings_schema": {"settings": []}}`, pluginID, th.App, th.Context) publicFilesFolderInTest := filepath.Join(pluginDir, pluginID, "public") publicFilesPath, err := th.App.GetPluginsEnvironment().PublicFilesPath(pluginID) assert.NoError(t, err) assert.Equal(t, publicFilesPath, publicFilesFolderInTest) } func TestPluginAPIGetUserPreference(t *testing.T) { mainHelper.Parallel(t) t.Run("should return preferences when called", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() err := api.UpdatePreferencesForUser(th.BasicUser.Id, []model.Preference{ { UserId: th.BasicUser.Id, Category: model.PreferenceCategoryDisplaySettings, Name: model.PreferenceNameUseMilitaryTime, Value: "true", }, { UserId: th.BasicUser.Id, Category: "test_category", Name: "test_key", Value: "test_value", }, }) require.Nil(t, err) preference, err := api.GetPreferenceForUser(th.BasicUser.Id, model.PreferenceCategoryDisplaySettings, model.PreferenceNameUseMilitaryTime) require.Nil(t, err) assert.Equal(t, model.PreferenceCategoryDisplaySettings, preference.Category) assert.Equal(t, model.PreferenceNameUseMilitaryTime, preference.Name) assert.Equal(t, "true", preference.Value) preference, err = api.GetPreferenceForUser(th.BasicUser.Id, "test_category", "test_key") require.Nil(t, err) assert.Equal(t, "test_category", preference.Category) assert.Equal(t, "test_key", preference.Name) assert.Equal(t, "test_value", preference.Value) }) t.Run("should return an error when a user doesn't have a preference set", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() _, err := api.GetPreferenceForUser(th.BasicUser.Id, "something", "that doesn't exist") assert.NotNil(t, err) }) } func TestPluginAPIGetUserPreferences(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() user1, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user1" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user1) require.Nil(t, appErr) }() preferences, err := api.GetPreferencesForUser(user1.Id) require.Nil(t, err) assert.Equal(t, 3, len(preferences)) assert.Equal(t, user1.Id, preferences[0].UserId) assert.Equal(t, model.PreferenceRecommendedNextSteps, preferences[0].Category) assert.Equal(t, "hide", preferences[0].Name) assert.Equal(t, "false", preferences[0].Value) assert.Equal(t, model.PreferenceCategorySystemNotice, preferences[1].Category) assert.Equal(t, user1.Id, preferences[2].UserId) assert.Equal(t, model.PreferenceCategoryTutorialSteps, preferences[2].Category) assert.Equal(t, user1.Id, preferences[2].Name) assert.Equal(t, "0", preferences[2].Value) } func TestPluginAPIDeleteUserPreferences(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() user1, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user1" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user1) require.Nil(t, appErr) }() preferences, err := api.GetPreferencesForUser(user1.Id) require.Nil(t, err) assert.Equal(t, 3, len(preferences)) err = api.DeletePreferencesForUser(user1.Id, preferences) require.Nil(t, err) preferences, err = api.GetPreferencesForUser(user1.Id) require.Nil(t, err) assert.Equal(t, 0, len(preferences)) user2, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user2" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user2) require.Nil(t, appErr) }() preference := model.Preference{ Name: user2.Id, UserId: user2.Id, Category: model.PreferenceCategoryTheme, Value: `{"color": "#ff0000", "color2": "#faf"}`, } err = api.UpdatePreferencesForUser(user2.Id, []model.Preference{preference}) require.Nil(t, err) preferences, err = api.GetPreferencesForUser(user2.Id) require.Nil(t, err) assert.Equal(t, 4, len(preferences)) err = api.DeletePreferencesForUser(user2.Id, []model.Preference{preference}) require.Nil(t, err) preferences, err = api.GetPreferencesForUser(user2.Id) require.Nil(t, err) assert.Equal(t, 3, len(preferences)) assert.ElementsMatch(t, []string{model.PreferenceRecommendedNextSteps, model.PreferenceCategoryTutorialSteps, model.PreferenceCategorySystemNotice}, []string{preferences[0].Category, preferences[1].Category, preferences[2].Category}, ) } func TestPluginAPIUpdateUserPreferences(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() user1, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user1" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user1) require.Nil(t, appErr) }() preferences, err := api.GetPreferencesForUser(user1.Id) require.Nil(t, err) assert.Equal(t, 3, len(preferences)) assert.Equal(t, user1.Id, preferences[0].UserId) assert.Equal(t, model.PreferenceRecommendedNextSteps, preferences[0].Category) assert.Equal(t, "hide", preferences[0].Name) assert.Equal(t, "false", preferences[0].Value) assert.Equal(t, model.PreferenceCategorySystemNotice, preferences[1].Category) assert.Equal(t, user1.Id, preferences[2].UserId) assert.Equal(t, model.PreferenceCategoryTutorialSteps, preferences[2].Category) assert.Equal(t, user1.Id, preferences[2].Name) assert.Equal(t, "0", preferences[2].Value) preference := model.Preference{ Name: user1.Id, UserId: user1.Id, Category: model.PreferenceCategoryTheme, Value: `{"color": "#ff0000", "color2": "#faf"}`, } err = api.UpdatePreferencesForUser(user1.Id, []model.Preference{preference}) require.Nil(t, err) preferences, err = api.GetPreferencesForUser(user1.Id) require.Nil(t, err) assert.Equal(t, 4, len(preferences)) expectedCategories := []string{model.PreferenceCategoryTutorialSteps, model.PreferenceCategoryTheme, model.PreferenceRecommendedNextSteps, model.PreferenceCategorySystemNotice} for _, pref := range preferences { assert.Contains(t, expectedCategories, pref.Category) assert.Equal(t, user1.Id, pref.UserId) } } func TestPluginAPIGetUsers(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).DeleteBots() defer th.TearDown() api := th.SetupPluginAPI() user1, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user1" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user1) require.Nil(t, appErr) }() user2, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user2" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user2) require.Nil(t, appErr) }() user3, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user3" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user3) require.Nil(t, appErr) }() user4, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user4" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user4) require.Nil(t, appErr) }() testCases := []struct { Description string Page int PerPage int ExpectedUsers []*model.User }{ { "page 0, perPage 0", 0, 0, []*model.User{}, }, { "page 0, perPage 10", 0, 10, []*model.User{user1, user2, user3, user4}, }, { "page 0, perPage 2", 0, 2, []*model.User{user1, user2}, }, { "page 1, perPage 2", 1, 2, []*model.User{user3, user4}, }, { "page 10, perPage 10", 10, 10, []*model.User{}, }, } for _, testCase := range testCases { t.Run(testCase.Description, func(t *testing.T) { users, err := api.GetUsers(&model.UserGetOptions{ Page: testCase.Page, PerPage: testCase.PerPage, }) assert.Nil(t, err) assert.Equal(t, testCase.ExpectedUsers, users) }) } } func TestPluginAPIGetUsersByIds(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).DeleteBots() defer th.TearDown() api := th.SetupPluginAPI() user1, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user1" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user1) require.Nil(t, appErr) }() user2, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user2" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user2) require.Nil(t, appErr) }() user3, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user3" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user3) require.Nil(t, appErr) }() testCases := []struct { Description string requestedIDs []string }{ { "no users", []string{}, }, { "getting 1 and 3", []string{user1.Id, user3.Id}, }, } for _, testCase := range testCases { t.Run(testCase.Description, func(t *testing.T) { users, err := api.GetUsersByIds(testCase.requestedIDs) assert.Nil(t, err) assert.Equal(t, len(testCase.requestedIDs), len(users)) for _, user := range users { assert.Contains(t, testCase.requestedIDs, user.Id) } }) } } func TestPluginAPIGetUsersInTeam(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() team1 := th.CreateTeam() team2 := th.CreateTeam() user1, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user1" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user1) require.Nil(t, appErr) }() user2, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user2" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user2) require.Nil(t, appErr) }() user3, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user3" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user3) require.Nil(t, appErr) }() user4, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Password: "password", Username: "user4" + model.NewId(), }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user4) require.Nil(t, appErr) }() // Add all users to team 1 _, appErr := th.App.JoinUserToTeam(th.Context, team1, user1, "") require.Nil(t, appErr) _, appErr = th.App.JoinUserToTeam(th.Context, team1, user2, "") require.Nil(t, appErr) _, appErr = th.App.JoinUserToTeam(th.Context, team1, user3, "") require.Nil(t, appErr) _, appErr = th.App.JoinUserToTeam(th.Context, team1, user4, "") require.Nil(t, appErr) // Add only user3 and user4 to team 2 _, appErr = th.App.JoinUserToTeam(th.Context, team2, user3, "") require.Nil(t, appErr) _, appErr = th.App.JoinUserToTeam(th.Context, team2, user4, "") require.Nil(t, appErr) testCases := []struct { Description string TeamId string Page int PerPage int ExpectedUsers []*model.User }{ { "unknown team", model.NewId(), 0, 0, []*model.User{}, }, { "team 1, page 0, perPage 10", team1.Id, 0, 10, []*model.User{user1, user2, user3, user4}, }, { "team 1, page 0, perPage 2", team1.Id, 0, 2, []*model.User{user1, user2}, }, { "team 1, page 1, perPage 2", team1.Id, 1, 2, []*model.User{user3, user4}, }, { "team 2, page 0, perPage 10", team2.Id, 0, 10, []*model.User{user3, user4}, }, } for _, testCase := range testCases { t.Run(testCase.Description, func(t *testing.T) { users, err := api.GetUsersInTeam(testCase.TeamId, testCase.Page, testCase.PerPage) assert.Nil(t, err) usersMap := make(map[string]bool) for _, user := range testCase.ExpectedUsers { usersMap[user.Id] = true } for _, user := range users { delete(usersMap, user.Id) } assert.Empty(t, usersMap) }) } } func TestPluginAPIUserCustomStatus(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() user1, err := th.App.CreateUser(th.Context, &model.User{ Email: strings.ToLower(model.NewId()) + "success+test@example.com", Username: "user_" + model.NewId(), Password: "password", }) require.Nil(t, err) defer func() { appErr := th.App.PermanentDeleteUser(th.Context, user1) require.Nil(t, appErr) }() custom := &model.CustomStatus{ Emoji: "tada", Text: "honk", } err = api.UpdateUserCustomStatus(user1.Id, custom) assert.Nil(t, err) userCs, err := th.App.GetCustomStatus(user1.Id) assert.Nil(t, err) assert.Equal(t, custom, userCs) custom.Text = "" err = api.UpdateUserCustomStatus(user1.Id, custom) assert.Nil(t, err) userCs, err = th.App.GetCustomStatus(user1.Id) assert.Nil(t, err) assert.Equal(t, custom, userCs) custom.Text = "honk" custom.Emoji = "" err = api.UpdateUserCustomStatus(user1.Id, custom) assert.Nil(t, err) userCs, err = th.App.GetCustomStatus(user1.Id) assert.Nil(t, err) assert.Equal(t, custom, userCs) custom.Text = "" err = api.UpdateUserCustomStatus(user1.Id, custom) assert.NotNil(t, err) assert.Equal(t, err.Error(), "SetCustomStatus: Failed to update the custom status. Please add either emoji or custom text status or both.") // Remove custom status err = api.RemoveUserCustomStatus(user1.Id) assert.Nil(t, err) var csClear *model.CustomStatus userCs, err = th.App.GetCustomStatus(user1.Id) assert.Nil(t, err) assert.Equal(t, csClear, userCs) } func TestPluginAPIGetFile(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() // check a valid file first uploadTime := time.Date(2007, 2, 4, 1, 2, 3, 4, time.Local) filename := "testGetFile" fileData := []byte("Hello World") info, err := th.App.DoUploadFile(th.Context, uploadTime, th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser.Id, filename, fileData, true) require.Nil(t, err) defer func() { err := th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, info.Id) require.NoError(t, err) appErr := th.App.RemoveFile(info.Path) require.Nil(t, appErr) }() data, err1 := api.GetFile(info.Id) require.Nil(t, err1) assert.Equal(t, data, fileData) // then checking invalid file data, err = api.GetFile("../fake/testingApi") require.NotNil(t, err) require.Nil(t, data) } func TestPluginAPIGetFileInfos(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() fileInfo1, err := th.App.DoUploadFile(th.Context, time.Date(2020, 1, 1, 1, 1, 1, 1, time.UTC), th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser.Id, "testFile1", []byte("testfile1 Content"), true, ) require.Nil(t, err) defer func() { err := th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, fileInfo1.Id) require.NoError(t, err) appErr := th.App.RemoveFile(fileInfo1.Path) require.Nil(t, appErr) }() fileInfo2, err := th.App.DoUploadFile(th.Context, time.Date(2020, 1, 2, 1, 1, 1, 1, time.UTC), th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser2.Id, "testFile2", []byte("testfile2 Content"), true, ) require.Nil(t, err) defer func() { err := th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, fileInfo2.Id) require.NoError(t, err) appErr := th.App.RemoveFile(fileInfo2.Path) require.Nil(t, appErr) }() fileInfo3, err := th.App.DoUploadFile(th.Context, time.Date(2020, 1, 3, 1, 1, 1, 1, time.UTC), th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser.Id, "testFile3", []byte("testfile3 Content"), true, ) require.Nil(t, err) defer func() { err := th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, fileInfo3.Id) require.NoError(t, err) appErr := th.App.RemoveFile(fileInfo3.Path) require.Nil(t, appErr) }() _, err = api.CreatePost(&model.Post{ Message: "testFile1", UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, FileIds: model.StringArray{fileInfo1.Id}, }) require.Nil(t, err) _, err = api.CreatePost(&model.Post{ Message: "testFile2", UserId: th.BasicUser2.Id, ChannelId: th.BasicChannel.Id, FileIds: model.StringArray{fileInfo2.Id}, }) require.Nil(t, err) t.Run("get file infos with no options 2nd page of 1 per page", func(t *testing.T) { fileInfos, err := api.GetFileInfos(1, 1, nil) require.Nil(t, err) require.Len(t, fileInfos, 1) }) t.Run("get file infos filtered by user", func(t *testing.T) { fileInfos, err := api.GetFileInfos(0, 5, &model.GetFileInfosOptions{ UserIds: []string{th.BasicUser.Id}, }) require.Nil(t, err) require.Len(t, fileInfos, 2) }) t.Run("get file infos filtered by channel ordered by created at descending", func(t *testing.T) { fileInfos, err := api.GetFileInfos(0, 5, &model.GetFileInfosOptions{ ChannelIds: []string{th.BasicChannel.Id}, SortBy: model.FileinfoSortByCreated, SortDescending: true, }) require.Nil(t, err) require.Len(t, fileInfos, 2) require.Equal(t, fileInfos[0].Id, fileInfo2.Id) require.Equal(t, fileInfos[1].Id, fileInfo1.Id) }) } func TestPluginAPISavePluginConfig(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() manifest := &model.Manifest{ Id: "pluginid", SettingsSchema: &model.PluginSettingsSchema{ Settings: []*model.PluginSetting{ {Key: "MyStringSetting", Type: "text"}, {Key: "MyIntSetting", Type: "text"}, {Key: "MyBoolSetting", Type: "bool"}, }, }, } api := NewPluginAPI(th.App, th.Context, manifest) pluginConfigJsonString := `{"mystringsetting": "str", "MyIntSetting": 32, "myboolsetting": true}` var pluginConfig map[string]any err := json.Unmarshal([]byte(pluginConfigJsonString), &pluginConfig) require.NoError(t, err) appErr := api.SavePluginConfig(pluginConfig) require.Nil(t, appErr) type Configuration struct { MyStringSetting string MyIntSetting int MyBoolSetting bool } savedConfiguration := new(Configuration) err = api.LoadPluginConfiguration(savedConfiguration) require.NoError(t, err) expectedConfiguration := new(Configuration) err = json.Unmarshal([]byte(pluginConfigJsonString), &expectedConfiguration) require.NoError(t, err) assert.Equal(t, expectedConfiguration, savedConfiguration) } func TestPluginAPISavePluginConfigPreservesOtherPlugins(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) otherPluginConfig := map[string]any{ "setting1": "value1", "setting2": "value2", } th.App.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.Plugins["otherplugin"] = otherPluginConfig }) manifest := &model.Manifest{ Id: "pluginid", SettingsSchema: &model.PluginSettingsSchema{ Settings: []*model.PluginSetting{ {Key: "MyStringSetting", Type: "text"}, }, }, } api := NewPluginAPI(th.App, th.Context, manifest) pluginConfig := map[string]any{"mystringsetting": "str"} appErr := api.SavePluginConfig(pluginConfig) require.Nil(t, appErr) cfg := th.App.Config() assert.Contains(t, cfg.PluginSettings.Plugins, "otherplugin") assert.Equal(t, otherPluginConfig, cfg.PluginSettings.Plugins["otherplugin"]) } func TestPluginAPILoadPluginConfiguration(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() var pluginJson map[string]any err := json.Unmarshal([]byte(`{"mystringsetting": "str", "MyIntSetting": 32, "myBoolsetting": true}`), &pluginJson) require.NoError(t, err) th.App.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.Plugins["testloadpluginconfig"] = pluginJson }) fullPath := filepath.Join(server.GetPackagePath(), "channels", "app", "plugin_api_tests", "manual.test_load_configuration_plugin", "main.go") err = pluginAPIHookTest(t, th, fullPath, "testloadpluginconfig", `{ "settings": [ { "key": "MyStringSetting", "type": "text" }, { "key": "MyIntSetting", "type": "text" }, { "key": "MyBoolSetting", "type": "bool" } ] }`) require.NoError(t, err) } func TestPluginAPILoadPluginConfigurationDefaults(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() var pluginJson map[string]any err := json.Unmarshal([]byte(`{"mystringsetting": "override"}`), &pluginJson) require.NoError(t, err) th.App.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.Plugins["testloadpluginconfig"] = pluginJson }) fullPath := filepath.Join(server.GetPackagePath(), "channels", "app", "plugin_api_tests", "manual.test_load_configuration_defaults_plugin", "main.go") err = pluginAPIHookTest(t, th, fullPath, "testloadpluginconfig", `{ "settings": [ { "key": "MyStringSetting", "type": "text", "default": "notthis" }, { "key": "MyIntSetting", "type": "text", "default": 35 }, { "key": "MyBoolSetting", "type": "bool", "default": true } ] }`) require.NoError(t, err) } func TestPluginAPIGetPlugins(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() pluginCode := ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" ) type MyPlugin struct { plugin.MattermostPlugin } func main() { plugin.ClientMain(&MyPlugin{}) } ` pluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) pluginIDs := []string{"pluginid1", "pluginid2", "pluginid3"} var pluginManifests []*model.Manifest for _, pluginID := range pluginIDs { backend := filepath.Join(pluginDir, pluginID, "backend.exe") utils.CompileGo(t, pluginCode, backend) err := os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), fmt.Appendf(nil, `{"id": "%s", "server": {"executable": "backend.exe"}}`, pluginID), 0600) require.NoError(t, err) manifest, activated, reterr := env.Activate(pluginID) require.NoError(t, reterr) require.NotNil(t, manifest) require.True(t, activated) pluginManifests = append(pluginManifests, manifest) } th.App.ch.SetPluginsEnvironment(env) // Deactivate the last one for testing success := env.Deactivate(pluginIDs[len(pluginIDs)-1]) require.True(t, success) // check existing user first plugins, appErr := api.GetPlugins() assert.Nil(t, appErr) assert.NotEmpty(t, plugins) assert.Equal(t, pluginManifests, plugins) } func TestPluginAPIInstallPlugin(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() tarData, err := os.ReadFile(filepath.Join(server.GetPackagePath(), "tests", "testplugin.tar.gz")) require.NoError(t, err) _, appErr := api.InstallPlugin(bytes.NewReader(tarData), true) assert.NotNil(t, appErr, "should not allow upload if upload disabled") assert.Equal(t, appErr.Error(), "installPlugin: Plugins and/or plugin uploads have been disabled.") th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true *cfg.PluginSettings.EnableUploads = true }) manifest, appErr := api.InstallPlugin(bytes.NewReader(tarData), true) defer os.RemoveAll("plugins/testplugin") require.Nil(t, appErr) assert.Equal(t, "testplugin", manifest.Id) // Successfully installed pluginsResp, appErr := api.GetPlugins() require.Nil(t, appErr) found := false for _, m := range pluginsResp { if m.Id == manifest.Id { found = true } } assert.True(t, found) } func TestInstallPlugin(t *testing.T) { mainHelper.Parallel(t) // TODO(ilgooz): remove this setup func to use existent setupPluginAPITest(). // following setupTest() func is a modified version of setupPluginAPITest(). // we need a modified version of setupPluginAPITest() because it wasn't possible to use it directly here // since it removes plugin dirs right after it returns, does not update App configs with the plugin // dirs and this behavior tends to break this test as a result. setupTest := func(t *testing.T, pluginCode string, pluginManifest string, pluginID string, app *App, rctx request.CTX) (func(), string) { pluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) app.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Directory = pluginDir *cfg.PluginSettings.ClientDirectory = webappPluginDir }) newPluginAPI := func(manifest *model.Manifest) plugin.API { return app.NewPluginAPI(rctx, manifest) } env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil) require.NoError(t, err) app.ch.SetPluginsEnvironment(env) backend := filepath.Join(pluginDir, pluginID, "backend.exe") utils.CompileGo(t, pluginCode, backend) err = os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(pluginManifest), 0600) require.NoError(t, err) manifest, activated, reterr := env.Activate(pluginID) require.NoError(t, reterr) require.NotNil(t, manifest) require.True(t, activated) return func() { os.RemoveAll(pluginDir) os.RemoveAll(webappPluginDir) }, pluginDir } th := Setup(t) defer th.TearDown() // start an http server to serve plugin's tarball to the test. ts := httptest.NewServer(http.FileServer(http.Dir(filepath.Join(server.GetPackagePath(), "tests")))) defer ts.Close() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true *cfg.PluginSettings.EnableUploads = true cfg.PluginSettings.Plugins["testinstallplugin"] = map[string]any{ "DownloadURL": ts.URL + "/testplugin.tar.gz", } }) tearDown, _ := setupTest(t, ` package main import ( "net/http" "github.com/pkg/errors" "github.com/mattermost/mattermost/server/public/plugin" ) type configuration struct { DownloadURL string } type Plugin struct { plugin.MattermostPlugin configuration configuration } func (p *Plugin) OnConfigurationChange() error { if err := p.API.LoadPluginConfiguration(&p.configuration); err != nil { return err } return nil } func (p *Plugin) OnActivate() error { resp, err := http.Get(p.configuration.DownloadURL) if err != nil { return err } defer resp.Body.Close() _, aerr := p.API.InstallPlugin(resp.Body, true) if aerr != nil { return errors.Wrap(aerr, "cannot install plugin") } return nil } func main() { plugin.ClientMain(&Plugin{}) } `, `{"id": "testinstallplugin", "server": {"executable": "backend.exe"}, "settings_schema": { "settings": [ { "key": "DownloadURL", "type": "text" } ] }}`, "testinstallplugin", th.App, th.Context) defer tearDown() hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin("testinstallplugin") require.NoError(t, err) err = hooks.OnActivate() require.NoError(t, err) plugins, aerr := th.App.GetPlugins() require.Nil(t, aerr) require.Len(t, plugins.Inactive, 1) require.Equal(t, "testplugin", plugins.Inactive[0].Id) } func TestPluginAPIGetTeamIcon(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() // Create an 128 x 128 image img := image.NewRGBA(image.Rect(0, 0, 128, 128)) // Draw a red dot at (2, 3) img.Set(2, 3, color.RGBA{255, 0, 0, 255}) buf := new(bytes.Buffer) err := png.Encode(buf, img) require.NoError(t, err) dataBytes := buf.Bytes() fileReader := bytes.NewReader(dataBytes) // Set the Team Icon appErr := th.App.SetTeamIconFromFile(th.Context, th.BasicTeam, fileReader) require.Nil(t, appErr) // Get the team icon to check teamIcon, appErr := api.GetTeamIcon(th.BasicTeam.Id) require.Nil(t, appErr) require.NotEmpty(t, teamIcon) colorful := color.NRGBA{255, 0, 0, 255} byteReader := bytes.NewReader(teamIcon) img2, _, err2 := image.Decode(byteReader) require.NoError(t, err2) require.Equal(t, img2.At(2, 3), colorful) } func TestPluginAPISetTeamIcon(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() // Create an 128 x 128 image img := image.NewRGBA(image.Rect(0, 0, 128, 128)) // Draw a red dot at (2, 3) img.Set(2, 3, color.RGBA{255, 0, 0, 255}) buf := new(bytes.Buffer) err := png.Encode(buf, img) require.NoError(t, err) dataBytes := buf.Bytes() // Set the user profile image appErr := api.SetTeamIcon(th.BasicTeam.Id, dataBytes) require.Nil(t, appErr) // Get the user profile image to check teamIcon, appErr := api.GetTeamIcon(th.BasicTeam.Id) require.Nil(t, appErr) require.NotEmpty(t, teamIcon) colorful := color.NRGBA{255, 0, 0, 255} byteReader := bytes.NewReader(teamIcon) img2, _, err2 := image.Decode(byteReader) require.NoError(t, err2) require.Equal(t, img2.At(2, 3), colorful) } func TestPluginAPIRemoveTeamIcon(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() // Create an 128 x 128 image img := image.NewRGBA(image.Rect(0, 0, 128, 128)) // Draw a red dot at (2, 3) img.Set(2, 3, color.RGBA{255, 0, 0, 255}) buf := new(bytes.Buffer) err1 := png.Encode(buf, img) require.NoError(t, err1) dataBytes := buf.Bytes() fileReader := bytes.NewReader(dataBytes) // Set the Team Icon err := th.App.SetTeamIconFromFile(th.Context, th.BasicTeam, fileReader) require.Nil(t, err) err = api.RemoveTeamIcon(th.BasicTeam.Id) require.Nil(t, err) } func pluginAPIHookTest(t *testing.T, th *TestHelper, fileName string, id string, settingsSchema string) error { data, err := os.ReadFile(fileName) if err != nil { return err } code := string(data) schema := `{"settings": [ ] }` if settingsSchema != "" { schema = settingsSchema } th.App.ch.srv.platform.SetSqlStore(th.GetSqlStore()) // TODO: platform: check if necessary setupPluginAPITest(t, code, fmt.Sprintf(`{"id": "%v", "server": {"executable": "backend.exe"}, "settings_schema": %v}`, id, schema), id, th.App, th.Context) hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin(id) require.NoError(t, err) require.NotNil(t, hooks) _, ret := hooks.MessageWillBePosted(nil, nil) if ret != "OK" { return errors.New(ret) } return nil } // This is a meta-test function. It does the following: // 1. Scans "tests/plugin_tests" folder // 2. For each folder - compiles the main.go inside and executes it, validating it's result // 3. If folder starts with "manual." it is skipped ("manual." tests executed in other part of this file) // 4. Before compiling the main.go file is passed through templating and the following values are available in the template: BasicUser, BasicUser2, BasicChannel, BasicTeam, BasicPost // 5. Successfully running test should return nil, "OK". Any other returned string is considered and error func TestBasicAPIPlugins(t *testing.T) { mainHelper.Parallel(t) defaultSchema := getDefaultPluginSettingsSchema() testFolder := filepath.Join(server.GetPackagePath(), "channels", "app", "plugin_api_tests") dirs, err := os.ReadDir(testFolder) require.NoError(t, err, "Cannot read test folder %v", testFolder) for _, dir := range dirs { d := dir.Name() if dir.IsDir() && !strings.HasPrefix(d, "manual.") { t.Run(d, func(t *testing.T) { mainHelper.Parallel(t) mainPath := filepath.Join(testFolder, d, "main.go") _, err := os.Stat(mainPath) require.NoError(t, err, "Cannot find plugin main file at %v", mainPath) th := Setup(t).InitBasic().DeleteBots() defer th.TearDown() setDefaultPluginConfig(th, dir.Name()) err = pluginAPIHookTest(t, th, mainPath, dir.Name(), defaultSchema) require.NoError(t, err) }) } } } func TestPluginAPIKVCompareAndSet(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() testCases := []struct { Description string ExpectedValue []byte }{ { Description: "Testing non-nil, non-empty value", ExpectedValue: []byte("value1"), }, { Description: "Testing empty value", ExpectedValue: []byte(""), }, } for i, testCase := range testCases { t.Run(testCase.Description, func(t *testing.T) { expectedKey := fmt.Sprintf("Key%d", i) expectedValueEmpty := []byte("") expectedValue1 := testCase.ExpectedValue expectedValue2 := []byte("value2") expectedValue3 := []byte("value3") // Attempt update using an incorrect old value updated, err := api.KVCompareAndSet(expectedKey, expectedValue2, expectedValue1) require.Nil(t, err) require.False(t, updated) // Make sure no key is already created value, err := api.KVGet(expectedKey) require.Nil(t, err) require.Nil(t, value) // Insert using nil old value updated, err = api.KVCompareAndSet(expectedKey, nil, expectedValue1) require.Nil(t, err) require.True(t, updated) // Get inserted value value, err = api.KVGet(expectedKey) require.Nil(t, err) require.Equal(t, expectedValue1, value) // Attempt to insert again using nil old value updated, err = api.KVCompareAndSet(expectedKey, nil, expectedValue2) require.Nil(t, err) require.False(t, updated) // Get old value to assert nothing has changed value, err = api.KVGet(expectedKey) require.Nil(t, err) require.Equal(t, expectedValue1, value) // Update using correct old value updated, err = api.KVCompareAndSet(expectedKey, expectedValue1, expectedValue2) require.Nil(t, err) require.True(t, updated) value, err = api.KVGet(expectedKey) require.Nil(t, err) require.Equal(t, expectedValue2, value) // Update using incorrect old value updated, err = api.KVCompareAndSet(expectedKey, []byte("incorrect"), expectedValue3) require.Nil(t, err) require.False(t, updated) value, err = api.KVGet(expectedKey) require.Nil(t, err) require.Equal(t, expectedValue2, value) // Update using nil old value updated, err = api.KVCompareAndSet(expectedKey, nil, expectedValue3) require.Nil(t, err) require.False(t, updated) value, err = api.KVGet(expectedKey) require.Nil(t, err) require.Equal(t, expectedValue2, value) // Update using empty old value updated, err = api.KVCompareAndSet(expectedKey, expectedValueEmpty, expectedValue3) require.Nil(t, err) require.False(t, updated) value, err = api.KVGet(expectedKey) require.Nil(t, err) require.Equal(t, expectedValue2, value) }) } } func TestPluginAPIKVCompareAndDelete(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() testCases := []struct { Description string ExpectedValue []byte }{ { Description: "Testing non-nil, non-empty value", ExpectedValue: []byte("value1"), }, { Description: "Testing empty value", ExpectedValue: []byte(""), }, } for i, testCase := range testCases { t.Run(testCase.Description, func(t *testing.T) { expectedKey := fmt.Sprintf("Key%d", i) expectedValue1 := testCase.ExpectedValue expectedValue2 := []byte("value2") // Set the value err := api.KVSet(expectedKey, expectedValue1) require.Nil(t, err) // Attempt delete using an incorrect old value deleted, err := api.KVCompareAndDelete(expectedKey, expectedValue2) require.Nil(t, err) require.False(t, deleted) // Make sure the value is still there value, err := api.KVGet(expectedKey) require.Nil(t, err) require.Equal(t, expectedValue1, value) // Attempt delete using the proper value deleted, err = api.KVCompareAndDelete(expectedKey, expectedValue1) require.Nil(t, err) require.True(t, deleted) // Verify it's deleted value, err = api.KVGet(expectedKey) require.Nil(t, err) require.Nil(t, value) }) } } func TestPluginCreateBot(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() bot, err := api.CreateBot(&model.Bot{ Username: "a" + model.NewRandomString(10), DisplayName: "bot", Description: "bot", }) require.Nil(t, err) _, err = api.CreateBot(&model.Bot{ Username: "a" + model.NewRandomString(10), OwnerId: bot.UserId, DisplayName: "bot2", Description: "bot2", }) require.NotNil(t, err) } func TestPluginCreatePostWithUploadedFile(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() data := []byte("Hello World") channelID := th.BasicChannel.Id filename := "testGetFile" fileInfo, err := api.UploadFile(data, channelID, filename) require.Nil(t, err) defer func() { err := th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, fileInfo.Id) require.NoError(t, err) appErr := th.App.RemoveFile(fileInfo.Path) require.Nil(t, appErr) }() actualData, err := api.GetFile(fileInfo.Id) require.Nil(t, err) assert.Equal(t, data, actualData) userID := th.BasicUser.Id post, err := api.CreatePost(&model.Post{ Message: "test", UserId: userID, ChannelId: channelID, FileIds: model.StringArray{fileInfo.Id}, }) require.Nil(t, err) assert.Equal(t, model.StringArray{fileInfo.Id}, post.FileIds) actualPost, err := api.GetPost(post.Id) require.Nil(t, err) assert.Equal(t, model.StringArray{fileInfo.Id}, actualPost.FileIds) } func TestPluginCreatePostAddsFromPluginProp(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() channelID := th.BasicChannel.Id userID := th.BasicUser.Id post, err := api.CreatePost(&model.Post{ Message: "test", ChannelId: channelID, UserId: userID, }) require.Nil(t, err) actualPost, err := api.GetPost(post.Id) require.Nil(t, err) assert.Equal(t, "true", actualPost.GetProp(model.PostPropsFromPlugin)) } func TestPluginAPIGetConfig(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() config := api.GetConfig() if config.LdapSettings.BindPassword != nil && *config.LdapSettings.BindPassword != "" { assert.Equal(t, *config.LdapSettings.BindPassword, model.FakeSetting) } assert.Equal(t, *config.FileSettings.PublicLinkSalt, model.FakeSetting) if *config.FileSettings.AmazonS3SecretAccessKey != "" { assert.Equal(t, *config.FileSettings.AmazonS3SecretAccessKey, model.FakeSetting) } if config.EmailSettings.SMTPPassword != nil && *config.EmailSettings.SMTPPassword != "" { assert.Equal(t, *config.EmailSettings.SMTPPassword, model.FakeSetting) } if *config.GitLabSettings.Secret != "" { assert.Equal(t, *config.GitLabSettings.Secret, model.FakeSetting) } assert.Equal(t, *config.SqlSettings.DataSource, model.FakeSetting) assert.Equal(t, *config.SqlSettings.AtRestEncryptKey, model.FakeSetting) assert.Equal(t, *config.ElasticsearchSettings.Password, model.FakeSetting) for i := range config.SqlSettings.DataSourceReplicas { assert.Equal(t, config.SqlSettings.DataSourceReplicas[i], model.FakeSetting) } for i := range config.SqlSettings.DataSourceSearchReplicas { assert.Equal(t, config.SqlSettings.DataSourceSearchReplicas[i], model.FakeSetting) } } func TestPluginAPIGetUnsanitizedConfig(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() config := api.GetUnsanitizedConfig() if config.LdapSettings.BindPassword != nil && *config.LdapSettings.BindPassword != "" { assert.NotEqual(t, *config.LdapSettings.BindPassword, model.FakeSetting) } assert.NotEqual(t, *config.FileSettings.PublicLinkSalt, model.FakeSetting) if *config.FileSettings.AmazonS3SecretAccessKey != "" { assert.NotEqual(t, *config.FileSettings.AmazonS3SecretAccessKey, model.FakeSetting) } if config.EmailSettings.SMTPPassword != nil && *config.EmailSettings.SMTPPassword != "" { assert.NotEqual(t, *config.EmailSettings.SMTPPassword, model.FakeSetting) } if *config.GitLabSettings.Secret != "" { assert.NotEqual(t, *config.GitLabSettings.Secret, model.FakeSetting) } assert.NotEqual(t, *config.SqlSettings.DataSource, model.FakeSetting) assert.NotEqual(t, *config.SqlSettings.AtRestEncryptKey, model.FakeSetting) assert.NotEqual(t, *config.ElasticsearchSettings.Password, model.FakeSetting) for i := range config.SqlSettings.DataSourceReplicas { assert.NotEqual(t, config.SqlSettings.DataSourceReplicas[i], model.FakeSetting) } for i := range config.SqlSettings.DataSourceSearchReplicas { assert.NotEqual(t, config.SqlSettings.DataSourceSearchReplicas[i], model.FakeSetting) } } func TestPluginAddUserToChannel(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() member, err := api.AddUserToChannel(th.BasicChannel.Id, th.BasicUser.Id, th.BasicUser2.Id) require.Nil(t, err) require.NotNil(t, member) require.Equal(t, th.BasicChannel.Id, member.ChannelId) require.Equal(t, th.BasicUser.Id, member.UserId) } func TestInterpluginPluginHTTP(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() setupMultiPluginAPITest(t, []string{ ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "bytes" "net/http" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/test": if r.URL.Query().Get("abc") != "xyz" { return } if r.Header.Get("Mattermost-Plugin-ID") != "testplugininterclient" { return } buf := bytes.Buffer{} buf.ReadFrom(r.Body) resp := "we got:" + buf.String() w.WriteHeader(598) w.Write([]byte(resp)) if r.URL.Path != "/api/v2/test" { return } case "/nobody": w.WriteHeader(599) } } func main() { plugin.ClientMain(&MyPlugin{}) } `, ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/model" "bytes" "net/http" "io" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { buf := bytes.Buffer{} buf.WriteString("This is the request") req, err := http.NewRequest("GET", "/testplugininterserver/api/v2/test?abc=xyz", &buf) if err != nil { return nil, err.Error() } req.Header.Add("Mattermost-User-Id", "userid") resp := p.API.PluginHTTP(req) if resp == nil { return nil, "Nil resp" } if resp.Body == nil { return nil, "Nil body" } respbody, err := io.ReadAll(resp.Body) if err != nil { return nil, err.Error() } if resp.StatusCode != 598 { return nil, "wrong status " + string(respbody) } if string(respbody) != "we got:This is the request" { return nil, "wrong response " + string(respbody) } req, err = http.NewRequest("GET", "/testplugininterserver/nobody", nil) if err != nil { return nil, err.Error() } resp = p.API.PluginHTTP(req) if resp == nil { return nil, "Nil resp" } if resp.StatusCode != 599 { return nil, "wrong status " + string(respbody) } return nil, "ok" } func main() { plugin.ClientMain(&MyPlugin{}) } `, }, []string{ `{"id": "testplugininterserver", "server": {"executable": "backend.exe"}}`, `{"id": "testplugininterclient", "server": {"executable": "backend.exe"}}`, }, []string{ "testplugininterserver", "testplugininterclient", }, true, th.App, th.Context, ) hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin("testplugininterclient") require.NoError(t, err) _, ret := hooks.MessageWillBePosted(nil, nil) assert.Equal(t, "ok", ret) } func TestInterpluginPluginHTTPWithBodyAfterWriteHeader(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) setupMultiPluginAPITest(t, []string{ ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "bytes" "net/http" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v2/test" { if r.URL.Query().Get("abc") != "xyz" { return } if r.Header.Get("Mattermost-Plugin-ID") != "testpluginbodyafter" { return } w.WriteHeader(598) buf := bytes.Buffer{} buf.ReadFrom(r.Body) resp := "we got:" + buf.String() w.Write([]byte(resp)) } } func main() { plugin.ClientMain(&MyPlugin{}) } `, ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/model" "bytes" "net/http" "io" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { buf := bytes.Buffer{} buf.WriteString("This is the request body") req, err := http.NewRequest("POST", "/testpluginbodyafterserver/api/v2/test?abc=xyz", &buf) if err != nil { return nil, err.Error() } req.Header.Add("Mattermost-User-Id", "userid") resp := p.API.PluginHTTP(req) if resp == nil { return nil, "Nil resp" } if resp.Body == nil { return nil, "Nil body" } respbody, err := io.ReadAll(resp.Body) if err != nil { return nil, err.Error() } if resp.StatusCode != 598 { return nil, "wrong status " + string(respbody) } if string(respbody) != "we got:This is the request body" { return nil, "wrong response: " + string(respbody) } return nil, "ok" } func main() { plugin.ClientMain(&MyPlugin{}) } `, }, []string{ `{"id": "testpluginbodyafterserver", "server": {"executable": "backend.exe"}}`, `{"id": "testpluginbodyafter", "server": {"executable": "backend.exe"}}`, }, []string{ "testpluginbodyafterserver", "testpluginbodyafter", }, true, th.App, th.Context, ) hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin("testpluginbodyafter") require.NoError(t, err) _, ret := hooks.MessageWillBePosted(nil, nil) assert.Equal(t, "ok", ret) } func TestInterpluginPluginHTTPStreaming(t *testing.T) { mainHelper.Parallel(t) t.Run("large payload streaming", func(t *testing.T) { th := Setup(t) defer th.TearDown() setupMultiPluginAPITest(t, []string{ ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "bytes" "net/http" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/largepayload": // Generate 1MB payload in 64KB chunks chunkSize := 64 * 1024 totalChunks := 16 w.WriteHeader(http.StatusOK) for i := 0; i < totalChunks; i++ { chunk := bytes.Repeat([]byte("X"), chunkSize) w.Write(chunk) if f, ok := w.(http.Flusher); ok { f.Flush() } } } } func main() { plugin.ClientMain(&MyPlugin{}) } `, ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/model" "net/http" "io" "fmt" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { req, err := http.NewRequest("GET", "/testpluginlargepayloadserver/api/v2/largepayload", nil) if err != nil { return nil, err.Error() } resp := p.API.PluginHTTP(req) if resp == nil { return nil, "Nil resp" } if resp.Body == nil { return nil, "Nil body" } // Read response incrementally totalRead := 0 buf := make([]byte, 32*1024) for { n, err := resp.Body.Read(buf) totalRead += n if err == io.EOF { break } if err != nil { return nil, fmt.Sprintf("Read error: %v", err) } } expectedSize := 1024 * 1024 if totalRead != expectedSize { return nil, fmt.Sprintf("Expected %d bytes, got %d", expectedSize, totalRead) } return nil, "ok" } func main() { plugin.ClientMain(&MyPlugin{}) } `, }, []string{ `{"id": "testpluginlargepayloadserver", "server": {"executable": "backend.exe"}}`, `{"id": "testpluginlargepayloadclient", "server": {"executable": "backend.exe"}}`, }, []string{ "testpluginlargepayloadserver", "testpluginlargepayloadclient", }, true, th.App, th.Context, ) hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin("testpluginlargepayloadclient") require.NoError(t, err) _, ret := hooks.MessageWillBePosted(nil, nil) assert.Equal(t, "ok", ret) }) t.Run("incremental delivery", func(t *testing.T) { th := Setup(t) defer th.TearDown() setupMultiPluginAPITest(t, []string{ ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "net/http" "time" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/incremental": w.WriteHeader(http.StatusOK) chunks := []string{ "chunk1|", "chunk2|", "chunk3|", "chunk4|", "chunk5|", } for i, chunk := range chunks { w.Write([]byte(chunk)) if f, ok := w.(http.Flusher); ok { f.Flush() } // Delay between chunks (except last) if i < len(chunks)-1 { time.Sleep(100 * time.Millisecond) } } } } func main() { plugin.ClientMain(&MyPlugin{}) } `, ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/model" "net/http" "io" "fmt" "time" "strings" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { req, err := http.NewRequest("GET", "/testpluginincrementalserver/api/v2/incremental", nil) if err != nil { return nil, err.Error() } start := time.Now() resp := p.API.PluginHTTP(req) if resp == nil { return nil, "Nil resp" } if resp.Body == nil { return nil, "Nil body" } // Track when chunks arrive chunkTimes := []time.Duration{} receivedChunks := []string{} buf := make([]byte, 7) for { n, err := resp.Body.Read(buf) if n > 0 { chunkTimes = append(chunkTimes, time.Since(start)) receivedChunks = append(receivedChunks, string(buf[:n])) } if err == io.EOF { break } if err != nil { return nil, fmt.Sprintf("Read error: %v", err) } } // Verify all chunks received expected := "chunk1|chunk2|chunk3|chunk4|chunk5|" received := strings.Join(receivedChunks, "") if received != expected { return nil, fmt.Sprintf("Expected %q, got %q", expected, received) } // Verify incremental delivery if len(chunkTimes) < 2 { return nil, "Not enough chunks for timing verification" } // Check that chunks didn't all arrive at once timeDiff := chunkTimes[len(chunkTimes)-1] - chunkTimes[0] if timeDiff < 200*time.Millisecond { return nil, fmt.Sprintf("Chunks arrived too quickly: %v (expected >200ms)", timeDiff) } return nil, "ok" } func main() { plugin.ClientMain(&MyPlugin{}) } `, }, []string{ `{"id": "testpluginincrementalserver", "server": {"executable": "backend.exe"}}`, `{"id": "testpluginincrementalclient", "server": {"executable": "backend.exe"}}`, }, []string{ "testpluginincrementalserver", "testpluginincrementalclient", }, true, th.App, th.Context, ) hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin("testpluginincrementalclient") require.NoError(t, err) _, ret := hooks.MessageWillBePosted(nil, nil) assert.Equal(t, "ok", ret) }) } func TestAPIMetrics(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() t.Run("", func(t *testing.T) { metricsMock := &mocks.MetricsInterface{} pluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) pluginID := model.NewId() backend := filepath.Join(pluginDir, pluginID, "backend.exe") code := ` package main import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) UserHasBeenCreated(c *plugin.Context, user *model.User) { user.Nickname = "plugin-callback-success" p.API.UpdateUser(user) } func main() { plugin.ClientMain(&MyPlugin{}) } ` utils.CompileGo(t, code, backend) err = os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(`{"id": "`+pluginID+`", "server": {"executable": "backend.exe"}}`), 0600) require.NoError(t, err) // Don't care about these mocks metricsMock.On("ObservePluginHookDuration", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return() metricsMock.On("ObservePluginMultiHookIterationDuration", mock.Anything, mock.Anything, mock.Anything).Return() metricsMock.On("ObservePluginMultiHookDuration", mock.Anything).Return() // Setup mocks metricsMock.On("ObservePluginAPIDuration", pluginID, "UpdateUser", true, mock.Anything).Return() _, _, activationErr := env.Activate(pluginID) require.NoError(t, activationErr) require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) user1 := &model.User{ Email: model.NewId() + "success+test@example.com", Nickname: "Darth Vader1", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: "", } _, appErr := th.App.CreateUser(th.Context, user1) require.Nil(t, appErr) time.Sleep(1 * time.Second) user1, appErr = th.App.GetUser(user1.Id) require.Nil(t, appErr) require.Equal(t, "plugin-callback-success", user1.Nickname) // Disable plugin require.True(t, th.App.GetPluginsEnvironment().Deactivate(pluginID)) require.False(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) metricsMock.AssertExpectations(t) }) } func TestPluginAPIGetPostsForChannel(t *testing.T) { mainHelper.Parallel(t) require := require.New(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() numPosts := 10 // GetPostsForChannel returns posts ordered with the most recent first, so we // need to invert the expected slice, the oldest post being BasicPost expectedPosts := make([]*model.Post, numPosts) expectedPosts[numPosts-1] = th.BasicPost for i := numPosts - 2; i >= 0; i-- { expectedPosts[i] = th.CreatePost(th.BasicChannel) } // CreatePost does not add Metadata, but initializes the structure. GetPostsForChannel // returns nil for an empty Metadata, so we need to match that behaviour for _, post := range expectedPosts { post.Metadata = nil } postList, err := api.GetPostsForChannel(th.BasicChannel.Id, 0, 0) require.Nil(err) require.Nil(postList.ToSlice()) postList, err = api.GetPostsForChannel(th.BasicChannel.Id, 0, numPosts/2) require.Nil(err) require.Equal(expectedPosts[:numPosts/2], postList.ToSlice()) postList, err = api.GetPostsForChannel(th.BasicChannel.Id, 1, numPosts/2) require.Nil(err) require.Equal(expectedPosts[numPosts/2:], postList.ToSlice()) postList, err = api.GetPostsForChannel(th.BasicChannel.Id, 2, numPosts/2) require.Nil(err) require.Nil(postList.ToSlice()) postList, err = api.GetPostsForChannel(th.BasicChannel.Id, 0, numPosts+1) require.Nil(err) require.Equal(expectedPosts, postList.ToSlice()) } func TestPluginHTTPConnHijack(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() fullPath := filepath.Join(server.GetPackagePath(), "channels", "app", "plugin_api_tests", "manual.test_http_hijack_plugin", "main.go") pluginCode, err := os.ReadFile(fullPath) require.NoError(t, err) require.NotEmpty(t, pluginCode) tearDown, ids, errors := SetAppEnvironmentWithPlugins(t, []string{string(pluginCode)}, th.App, th.NewPluginAPI) defer tearDown() require.NoError(t, errors[0]) require.Len(t, ids, 1) pluginID := ids[0] require.NotEmpty(t, pluginID) reqURL := fmt.Sprintf("http://localhost:%d/plugins/%s", th.Server.ListenAddr.Port, pluginID) req, err := http.NewRequest("GET", reqURL, nil) require.NoError(t, err) client := &http.Client{} resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, "OK", string(body)) } func makePluginHTTPRequest(t *testing.T, pluginID string, port int, token string) string { t.Helper() client := &http.Client{} reqURL := fmt.Sprintf("http://localhost:%d/plugins/%s", port, pluginID) req, err := http.NewRequest("GET", reqURL, nil) require.NoError(t, err) req.Header.Set(model.HeaderAuth, model.HeaderToken+" "+token) resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) return string(body) } func TestPluginMFAEnforcement(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() th.App.Srv().SetLicense(model.NewTestLicense("mfa")) pluginCode := ` package main import ( "net/http" "github.com/mattermost/mattermost/server/public/plugin" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { // Simply return the value of Mattermost-User-Id header userID := r.Header.Get("Mattermost-User-Id") w.Write([]byte(userID)) } func main() { plugin.ClientMain(&MyPlugin{}) } ` // Create and setup plugin tearDown, ids, errs := SetAppEnvironmentWithPlugins(t, []string{pluginCode}, th.App, th.NewPluginAPI) defer tearDown() require.NoError(t, errs[0]) require.Len(t, ids, 1) pluginID := ids[0] // Create user that requires MFA user := th.CreateUser() // Create session session, appErr := th.App.CreateSession(th.Context, &model.Session{ UserId: user.Id, }) require.Nil(t, appErr) t.Run("MFA not enforced", func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = true *cfg.ServiceSettings.EnforceMultifactorAuthentication = false }) // Should return user ID since MFA is not enforced userID := makePluginHTTPRequest(t, pluginID, th.Server.ListenAddr.Port, session.Token) assert.Equal(t, user.Id, userID) }) t.Run("MFA enforced", func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = true *cfg.ServiceSettings.EnforceMultifactorAuthentication = true }) // Should return empty string since MFA is enforced but not active userID := makePluginHTTPRequest(t, pluginID, th.Server.ListenAddr.Port, session.Token) assert.Empty(t, userID) }) } func TestPluginHTTPUpgradeWebSocket(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() fullPath := filepath.Join(server.GetPackagePath(), "channels", "app", "plugin_api_tests", "manual.test_http_upgrade_websocket_plugin", "main.go") pluginCode, err := os.ReadFile(fullPath) require.NoError(t, err) require.NotEmpty(t, pluginCode) tearDown, ids, errors := SetAppEnvironmentWithPlugins(t, []string{string(pluginCode)}, th.App, th.NewPluginAPI) defer tearDown() require.NoError(t, errors[0]) require.Len(t, ids, 1) pluginID := ids[0] require.NotEmpty(t, pluginID) reqURL := fmt.Sprintf("ws://localhost:%d/plugins/%s", th.Server.ListenAddr.Port, pluginID) wsc, err := model.NewWebSocketClient(reqURL, "") require.NoError(t, err) require.NotNil(t, wsc) wsc.Listen() defer wsc.Close() resp := <-wsc.ResponseChannel require.Equal(t, resp.Status, model.StatusOk) for i := range 10 { wsc.SendMessage("custom_action", map[string]any{"value": i}) var resp *model.WebSocketResponse select { case resp = <-wsc.ResponseChannel: case <-time.After(2 * time.Second): } require.NotNil(t, resp) require.Equal(t, resp.Status, model.StatusOk) require.Equal(t, "custom_action", resp.Data["action"]) require.Equal(t, float64(i), resp.Data["value"]) } } type MockSlashCommandProvider struct { Args *model.CommandArgs Message string } func (*MockSlashCommandProvider) GetTrigger() string { return "mock" } func (*MockSlashCommandProvider) GetCommand(a *App, T i18n.TranslateFunc) *model.Command { return &model.Command{ Trigger: "mock", AutoComplete: true, AutoCompleteDesc: "mock", AutoCompleteHint: "mock", DisplayName: "mock", } } func (mscp *MockSlashCommandProvider) DoCommand(a *App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse { mscp.Args = args mscp.Message = message return &model.CommandResponse{ Text: "mock", ResponseType: model.CommandResponseTypeEphemeral, } } func TestPluginExecuteSlashCommand(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() slashCommandMock := &MockSlashCommandProvider{} RegisterCommandProvider(slashCommandMock) newUser := th.CreateUser() th.LinkUserToTeam(newUser, th.BasicTeam) t.Run("run invite command", func(t *testing.T) { args := &model.CommandArgs{ Command: "/mock @" + newUser.Username, TeamId: th.BasicTeam.Id, UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, } _, err := api.ExecuteSlashCommand(args) require.NoError(t, err) require.Equal(t, args, slashCommandMock.Args) require.Equal(t, "@"+newUser.Username, slashCommandMock.Message) }) } func TestPluginAPISearchPostsInTeamByUser(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() basicPostText := &th.BasicPost.Message unknownTerm := "Unknown Message" testCases := []struct { description string teamID string userID string params model.SearchParameter expectedPostsLen int }{ { "empty params", th.BasicTeam.Id, th.BasicUser.Id, model.SearchParameter{}, 0, }, { "doesn't match any posts", th.BasicTeam.Id, th.BasicUser.Id, model.SearchParameter{Terms: &unknownTerm}, 0, }, { "matched posts", th.BasicTeam.Id, th.BasicUser.Id, model.SearchParameter{Terms: basicPostText}, 1, }, } for _, testCase := range testCases { t.Run(testCase.description, func(t *testing.T) { searchResults, err := api.SearchPostsInTeamForUser(testCase.teamID, testCase.userID, testCase.params) assert.Nil(t, err) assert.Equal(t, testCase.expectedPostsLen, len(searchResults.Posts)) }) } } func TestPluginAPICreateCommandAndListCommands(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() foundCommand := func(listXCommand func(teamID string) ([]*model.Command, error)) bool { cmds, appErr := listXCommand(th.BasicTeam.Id) require.NoError(t, appErr) for _, cmd := range cmds { if cmd.Trigger == "testcmd" { return true } } return false } require.False(t, foundCommand(api.ListCommands)) cmd := &model.Command{ TeamId: th.BasicTeam.Id, Trigger: "testcmd", Method: "G", URL: "http://test.com/testcmd", } cmd, appErr := api.CreateCommand(cmd) require.NoError(t, appErr) newCmd, appErr := api.GetCommand(cmd.Id) require.NoError(t, appErr) require.Equal(t, "pluginid", newCmd.PluginId) require.Equal(t, "", newCmd.CreatorId) require.True(t, foundCommand(api.ListCommands)) require.True(t, foundCommand(api.ListCustomCommands)) require.False(t, foundCommand(api.ListPluginCommands)) } func TestPluginAPIUpdateCommand(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() cmd := &model.Command{ TeamId: th.BasicTeam.Id, Trigger: "testcmd", Method: "G", URL: "http://test.com/testcmd", } cmd, appErr := api.CreateCommand(cmd) require.NoError(t, appErr) newCmd, appErr := api.GetCommand(cmd.Id) require.NoError(t, appErr) require.Equal(t, "pluginid", newCmd.PluginId) require.Equal(t, "", newCmd.CreatorId) newCmd.Trigger = "NewTrigger" newCmd.PluginId = "CannotChangeMe" newCmd2, appErr := api.UpdateCommand(newCmd.Id, newCmd) require.NoError(t, appErr) require.Equal(t, "pluginid", newCmd2.PluginId) require.Equal(t, "newtrigger", newCmd2.Trigger) team1 := th.CreateTeam() newCmd2.PluginId = "CannotChangeMe" newCmd2.Trigger = "anotherNewTrigger" newCmd2.TeamId = team1.Id newCmd3, appErr := api.UpdateCommand(newCmd2.Id, newCmd2) require.NoError(t, appErr) require.Equal(t, "pluginid", newCmd3.PluginId) require.Equal(t, "anothernewtrigger", newCmd3.Trigger) require.Equal(t, team1.Id, newCmd3.TeamId) newCmd3.Trigger = "anotherNewTriggerAgain" newCmd3.TeamId = "" newCmd4, appErr := api.UpdateCommand(newCmd2.Id, newCmd2) require.NoError(t, appErr) require.Equal(t, "anothernewtriggeragain", newCmd4.Trigger) require.Equal(t, team1.Id, newCmd4.TeamId) } func TestPluginAPIIsEnterpriseReady(t *testing.T) { oldValue := model.BuildEnterpriseReady defer func() { model.BuildEnterpriseReady = oldValue }() model.BuildEnterpriseReady = "true" th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() assert.Equal(t, true, api.IsEnterpriseReady()) } func TestPluginUploadsAPI(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() pluginCode := fmt.Sprintf(` package main import ( "fmt" "bytes" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" ) type TestPlugin struct { plugin.MattermostPlugin } func (p *TestPlugin) OnActivate() error { data := []byte("some content to upload") us, err := p.API.CreateUploadSession(&model.UploadSession{ Id: "%s", UserId: "%s", ChannelId: "%s", Type: model.UploadTypeAttachment, FileSize: int64(len(data)), Filename: "upload.test", }) if err != nil { return fmt.Errorf("failed to create upload session: %%w", err) } us2, err := p.API.GetUploadSession(us.Id) if err != nil { return fmt.Errorf("failed to get upload session: %%w", err) } if us.Id != us2.Id { return fmt.Errorf("upload sessions should match") } fi, err := p.API.UploadData(us, bytes.NewBuffer(data)) if err != nil { return fmt.Errorf("failed to upload data: %%w", err) } if fi == nil || fi.Id == "" { return fmt.Errorf("fileinfo should be set") } fileData, appErr := p.API.GetFile(fi.Id) if appErr != nil { return fmt.Errorf("failed to get file data: %%w", err) } if !bytes.Equal(data, fileData) { return fmt.Errorf("file data should match") } return nil } func main() { plugin.ClientMain(&TestPlugin{}) } `, model.NewId(), th.BasicUser.Id, th.BasicChannel.Id) pluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) newPluginAPI := func(manifest *model.Manifest) plugin.API { return th.App.NewPluginAPI(th.Context, manifest) } env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) pluginID := "testplugin" pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` backend := filepath.Join(pluginDir, pluginID, "backend.exe") utils.CompileGo(t, pluginCode, backend) err = os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(pluginManifest), 0600) require.NoError(t, err) manifest, activated, reterr := env.Activate(pluginID) require.NoError(t, reterr) require.NotNil(t, manifest) require.True(t, activated) } //go:embed plugin_api_tests/manual.test_configuration_will_be_saved_hook/main.tmpl var configurationWillBeSavedHookTemplate string func TestConfigurationWillBeSavedHook(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() getPluginCode := func(hookCode string) string { return fmt.Sprintf(configurationWillBeSavedHookTemplate, hookCode) } runPlugin := func(t *testing.T, code string) { pluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) webappPluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) defer os.RemoveAll(pluginDir) defer os.RemoveAll(webappPluginDir) newPluginAPI := func(manifest *model.Manifest) plugin.API { return th.App.NewPluginAPI(th.Context, manifest) } env, err := plugin.NewEnvironment(newPluginAPI, NewDriverImpl(th.App.Srv()), pluginDir, webappPluginDir, th.App.Log(), nil) require.NoError(t, err) th.App.ch.SetPluginsEnvironment(env) pluginID := "testplugin" pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` backend := filepath.Join(pluginDir, pluginID, "backend.exe") utils.CompileGo(t, code, backend) err = os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(pluginManifest), 0600) require.NoError(t, err) manifest, activated, reterr := env.Activate(pluginID) require.NoError(t, reterr) require.NotNil(t, manifest) require.True(t, activated) } t.Run("error", func(t *testing.T) { hookCode := ` return nil, fmt.Errorf("plugin hook failed") ` runPlugin(t, getPluginCode(hookCode)) cfg := th.App.Config() _, _, appErr := th.App.SaveConfig(cfg, false) require.NotNil(t, appErr) require.Equal(t, "saveConfig: An error occurred running the plugin hook on configuration save., plugin hook failed", appErr.Error()) require.Equal(t, cfg, th.App.Config()) }) t.Run("AppError", func(t *testing.T) { hookCode := ` return nil, model.NewAppError("saveConfig", "custom_error", nil, "", 400) ` runPlugin(t, getPluginCode(hookCode)) cfg := th.App.Config() _, _, appErr := th.App.SaveConfig(cfg, false) require.NotNil(t, appErr) require.Equal(t, "custom_error", appErr.Id) require.Equal(t, cfg, th.App.Config()) }) t.Run("no error, no config change", func(t *testing.T) { hookCode := ` return nil, nil ` runPlugin(t, getPluginCode(hookCode)) cfg := th.App.Config() _, newCfg, appErr := th.App.SaveConfig(cfg, false) require.Nil(t, appErr) require.Equal(t, cfg, newCfg) }) t.Run("config change", func(t *testing.T) { hookCode := ` cfg := newCfg.Clone() cfg.PluginSettings.Plugins["custom_plugin"] = map[string]any{ "custom_key": "custom_val", } return cfg, nil ` runPlugin(t, getPluginCode(hookCode)) cfg := th.App.Config() _, newCfg, appErr := th.App.SaveConfig(cfg, false) require.Nil(t, appErr) require.NotEqual(t, cfg, newCfg) require.Equal(t, map[string]any{ "custom_key": "custom_val", }, newCfg.PluginSettings.Plugins["custom_plugin"]) }) } func TestSendPushNotification(t *testing.T) { mainHelper.Parallel(t) if testing.Short() { t.Skip("skipping TestSendPushNotification test in short mode") } th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() // Create 3 users, each having 2 sessions. type userSession struct { user *model.User session *model.Session } var userSessions []userSession for range 3 { u := th.CreateUser() sess, err := th.App.CreateSession(th.Context, &model.Session{ UserId: u.Id, DeviceId: "deviceID" + u.Id, ExpiresAt: model.GetMillis() + 100000, }) require.Nil(t, err) // We don't need to track the 2nd session. _, err = th.App.CreateSession(th.Context, &model.Session{ UserId: u.Id, DeviceId: "deviceID" + u.Id, ExpiresAt: model.GetMillis() + 100000, }) require.Nil(t, err) _, err = th.App.AddTeamMember(th.Context, th.BasicTeam.Id, u.Id) require.Nil(t, err) th.AddUserToChannel(u, th.BasicChannel) userSessions = append(userSessions, userSession{ user: u, session: sess, }) } handler := &testPushNotificationHandler{ t: t, behavior: "simple", } pushServer := httptest.NewServer( http.HandlerFunc(handler.handleReq), ) defer pushServer.Close() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.EmailSettings.PushNotificationContents = model.FullNotification *cfg.EmailSettings.PushNotificationServer = pushServer.URL }) var wg sync.WaitGroup for _, data := range userSessions { wg.Add(1) go func(user model.User) { defer wg.Done() post := th.CreatePost(th.BasicChannel) post.Message = "started a conversation" notification := &model.PushNotification{ Category: model.CategoryCanReply, Version: model.PushMessageV2, Type: model.PushTypeMessage, TeamId: th.BasicChannel.TeamId, ChannelId: th.BasicChannel.Id, PostId: post.Id, RootId: post.RootId, SenderId: post.UserId, SenderName: "Sender Name", PostType: post.Type, ChannelType: th.BasicChannel.Type, Message: "Custom message", } appErr := api.SendPushNotification(notification, user.Id) require.Nil(t, appErr) }(*data.user) } wg.Wait() // Hack to let the worker goroutines complete. time.Sleep(1 * time.Second) // Server side verification. var numMessages int for _, n := range handler.notifications() { switch n.Type { case model.PushTypeMessage: numMessages++ assert.Equal(t, th.BasicChannel.Id, n.ChannelId) assert.Equal(t, "Custom message", n.Message) default: assert.Fail(t, "should not receive any other push notification types") } } assert.Equal(t, 6, numMessages) } func TestPluginServeMetrics(t *testing.T) { mainHelper.Parallel(t) th := Setup(t, StartMetrics) defer th.TearDown() var prevEnable *bool var prevAddress *string th.App.UpdateConfig(func(cfg *model.Config) { prevEnable = cfg.MetricsSettings.Enable prevAddress = cfg.MetricsSettings.ListenAddress cfg.MetricsSettings.Enable = model.NewPointer(true) cfg.MetricsSettings.ListenAddress = model.NewPointer(":30067") }) defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.MetricsSettings.Enable = prevEnable cfg.MetricsSettings.ListenAddress = prevAddress }) fullPath := filepath.Join(server.GetPackagePath(), "channels", "app", "plugin_api_tests", "manual.test_serve_metrics_plugin", "main.go") pluginCode, err := os.ReadFile(fullPath) require.NoError(t, err) require.NotEmpty(t, pluginCode) tearDown, ids, errors := SetAppEnvironmentWithPlugins(t, []string{string(pluginCode)}, th.App, th.NewPluginAPI) defer tearDown() require.NoError(t, errors[0]) require.Len(t, ids, 1) pluginID := ids[0] require.NotEmpty(t, pluginID) reqURL := fmt.Sprintf("http://localhost%s/plugins/%s/metrics", *th.App.Config().MetricsSettings.ListenAddress, pluginID) req, err := http.NewRequest("GET", reqURL, nil) require.NoError(t, err) client := &http.Client{} resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, "METRICS", string(body)) reqURL = fmt.Sprintf("http://localhost%s/plugins/%s/metrics/subpath", *th.App.Config().MetricsSettings.ListenAddress, pluginID) req, err = http.NewRequest("GET", reqURL, nil) require.NoError(t, err) resp, err = client.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err = io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, "METRICS SUBPATH", string(body)) } func TestPluginGetChannelsForTeamForUser(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() user := th.CreateUser() team1 := th.CreateTeam() th.LinkUserToTeam(user, team1) team2 := th.CreateTeam() th.LinkUserToTeam(user, team2) channel1 := th.CreateChannel(th.Context, team1) th.AddUserToChannel(user, channel1) channel2 := th.CreateChannel(th.Context, team2) th.AddUserToChannel(user, channel2) dmChannel := th.CreateDmChannel(user) pluginCode := ` package main import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/pkg/errors" ) const ( userID = "` + user.Id + `" teamID1 = "` + team1.Id + `" teamID2 = "` + team2.Id + `" channelID1 = "` + channel1.Id + `" channelID2 = "` + channel2.Id + `" dmChannelID = "` + dmChannel.Id + `" ) type TestPlugin struct { plugin.MattermostPlugin } func checkForChannels(channels []*model.Channel, expectedLength int, channel1Expected, channel2Expected, dmChannelExpected bool) string { if len(channels) != expectedLength { return "Returned the wrong number of channels" } var channel1Found, channel2Found, dmChannelFound bool for _, channel := range channels { if channel.Id == channelID1 { channel1Found = true } else if channel.Id == channelID2 { channel2Found = true } else if channel.Id == dmChannelID { dmChannelFound = true } } if channel1Found && !channel1Expected { return "Channel 1 found" } else if !channel1Found && channel1Expected { return "Channel 1 not found" } else if channel2Found && !channel2Expected { return "Channel 2 found" } else if !channel2Found && channel2Expected { return "Channel 2 not found" } else if dmChannelFound && !dmChannelExpected { return "DM Channel found" } else if !dmChannelFound && dmChannelExpected { return "DM Channel not found" } else { return "" } } func (p *TestPlugin) OnActivate() error { if channels, appErr := p.API.GetChannelsForTeamForUser(teamID1, userID, true); appErr != nil { return appErr } else if msg := checkForChannels(channels, 4, true, false, true); msg != "" { return errors.New(msg + " when called with team ID 1") } if channels, appErr := p.API.GetChannelsForTeamForUser(teamID2, userID, true); appErr != nil { return appErr } else if msg := checkForChannels(channels, 4, false, true, true); msg != "" { return errors.New(msg + " when called with team ID 2") } if channels, appErr := p.API.GetChannelsForTeamForUser("", userID, true); appErr != nil { return appErr } else if msg := checkForChannels(channels, 7, true, true, true); msg != "" { return errors.New(msg + " when called with empty team ID") } return nil } func main() { plugin.ClientMain(&TestPlugin{}) }` pluginID := "testplugin" pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` setupPluginAPITest(t, pluginCode, pluginManifest, pluginID, th.App, th.Context) } func TestPluginPatchChannelMembersNotifications(t *testing.T) { mainHelper.Parallel(t) t.Run("should be able to set fields for multiple members", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() channel := th.CreateChannel(th.Context, th.BasicTeam) th.AddUserToChannel(th.BasicUser, channel) th.AddUserToChannel(th.BasicUser2, channel) member1, err := th.App.GetChannelMember(th.Context, channel.Id, th.BasicUser.Id) require.Nil(t, err) require.Equal(t, "", member1.NotifyProps["test_field"]) require.Equal(t, model.IgnoreChannelMentionsDefault, member1.NotifyProps[model.IgnoreChannelMentionsNotifyProp]) member2, err := th.App.GetChannelMember(th.Context, channel.Id, th.BasicUser2.Id) require.Nil(t, err) require.Equal(t, "", member2.NotifyProps["test_field"]) require.Equal(t, model.IgnoreChannelMentionsDefault, member2.NotifyProps[model.IgnoreChannelMentionsNotifyProp]) pluginCode := ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/model" ) const ( channelID = "` + channel.Id + `" userID1 = "` + th.BasicUser.Id + `" userID2 = "` + th.BasicUser2.Id + `" ) type TestPlugin struct { plugin.MattermostPlugin } func (p *TestPlugin) OnActivate() error { return p.API.PatchChannelMembersNotifications( []*model.ChannelMemberIdentifier{ {ChannelId: channelID, UserId: userID1}, {ChannelId: channelID, UserId: userID2}, }, map[string]string{ "test_field": "test_value", model.IgnoreChannelMentionsNotifyProp: model.IgnoreChannelMentionsOn, }, ) } func main() { plugin.ClientMain(&TestPlugin{}) }` pluginID := "testplugin" pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` setupPluginAPITest(t, pluginCode, pluginManifest, pluginID, th.App, th.Context) updated1, err := th.App.GetChannelMember(th.Context, member1.ChannelId, member1.UserId) require.Nil(t, err) updated2, err := th.App.GetChannelMember(th.Context, member2.ChannelId, member2.UserId) require.Nil(t, err) assert.Equal(t, member1.NotifyProps[model.MarkUnreadNotifyProp], updated1.NotifyProps[model.MarkUnreadNotifyProp]) assert.Equal(t, "test_value", updated1.NotifyProps["test_field"]) assert.Equal(t, model.IgnoreChannelMentionsOn, updated1.NotifyProps[model.IgnoreChannelMentionsNotifyProp]) assert.Equal(t, member2.NotifyProps[model.MarkUnreadNotifyProp], updated2.NotifyProps[model.MarkUnreadNotifyProp]) assert.Equal(t, "test_value", updated2.NotifyProps["test_field"]) assert.Equal(t, model.IgnoreChannelMentionsOn, updated2.NotifyProps[model.IgnoreChannelMentionsNotifyProp]) }) t.Run("should be able to clear a field", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() channel := th.CreateChannel(th.Context, th.BasicTeam) th.AddUserToChannel(th.BasicUser, channel) member, err := th.App.GetChannelMember(th.Context, channel.Id, th.BasicUser.Id) require.Nil(t, err) member.NotifyProps["test_field"] = "test_value" _, err = th.App.updateChannelMember(th.Context, member) require.Nil(t, err) member, err = th.App.GetChannelMember(th.Context, channel.Id, th.BasicUser.Id) require.Nil(t, err) require.Equal(t, "test_value", member.NotifyProps["test_field"]) pluginCode := ` package main import ( "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/model" ) const ( channelID = "` + channel.Id + `" userID = "` + th.BasicUser.Id + `" ) type TestPlugin struct { plugin.MattermostPlugin } func (p *TestPlugin) OnActivate() error { return p.API.PatchChannelMembersNotifications( []*model.ChannelMemberIdentifier{ {ChannelId: channelID, UserId: userID}, }, map[string]string{ "test_field": "", }, ) } func main() { plugin.ClientMain(&TestPlugin{}) }` pluginID := "testplugin" pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` setupPluginAPITest(t, pluginCode, pluginManifest, pluginID, th.App, th.Context) updated, err := th.App.GetChannelMember(th.Context, member.ChannelId, member.UserId) require.Nil(t, err) assert.Equal(t, "", updated.NotifyProps["test_field"]) }) } func TestPluginServeHTTPCompatibility(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() pluginCode := ` package main import ( "net/http" "github.com/mattermost/mattermost/server/public/plugin" ) type MyPlugin struct { plugin.MattermostPlugin } func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte("plugin response")) } func main() { plugin.ClientMain(&MyPlugin{}) } ` for goVersion := range strings.FieldsSeq(os.Getenv("GO_COMPATIBILITY_TEST_VERSIONS")) { t.Run(goVersion, func(t *testing.T) { tearDown, ids, errs := SetAppEnvironmentWithPluginsGoVersion(t, []string{pluginCode}, th.App, th.NewPluginAPI, goVersion) defer tearDown() require.NoError(t, errs[0]) require.Len(t, ids, 1) pluginID := ids[0] res := makePluginHTTPRequest(t, pluginID, th.Server.ListenAddr.Port, "") assert.Equal(t, "plugin response", res) }) } } func TestPluginAPICreatePropertyField(t *testing.T) { mainHelper.Parallel(t) t.Run("should allow creation after deleting fields", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() // Create 20 property fields groupID := model.NewId() var createdFields []*model.PropertyField for i := 1; i <= 20; i++ { field := &model.PropertyField{ GroupID: groupID, Name: fmt.Sprintf("field_%d", i), Type: model.PropertyFieldTypeText, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), } created, err := api.CreatePropertyField(field) require.NoError(t, err) createdFields = append(createdFields, created) } // Delete one field err := api.DeletePropertyField(groupID, createdFields[0].ID) require.NoError(t, err) // Should now be able to create another field newField := &model.PropertyField{ GroupID: groupID, Name: "new_field", Type: model.PropertyFieldTypeText, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), } created, err := api.CreatePropertyField(newField) require.NoError(t, err) assert.Equal(t, newField.Name, created.Name) }) t.Run("should not count deleted fields", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() groupID := model.NewId() // Create and delete 5 fields for i := 1; i <= 5; i++ { field := &model.PropertyField{ GroupID: groupID, Name: fmt.Sprintf("deleted_field_%d", i), Type: model.PropertyFieldTypeText, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), } created, err := api.CreatePropertyField(field) require.NoError(t, err) err = api.DeletePropertyField(groupID, created.ID) require.NoError(t, err) } // Should be able to create multiple active fields for i := 1; i <= 20; i++ { field := &model.PropertyField{ GroupID: groupID, Name: fmt.Sprintf("active_field_%d", i), Type: model.PropertyFieldTypeText, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), } created, err := api.CreatePropertyField(field) require.NoError(t, err) assert.Equal(t, field.Name, created.Name) } }) t.Run("should reject empty or invalid group ID", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() // Test with empty group ID - should fail validation field := &model.PropertyField{ GroupID: "", Name: "test_field", Type: model.PropertyFieldTypeText, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), } created, err := api.CreatePropertyField(field) require.Error(t, err) // Should fail due to invalid GroupID assert.Nil(t, created) assert.Contains(t, err.Error(), "group_id") // Test with nil field - should fail gracefully created, err = api.CreatePropertyField(nil) require.Error(t, err) // Should fail when given nil input assert.Nil(t, created) assert.Contains(t, err.Error(), "invalid input: property field parameter is required") }) } func TestPluginAPICountPropertyFields(t *testing.T) { mainHelper.Parallel(t) t.Run("should count active property fields only", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() groupID := model.NewId() // Create 5 fields var createdFields []*model.PropertyField for i := 1; i <= 5; i++ { field := &model.PropertyField{ GroupID: groupID, Name: fmt.Sprintf("field_%d", i), Type: model.PropertyFieldTypeText, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), } created, err := api.CreatePropertyField(field) require.NoError(t, err) createdFields = append(createdFields, created) } // Count active fields count, err := api.CountPropertyFields(groupID, false) require.NoError(t, err) assert.Equal(t, int64(5), count) // Delete 2 fields err = api.DeletePropertyField(groupID, createdFields[0].ID) require.NoError(t, err) err = api.DeletePropertyField(groupID, createdFields[1].ID) require.NoError(t, err) // Count should now be 3 count, err = api.CountPropertyFields(groupID, false) require.NoError(t, err) assert.Equal(t, int64(3), count) }) t.Run("should count all property fields including deleted", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() groupID := model.NewId() // Create 5 fields var createdFields []*model.PropertyField for i := 1; i <= 5; i++ { field := &model.PropertyField{ GroupID: groupID, Name: fmt.Sprintf("field_%d", i), Type: model.PropertyFieldTypeText, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), } created, err := api.CreatePropertyField(field) require.NoError(t, err) createdFields = append(createdFields, created) } // Count all fields count, err := api.CountPropertyFields(groupID, true) require.NoError(t, err) assert.Equal(t, int64(5), count) // Delete 2 fields err = api.DeletePropertyField(groupID, createdFields[0].ID) require.NoError(t, err) err = api.DeletePropertyField(groupID, createdFields[1].ID) require.NoError(t, err) // Count all should still be 5 count, err = api.CountPropertyFields(groupID, true) require.NoError(t, err) assert.Equal(t, int64(5), count) // Count active should be 3 count, err = api.CountPropertyFields(groupID, false) require.NoError(t, err) assert.Equal(t, int64(3), count) }) t.Run("should return 0 for empty group", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() api := th.SetupPluginAPI() count, err := api.CountPropertyFields("non-existent-group", false) require.NoError(t, err) assert.Equal(t, int64(0), count) count, err = api.CountPropertyFields("non-existent-group", true) require.NoError(t, err) assert.Equal(t, int64(0), count) }) }