Files
team/server/channels/app/plugin_api_test.go
dcab3d0ce6 Fix flaky TestBasicAPIPlugins/test_send_mail_plugin (#36834)
The send-mail plugin test calls RetryInbucket only when GetMailBox
returns an error. When mail delivery is slow, Inbucket returns an empty
mailbox with no error, so the hook fails immediately instead of waiting.
Wrap the hook invocation in require.Eventually so the test retries until
SendMail delivery and mailbox assertions succeed under CI load.

Tests-only change. Verified with go test -run '^TestBasicAPIPlugins$/test_send_mail_plugin$' -race -count=100 ./channels/app.

Co-authored-by: mattermost-code <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-10 15:40:07 -04:00

3953 lines
109 KiB
Go

// 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)
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(t)
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(t)
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)
api := th.SetupPluginAPI()
user1, err := th.App.CreateUser(th.Context, &model.User{
Email: strings.ToLower(model.NewId()) + "[email protected]",
Password: model.NewTestPassword(),
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)
require.Len(t, preferences, 3)
prefByCategory := make(map[string]model.Preference, len(preferences))
for _, p := range preferences {
prefByCategory[p.Category] = p
}
require.Len(t, prefByCategory, 3, "default user preferences should use distinct categories")
nextSteps, ok := prefByCategory[model.PreferenceRecommendedNextSteps]
require.True(t, ok, "expected default preference for category %q", model.PreferenceRecommendedNextSteps)
assert.Equal(t, user1.Id, nextSteps.UserId)
assert.Equal(t, "hide", nextSteps.Name)
assert.Equal(t, "false", nextSteps.Value)
_, ok = prefByCategory[model.PreferenceCategorySystemNotice]
require.True(t, ok, "expected default preference for category %q", model.PreferenceCategorySystemNotice)
tutorial, ok := prefByCategory[model.PreferenceCategoryTutorialSteps]
require.True(t, ok, "expected default preference for category %q", model.PreferenceCategoryTutorialSteps)
assert.Equal(t, user1.Id, tutorial.UserId)
assert.Equal(t, user1.Id, tutorial.Name)
assert.Equal(t, "0", tutorial.Value)
}
func TestPluginAPIDeleteUserPreferences(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
api := th.SetupPluginAPI()
user1, err := th.App.CreateUser(th.Context, &model.User{
Email: strings.ToLower(model.NewId()) + "[email protected]",
Password: model.NewTestPassword(),
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()) + "[email protected]",
Password: model.NewTestPassword(),
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)
api := th.SetupPluginAPI()
user1, err := th.App.CreateUser(th.Context, &model.User{
Email: strings.ToLower(model.NewId()) + "[email protected]",
Password: model.NewTestPassword(),
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)
require.Len(t, preferences, 3)
prefByCategory := make(map[string]model.Preference, len(preferences))
for _, p := range preferences {
prefByCategory[p.Category] = p
}
require.Len(t, prefByCategory, 3, "default user preferences should use distinct categories")
nextSteps, ok := prefByCategory[model.PreferenceRecommendedNextSteps]
require.True(t, ok, "expected default preference for category %q", model.PreferenceRecommendedNextSteps)
assert.Equal(t, user1.Id, nextSteps.UserId)
assert.Equal(t, "hide", nextSteps.Name)
assert.Equal(t, "false", nextSteps.Value)
_, ok = prefByCategory[model.PreferenceCategorySystemNotice]
require.True(t, ok, "expected default preference for category %q", model.PreferenceCategorySystemNotice)
tutorial, ok := prefByCategory[model.PreferenceCategoryTutorialSteps]
require.True(t, ok, "expected default preference for category %q", model.PreferenceCategoryTutorialSteps)
assert.Equal(t, user1.Id, tutorial.UserId)
assert.Equal(t, user1.Id, tutorial.Name)
assert.Equal(t, "0", tutorial.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(t)
api := th.SetupPluginAPI()
user1, err := th.App.CreateUser(th.Context, &model.User{
Email: strings.ToLower(model.NewId()) + "[email protected]",
Password: model.NewTestPassword(),
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()) + "[email protected]",
Password: model.NewTestPassword(),
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()) + "[email protected]",
Password: model.NewTestPassword(),
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()) + "[email protected]",
Password: model.NewTestPassword(),
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(t)
api := th.SetupPluginAPI()
user1, err := th.App.CreateUser(th.Context, &model.User{
Email: strings.ToLower(model.NewId()) + "[email protected]",
Password: model.NewTestPassword(),
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()) + "[email protected]",
Password: model.NewTestPassword(),
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()) + "[email protected]",
Password: model.NewTestPassword(),
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)
api := th.SetupPluginAPI()
team1 := th.CreateTeam(t)
team2 := th.CreateTeam(t)
user1, err := th.App.CreateUser(th.Context, &model.User{
Email: strings.ToLower(model.NewId()) + "[email protected]",
Password: model.NewTestPassword(),
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()) + "[email protected]",
Password: model.NewTestPassword(),
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()) + "[email protected]",
Password: model.NewTestPassword(),
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()) + "[email protected]",
Password: model.NewTestPassword(),
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)
api := th.SetupPluginAPI()
user1, err := th.App.CreateUser(th.Context, &model.User{
Email: strings.ToLower(model.NewId()) + "[email protected]",
Username: "user_" + model.NewId(),
Password: model.NewTestPassword(),
})
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(t)
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(t)
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)
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)
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)
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 TestPluginAPILoadPluginConfigurationDefaultsFromSections(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
var pluginJson map[string]any
err := json.Unmarshal([]byte(`{"mysectionstringsetting": "override"}`), &pluginJson)
require.NoError(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.PluginSettings.Plugins["testsectiondefaults"] = pluginJson
})
manifest := &model.Manifest{
Id: "testsectiondefaults",
SettingsSchema: &model.PluginSettingsSchema{
Sections: []*model.PluginSettingsSection{
{
Key: "section1",
Settings: []*model.PluginSetting{
{Key: "MySectionStringSetting", Type: "text", Default: "notthis"},
{Key: "MySectionIntSetting", Type: "text", Default: float64(42)},
{Key: "MySectionBoolSetting", Type: "bool", Default: true},
},
},
},
},
}
api := NewPluginAPI(th.App, th.Context, manifest)
var dest struct {
MySectionStringSetting string
MySectionIntSetting int
MySectionBoolSetting bool
}
err = api.LoadPluginConfiguration(&dest)
require.NoError(t, err)
assert.Equal(t, "override", dest.MySectionStringSetting) // saved config overrides default
assert.Equal(t, 42, dest.MySectionIntSetting) // default applied from section
assert.True(t, dest.MySectionBoolSetting) // default applied from section
}
func TestPluginAPILoadPluginConfigurationDefaultsMixed(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
var pluginJson map[string]any
err := json.Unmarshal([]byte(`{"toplevelsetting": "saved_value"}`), &pluginJson)
require.NoError(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.PluginSettings.Plugins["testmixeddefaults"] = pluginJson
})
manifest := &model.Manifest{
Id: "testmixeddefaults",
SettingsSchema: &model.PluginSettingsSchema{
Settings: []*model.PluginSetting{
{Key: "TopLevelSetting", Type: "text", Default: "top_default"},
{Key: "TopLevelBool", Type: "bool", Default: true},
},
Sections: []*model.PluginSettingsSection{
{
Key: "section1",
Settings: []*model.PluginSetting{
{Key: "SectionSetting", Type: "text", Default: "section_default"},
{Key: "SectionInt", Type: "text", Default: float64(99)},
},
},
},
},
}
api := NewPluginAPI(th.App, th.Context, manifest)
var dest struct {
TopLevelSetting string
TopLevelBool bool
SectionSetting string
SectionInt int
}
err = api.LoadPluginConfiguration(&dest)
require.NoError(t, err)
// Top-level: saved config overrides default
assert.Equal(t, "saved_value", dest.TopLevelSetting)
// Top-level: default applied
assert.True(t, dest.TopLevelBool)
// Section: default applied
assert.Equal(t, "section_default", dest.SectionSetting)
// Section: default applied
assert.Equal(t, 99, dest.SectionInt)
}
func TestPluginAPIGetPlugins(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
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)
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)
// 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(t)
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(t)
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(t)
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)
runHook := func() error {
_, ret := hooks.MessageWillBePosted(nil, nil)
if ret != "OK" {
return errors.New(ret)
}
return nil
}
// SendMail + Inbucket delivery is async; under CI load the plugin's internal
// RetryInbucket window can expire before the message is visible.
if id == "test_send_mail_plugin" {
require.Eventually(t, func() bool {
return runHook() == nil
}, 90*time.Second, 2*time.Second)
return nil
}
return runHook()
}
// 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(t).DeleteBots(t)
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)
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)
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)
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(t)
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(t)
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)
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)
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(t)
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)
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)
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)
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)
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() + "[email protected]",
Nickname: "Darth Vader1",
Username: "vader" + model.NewId(),
Password: model.NewTestPassword(),
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(t)
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(t, 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)
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(t)
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(t)
// 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)
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(t)
api := th.SetupPluginAPI()
slashCommandMock := &MockSlashCommandProvider{}
RegisterCommandProvider(slashCommandMock)
newUser := th.CreateUser(t)
th.LinkUserToTeam(t, 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(t)
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(t)
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(t)
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(t)
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)
// Updating a command's trigger to one that already exists should fail.
cmd2 := &model.Command{
TeamId: team1.Id,
Trigger: "uniquetrigger",
Method: "G",
URL: "http://test.com/uniquetrigger",
}
cmd2, appErr = api.CreateCommand(cmd2)
require.NoError(t, appErr)
cmd2.Trigger = "anotherNewTriggerAgain"
_, appErr = api.UpdateCommand(cmd2.Id, cmd2)
require.Error(t, appErr)
var appError *model.AppError
require.ErrorAs(t, appErr, &appError)
require.Equal(t, "api.command.duplicate_trigger.app_error", appError.Id)
}
func TestPluginAPIIsEnterpriseReady(t *testing.T) {
oldValue := model.BuildEnterpriseReady
defer func() { model.BuildEnterpriseReady = oldValue }()
model.BuildEnterpriseReady = "true"
th := Setup(t)
api := th.SetupPluginAPI()
assert.Equal(t, true, api.IsEnterpriseReady())
}
func TestPluginUploadsAPI(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
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(t)
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(t)
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(t)
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(t, 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(t, 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)
var prevEnable *bool
var prevAddress *string
th.App.UpdateConfig(func(cfg *model.Config) {
prevEnable = cfg.MetricsSettings.Enable
prevAddress = cfg.MetricsSettings.ListenAddress
cfg.MetricsSettings.Enable = new(true)
cfg.MetricsSettings.ListenAddress = new(":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(t)
user := th.CreateUser(t)
team1 := th.CreateTeam(t)
th.LinkUserToTeam(t, user, team1)
team2 := th.CreateTeam(t)
th.LinkUserToTeam(t, user, team2)
channel1 := th.CreateChannel(t, team1)
th.AddUserToChannel(t, user, channel1)
channel2 := th.CreateChannel(t, team2)
th.AddUserToChannel(t, user, channel2)
dmChannel := th.CreateDmChannel(t, 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(t)
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, channel)
th.AddUserToChannel(t, 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(t)
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, 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(t)
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(t)
api := th.SetupPluginAPI()
// Register a property group first so the version check can look it up
group, err := api.RegisterPropertyGroup("testgroup" + model.NewId())
require.NoError(t, err)
// Create 20 property fields
var createdFields []*model.PropertyField
for i := 1; i <= 20; i++ {
field := &model.PropertyField{
GroupID: group.ID,
Name: fmt.Sprintf("field_%d", i),
Type: model.PropertyFieldTypeText,
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
}
var created *model.PropertyField
created, err = api.CreatePropertyField(field)
require.NoError(t, err)
createdFields = append(createdFields, created)
}
// Delete one field
err = api.DeletePropertyField(group.ID, createdFields[0].ID)
require.NoError(t, err)
// Should now be able to create another field
newField := &model.PropertyField{
GroupID: group.ID,
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(t)
api := th.SetupPluginAPI()
// Register a property group first so the version check can look it up
group, err := api.RegisterPropertyGroup("testgroup" + model.NewId())
require.NoError(t, err)
// Create and delete 5 fields
for i := 1; i <= 5; i++ {
field := &model.PropertyField{
GroupID: group.ID,
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(group.ID, created.ID)
require.NoError(t, err)
}
// Should be able to create multiple active fields
for i := 1; i <= 20; i++ {
field := &model.PropertyField{
GroupID: group.ID,
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(t)
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)
// 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(), "property field 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(t)
api := th.SetupPluginAPI()
group, groupErr := api.RegisterPropertyGroup(model.NewId())
require.NoError(t, groupErr)
// Create 5 fields
var createdFields []*model.PropertyField
for i := 1; i <= 5; i++ {
field := &model.PropertyField{
GroupID: group.ID,
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(group.ID, false)
require.NoError(t, err)
assert.Equal(t, int64(5), count)
// Delete 2 fields
err = api.DeletePropertyField(group.ID, createdFields[0].ID)
require.NoError(t, err)
err = api.DeletePropertyField(group.ID, createdFields[1].ID)
require.NoError(t, err)
// Count should now be 3
count, err = api.CountPropertyFields(group.ID, 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(t)
api := th.SetupPluginAPI()
group, groupErr := api.RegisterPropertyGroup(model.NewId())
require.NoError(t, groupErr)
// Create 5 fields
var createdFields []*model.PropertyField
for i := 1; i <= 5; i++ {
field := &model.PropertyField{
GroupID: group.ID,
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(group.ID, true)
require.NoError(t, err)
assert.Equal(t, int64(5), count)
// Delete 2 fields
err = api.DeletePropertyField(group.ID, createdFields[0].ID)
require.NoError(t, err)
err = api.DeletePropertyField(group.ID, createdFields[1].ID)
require.NoError(t, err)
// Count all should still be 5
count, err = api.CountPropertyFields(group.ID, true)
require.NoError(t, err)
assert.Equal(t, int64(5), count)
// Count active should be 3
count, err = api.CountPropertyFields(group.ID, 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(t)
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)
})
}
func TestPluginAPICreateTeamAnonymousURLs(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
api := th.SetupPluginAPI()
t.Run("should override team name when UseAnonymousURLs is enabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false })
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
originalName := "original-team-name"
team := &model.Team{
DisplayName: "Anonymous URL Team",
Name: originalName,
Type: model.TeamOpen,
}
createdTeam, appErr := api.CreateTeam(team)
require.Nil(t, appErr)
require.NotNil(t, createdTeam)
assert.NotEqual(t, originalName, createdTeam.Name, "team name should be overridden by server")
assert.True(t, model.IsValidId(createdTeam.Name), "team name should be a valid server-generated ID")
assert.Equal(t, "Anonymous URL Team", createdTeam.DisplayName, "display name should remain unchanged")
})
t.Run("should preserve team name when UseAnonymousURLs is disabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false })
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
originalName := "preserved-team-name"
team := &model.Team{
DisplayName: "Normal Team",
Name: originalName,
Type: model.TeamOpen,
}
createdTeam, appErr := api.CreateTeam(team)
require.Nil(t, appErr)
require.NotNil(t, createdTeam)
assert.Equal(t, originalName, createdTeam.Name, "team name should not be overridden")
})
t.Run("should not override team name without Enterprise Advanced license", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false })
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
originalName := "original-team-name"
team := &model.Team{
DisplayName: "Enterprise Team",
Name: originalName,
Type: model.TeamOpen,
}
createdTeam, appErr := api.CreateTeam(team)
require.Nil(t, appErr)
require.NotNil(t, createdTeam)
assert.Equal(t, originalName, createdTeam.Name, "team name should not be overridden")
})
}
func TestPluginAPICreateChannelManagedCategory(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.ConfigStore.SetReadOnlyFF(false)
t.Cleanup(func() {
th.ConfigStore.SetReadOnlyFF(true)
})
api := th.SetupPluginAPI()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.ManagedChannelCategories = true })
defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.ManagedChannelCategories = false })
categoryName := "Operations"
channel := &model.Channel{
DisplayName: "Plugin channel with managed category",
Name: "plugin-managed-cat-" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
ManagedCategoryName: categoryName,
}
createdChannel, appErr := api.CreateChannel(channel)
require.Nil(t, appErr)
require.NotNil(t, createdChannel)
defer func() {
_ = th.App.ClearChannelManagedCategory(th.Context, createdChannel.Id)
}()
th.AddUserToChannel(t, th.BasicUser, createdChannel)
session := &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}}
rctx := th.Context.WithSession(session)
mappings, err := th.App.GetVisibleManagedCategoryMappings(rctx, th.BasicTeam.Id)
require.Nil(t, err)
assert.Equal(t, categoryName, mappings[createdChannel.Id])
}
func TestPluginAPICreateChannelAnonymousURLs(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
api := th.SetupPluginAPI()
t.Run("should override open channel name when UseAnonymousURLs is enabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false })
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
originalName := "original-channel-name"
channel := &model.Channel{
DisplayName: "Anonymous URL Channel",
Name: originalName,
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}
createdChannel, appErr := api.CreateChannel(channel)
require.Nil(t, appErr)
require.NotNil(t, createdChannel)
assert.NotEqual(t, originalName, createdChannel.Name, "open channel name should be overridden")
assert.True(t, model.IsValidId(createdChannel.Name), "channel name should be a valid server-generated ID")
assert.Equal(t, "Anonymous URL Channel", createdChannel.DisplayName, "display name should remain unchanged")
})
t.Run("should override private channel name when UseAnonymousURLs is enabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false })
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
originalName := "private-channel-name"
channel := &model.Channel{
DisplayName: "Anonymous Private Channel",
Name: originalName,
Type: model.ChannelTypePrivate,
TeamId: th.BasicTeam.Id,
}
createdChannel, appErr := api.CreateChannel(channel)
require.Nil(t, appErr)
require.NotNil(t, createdChannel)
assert.NotEqual(t, originalName, createdChannel.Name, "private channel name should be overridden")
assert.True(t, model.IsValidId(createdChannel.Name), "channel name should be a valid server-generated ID")
})
t.Run("should preserve channel name when UseAnonymousURLs is disabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false })
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
originalName := "preserved-channel"
channel := &model.Channel{
DisplayName: "Normal Channel",
Name: originalName,
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}
createdChannel, appErr := api.CreateChannel(channel)
require.Nil(t, appErr)
require.NotNil(t, createdChannel)
assert.Equal(t, originalName, createdChannel.Name, "channel name should not be overridden")
})
t.Run("should not override channel name without Enterprise Advanced license", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = true })
defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PrivacySettings.UseAnonymousURLs = false })
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
originalName := "original-channel-name"
channel := &model.Channel{
DisplayName: "Normal Channel",
Name: originalName,
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}
createdChannel, appErr := api.CreateChannel(channel)
require.Nil(t, appErr)
require.NotNil(t, createdChannel)
assert.Equal(t, originalName, createdChannel.Name, "channel name should not be overridden")
})
}
func TestPluginAPIPropertyGroupDeprecatedName(t *testing.T) {
mainHelper.Parallel(t)
t.Run("RegisterPropertyGroup rejects deprecated name", func(t *testing.T) {
th := Setup(t).InitBasic(t)
api := th.SetupPluginAPI()
// Register using the deprecated name must fail
_, err := api.RegisterPropertyGroup(model.DeprecatedCPAPropertyGroupName)
require.Error(t, err)
assert.Contains(t, err.Error(), "deprecated")
// Register using the canonical name should still work
group, err := api.RegisterPropertyGroup(model.AccessControlPropertyGroupName)
require.NoError(t, err)
require.NotNil(t, group)
assert.Equal(t, model.AccessControlPropertyGroupName, group.Name)
})
t.Run("GetPropertyGroup rejects deprecated name", func(t *testing.T) {
th := Setup(t).InitBasic(t)
api := th.SetupPluginAPI()
// Looking up by the deprecated name must fail with an actionable error
_, err := api.GetPropertyGroup(model.DeprecatedCPAPropertyGroupName)
require.Error(t, err)
assert.Contains(t, err.Error(), "deprecated")
// Looking up by the canonical name should still work
canonical, err := api.GetPropertyGroup(model.AccessControlPropertyGroupName)
require.NoError(t, err)
require.NotNil(t, canonical)
assert.Equal(t, model.AccessControlPropertyGroupName, canonical.Name)
})
t.Run("other group names are not affected by the mapping", func(t *testing.T) {
th := Setup(t).InitBasic(t)
api := th.SetupPluginAPI()
// Register a different group — no mapping should occur
group, err := api.RegisterPropertyGroup("my_plugin_group")
require.NoError(t, err)
require.NotNil(t, group)
assert.Equal(t, "my_plugin_group", group.Name)
// Look it up
fetched, err := api.GetPropertyGroup("my_plugin_group")
require.NoError(t, err)
assert.Equal(t, group.ID, fetched.ID)
})
t.Run("GetPropertyGroup with nonexistent name returns error", func(t *testing.T) {
th := Setup(t).InitBasic(t)
api := th.SetupPluginAPI()
_, err := api.GetPropertyGroup("no_such_group")
require.Error(t, err)
})
}