mattermost-community-enterp.../channels/app/plugin_hooks_test.go
Claude ec1f89217a Merge: Complete Mattermost Server with Community Enterprise
Full Mattermost server source with integrated Community Enterprise features.
Includes vendor directory for offline/air-gapped builds.

Structure:
- enterprise-impl/: Enterprise feature implementations
- enterprise-community/: Init files that register implementations
- enterprise/: Bridge imports (community_imports.go)
- vendor/: All dependencies for offline builds

Build (online):
  go build ./cmd/mattermost

Build (offline/air-gapped):
  go build -mod=vendor ./cmd/mattermost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 23:59:07 +09:00

2350 lines
62 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
_ "embed"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/pkg/errors"
"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/plugintest"
"github.com/mattermost/mattermost/server/public/plugin/utils"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
func SetAppEnvironmentWithPlugins(t *testing.T, pluginCode []string, app *App, apiFunc func(*model.Manifest) plugin.API) (func(), []string, []error) {
return setAppEnvironmentWithPlugins(t, pluginCode, app, apiFunc, "")
}
func SetAppEnvironmentWithPluginsGoVersion(t *testing.T, pluginCode []string, app *App, apiFunc func(*model.Manifest) plugin.API, goVersion string) (func(), []string, []error) {
return setAppEnvironmentWithPlugins(t, pluginCode, app, apiFunc, goVersion)
}
func setAppEnvironmentWithPlugins(t *testing.T, pluginCode []string, app *App, apiFunc func(*model.Manifest) plugin.API, goVersion string) (func(), []string, []error) {
pluginDir, err := os.MkdirTemp("", "")
require.NoError(t, err)
webappPluginDir, err := os.MkdirTemp("", "")
require.NoError(t, err)
env, err := plugin.NewEnvironment(apiFunc, NewDriverImpl(app.Srv()), pluginDir, webappPluginDir, app.Log(), nil)
require.NoError(t, err)
app.ch.SetPluginsEnvironment(env)
pluginIDs := []string{}
activationErrors := []error{}
for _, code := range pluginCode {
pluginID := model.NewId()
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
utils.CompileGoVersion(t, goVersion, code, backend)
err = os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(`{"id": "`+pluginID+`", "server": {"executable": "backend.exe"}}`), 0600)
require.NoError(t, err)
_, _, activationErr := env.Activate(pluginID)
pluginIDs = append(pluginIDs, pluginID)
activationErrors = append(activationErrors, activationErr)
app.UpdateConfig(func(cfg *model.Config) {
cfg.PluginSettings.PluginStates[pluginID] = &model.PluginState{
Enable: true,
}
})
}
return func() {
os.RemoveAll(pluginDir)
os.RemoveAll(webappPluginDir)
}, pluginIDs, activationErrors
}
func TestHookMessageWillBePosted(t *testing.T) {
mainHelper.Parallel(t)
t.Run("rejected", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
return nil, "rejected"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message_",
CreateAt: model.GetMillis() - 10000,
}
_, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
if assert.NotNil(t, err) {
assert.Equal(t, "Post rejected by plugin. rejected", err.Message)
}
})
t.Run("rejected, returned post ignored", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
post.Message = "ignored"
return post, "rejected"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message_",
CreateAt: model.GetMillis() - 10000,
}
_, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
if assert.NotNil(t, err) {
assert.Equal(t, "Post rejected by plugin. rejected", err.Message)
}
})
t.Run("allowed", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
return nil, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
CreateAt: model.GetMillis() - 10000,
}
post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, "message", post.Message)
retrievedPost, errSingle := th.App.Srv().Store().Post().GetSingle(th.Context, post.Id, false)
require.NoError(t, errSingle)
assert.Equal(t, "message", retrievedPost.Message)
})
t.Run("updated", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
post.Message = post.Message + "_fromplugin"
return post, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
CreateAt: model.GetMillis() - 10000,
}
post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, "message_fromplugin", post.Message)
retrievedPost, errSingle := th.App.Srv().Store().Post().GetSingle(th.Context, post.Id, false)
require.NoError(t, errSingle)
assert.Equal(t, "message_fromplugin", retrievedPost.Message)
})
t.Run("multiple updated", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
post.Message = "prefix_" + post.Message
return post, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
post.Message = post.Message + "_suffix"
return post, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
CreateAt: model.GetMillis() - 10000,
}
post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, "prefix_message_suffix", post.Message)
})
}
func TestHookMessageHasBeenPosted(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil)
mockAPI.On("LogDebug", "message").Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
p.API.LogDebug(post.Message)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
CreateAt: model.GetMillis() - 10000,
}
_, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
}
func TestHookMessageWillBeUpdated(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBeUpdated(c *plugin.Context, newPost, oldPost *model.Post) (*model.Post, string) {
newPost.Message = newPost.Message + "fromplugin"
return newPost, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message_",
CreateAt: model.GetMillis() - 10000,
}
post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, "message_", post.Message)
post.Message = post.Message + "edited_"
post, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true})
require.Nil(t, err)
assert.Equal(t, "message_edited_fromplugin", post.Message)
}
func TestHookMessageHasBeenUpdated(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil)
mockAPI.On("LogDebug", "message_edited").Return(nil)
mockAPI.On("LogDebug", "message_").Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageHasBeenUpdated(c *plugin.Context, newPost, oldPost *model.Post) {
p.API.LogDebug(newPost.Message)
p.API.LogDebug(oldPost.Message)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message_",
CreateAt: model.GetMillis() - 10000,
}
post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, "message_", post.Message)
post.Message = post.Message + "edited"
_, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true})
require.Nil(t, err)
}
func TestHookMessageHasBeenDeleted(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil)
mockAPI.On("LogDebug", "message").Return(nil).Times(1)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageHasBeenDeleted(c *plugin.Context, post *model.Post) {
p.API.LogDebug(post.Message)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
CreateAt: model.GetMillis() - 10000,
}
_, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, err = th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id)
require.Nil(t, err)
}
func TestHookFileWillBeUploaded(t *testing.T) {
mainHelper.Parallel(t)
t.Run("rejected", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil)
mockAPI.On("LogDebug", "testhook.txt").Return(nil)
mockAPI.On("LogDebug", "inputfile").Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"io"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
return nil, "rejected"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
_, appErr := th.App.UploadFile(th.Context,
[]byte("inputfile"),
th.BasicChannel.Id,
"testhook.txt",
)
if assert.NotNil(t, appErr) {
assert.Equal(t, "File rejected by plugin. rejected", appErr.Message)
}
})
t.Run("rejected, returned file ignored", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil)
mockAPI.On("LogDebug", "testhook.txt").Return(nil)
mockAPI.On("LogDebug", "inputfile").Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"fmt"
"io"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
n, err := output.Write([]byte("ignored"))
if err != nil {
return info, fmt.Sprintf("FAILED to write output file n: %v, err: %v", n, err)
}
info.Name = "ignored"
return info, "rejected"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
_, appErr := th.App.UploadFile(th.Context,
[]byte("inputfile"),
th.BasicChannel.Id,
"testhook.txt",
)
if assert.NotNil(t, appErr) {
assert.Equal(t, "File rejected by plugin. rejected", appErr.Message)
}
})
t.Run("allowed", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil)
mockAPI.On("LogDebug", "testhook.txt").Return(nil)
mockAPI.On("LogDebug", "inputfile").Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"io"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
return nil, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
response, appErr := th.App.UploadFile(th.Context,
[]byte("inputfile"),
th.BasicChannel.Id,
"testhook.txt",
)
assert.Nil(t, appErr)
assert.NotNil(t, response)
fileID := response.Id
fileInfo, appErr := th.App.GetFileInfo(th.Context, fileID)
assert.Nil(t, appErr)
assert.NotNil(t, fileInfo)
assert.Equal(t, "testhook.txt", fileInfo.Name)
fileReader, appErr := th.App.FileReader(fileInfo.Path)
assert.Nil(t, appErr)
var resultBuf bytes.Buffer
_, err := io.Copy(&resultBuf, fileReader)
require.NoError(t, err)
assert.Equal(t, "inputfile", resultBuf.String())
})
t.Run("updated", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil)
mockAPI.On("LogDebug", "testhook.txt").Return(nil)
mockAPI.On("LogDebug", "inputfile").Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"io"
"fmt"
"bytes"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
var buf bytes.Buffer
n, err := buf.ReadFrom(file)
if err != nil {
panic(fmt.Sprintf("buf.ReadFrom failed, reading %d bytes: %s", err.Error()))
}
outbuf := bytes.NewBufferString("changedtext")
n, err = io.Copy(output, outbuf)
if err != nil {
panic(fmt.Sprintf("io.Copy failed after %d bytes: %s", n, err.Error()))
}
if n != 11 {
panic(fmt.Sprintf("io.Copy only copied %d bytes", n))
}
info.Name = "modifiedinfo"
return info, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
response, appErr := th.App.UploadFile(th.Context,
[]byte("inputfile"),
th.BasicChannel.Id,
"testhook.txt",
)
assert.Nil(t, appErr)
assert.NotNil(t, response)
fileID := response.Id
fileInfo, appErr := th.App.GetFileInfo(th.Context, fileID)
assert.Nil(t, appErr)
assert.NotNil(t, fileInfo)
assert.Equal(t, "modifiedinfo", fileInfo.Name)
fileReader, appErr := th.App.FileReader(fileInfo.Path)
assert.Nil(t, appErr)
var resultBuf bytes.Buffer
_, err := io.Copy(&resultBuf, fileReader)
require.NoError(t, err)
assert.Equal(t, "changedtext", resultBuf.String())
})
}
func TestUserWillLogIn_Blocked(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
err := th.App.UpdatePassword(th.Context, th.BasicUser, "hunter2")
assert.Nil(t, err, "Error updating user password: %s", err)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) UserWillLogIn(c *plugin.Context, user *model.User) string {
return "Blocked By Plugin"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
r := &http.Request{}
w := httptest.NewRecorder()
session, err := th.App.DoLogin(th.Context, w, r, th.BasicUser, "", false, false, false)
assert.Contains(t, err.Id, "Login rejected by plugin", "Expected Login rejected by plugin, got %s", err.Id)
assert.Nil(t, session)
}
func TestUserWillLogInIn_Passed(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
err := th.App.UpdatePassword(th.Context, th.BasicUser, "hunter2")
assert.Nil(t, err, "Error updating user password: %s", err)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) UserWillLogIn(c *plugin.Context, user *model.User) string {
return ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
r := &http.Request{}
w := httptest.NewRecorder()
session, err := th.App.DoLogin(th.Context, w, r, th.BasicUser, "", false, false, false)
assert.Nil(t, err, "Expected nil, got %s", err)
require.NotNil(t, session)
assert.Equal(t, session.UserId, th.BasicUser.Id)
}
func TestUserHasLoggedIn(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
err := th.App.UpdatePassword(th.Context, th.BasicUser, "hunter2")
assert.Nil(t, err, "Error updating user password: %s", err)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) UserHasLoggedIn(c *plugin.Context, user *model.User) {
user.FirstName = "plugin-callback-success"
p.API.UpdateUser(user)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
r := &http.Request{}
w := httptest.NewRecorder()
session, err := th.App.DoLogin(th.Context, w, r, th.BasicUser, "", false, false, false)
assert.Nil(t, err, "Expected nil, got %s", err)
assert.NotNil(t, session)
require.EventuallyWithT(t, func(c *assert.CollectT) {
user, _ := th.App.GetUser(th.BasicUser.Id)
assert.Equal(c, user.FirstName, "plugin-callback-success", "Expected firstname overwrite, got default")
}, 2*time.Second, 100*time.Millisecond)
}
func TestUserHasBeenDeactivated(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) UserHasBeenDeactivated(c *plugin.Context, user *model.User) {
user.Nickname = "plugin-callback-success"
p.API.UpdateUser(user)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
user := &model.User{
Email: "success+test@example.com",
Nickname: "testnickname",
Username: "testusername",
Password: "testpassword",
}
_, err := th.App.CreateUser(th.Context, user)
require.Nil(t, err)
_, err = th.App.UpdateActive(th.Context, user, false)
require.Nil(t, err)
time.Sleep(2 * time.Second)
user, err = th.App.GetUser(user.Id)
require.Nil(t, err)
require.Equal(t, "plugin-callback-success", user.Nickname)
}
func TestUserHasBeenCreated(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
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{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
user := &model.User{
Email: "success+test@example.com",
Nickname: "testnickname",
Username: "testusername",
Password: "testpassword",
}
_, err := th.App.CreateUser(th.Context, user)
require.Nil(t, err)
time.Sleep(2 * time.Second)
user, err = th.App.GetUser(user.Id)
require.Nil(t, err)
require.Equal(t, "plugin-callback-success", user.Nickname)
}
func TestErrorString(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
t.Run("errors.New", func(t *testing.T) {
tearDown, _, activationErrors := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"errors"
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) OnActivate() error {
return errors.New("simulate failure")
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
require.Len(t, activationErrors, 1)
require.Error(t, activationErrors[0])
require.Contains(t, activationErrors[0].Error(), "simulate failure")
})
t.Run("AppError", func(t *testing.T) {
tearDown, _, activationErrors := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) OnActivate() error {
return model.NewAppError("where", "id", map[string]any{"param": 1}, "details", 42)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
require.Len(t, activationErrors, 1)
require.Error(t, activationErrors[0])
cause := errors.Cause(activationErrors[0])
require.IsType(t, &model.AppError{}, cause)
// params not expected, since not exported
expectedErr := model.NewAppError("where", "id", nil, "details", 42)
require.Equal(t, expectedErr, cause)
})
}
func TestHookContext(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
ctx := request.EmptyContext(th.TestLogger)
// We don't actually have a session, we are faking it so just set something arbitrarily
ctx.Session().Id = model.NewId()
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil)
mockAPI.On("LogDebug", ctx.Session().Id).Return(nil)
mockAPI.On("LogInfo", ctx.RequestId()).Return(nil)
mockAPI.On("LogError", ctx.IPAddress()).Return(nil)
mockAPI.On("LogWarn", ctx.AcceptLanguage()).Return(nil)
mockAPI.On("DeleteTeam", ctx.UserAgent()).Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
p.API.LogDebug(c.SessionId)
p.API.LogInfo(c.RequestId)
p.API.LogError(c.IPAddress)
p.API.LogWarn(c.AcceptLanguage)
p.API.DeleteTeam(c.UserAgent)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "not this",
CreateAt: model.GetMillis() - 10000,
}
_, err := th.App.CreatePost(ctx, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
}
func TestActiveHooks(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
t.Run("", func(t *testing.T) {
tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
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) OnActivate() error {
return nil
}
func (p *MyPlugin) OnConfigurationChange() error {
return nil
}
func (p *MyPlugin) UserHasBeenCreated(c *plugin.Context, user *model.User) {
user.Nickname = "plugin-callback-success"
p.API.UpdateUser(user)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
require.Len(t, pluginIDs, 1)
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
user1 := &model.User{
Email: "success+test@example.com",
Nickname: "testnickname",
Username: "testusername",
Password: "testpassword",
}
_, appErr := th.App.CreateUser(th.Context, user1)
require.Nil(t, appErr)
time.Sleep(2 * 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))
hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin(pluginID)
require.Error(t, err)
require.Nil(t, hooks)
// Should fail to find pluginID as it was deleted when deactivated
path, err := th.App.GetPluginsEnvironment().PublicFilesPath(pluginID)
require.Error(t, err)
require.Empty(t, path)
})
}
func TestHookMetrics(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
t.Run("", func(t *testing.T) {
metricsMock := &mocks.MetricsInterface{}
pluginDir, err := os.MkdirTemp("", "")
require.NoError(t, err)
webappPluginDir, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer os.RemoveAll(pluginDir)
defer os.RemoveAll(webappPluginDir)
env, err := plugin.NewEnvironment(th.NewPluginAPI, NewDriverImpl(th.Server), pluginDir, webappPluginDir, th.App.Log(), metricsMock)
require.NoError(t, err)
th.App.ch.SetPluginsEnvironment(env)
pluginID := model.NewId()
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
code := `
package main
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) OnActivate() error {
return nil
}
func (p *MyPlugin) OnConfigurationChange() error {
return nil
}
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)
// Setup mocks before activating
metricsMock.On("ObservePluginHookDuration", pluginID, "Implemented", true, mock.Anything).Return()
metricsMock.On("ObservePluginHookDuration", pluginID, "OnActivate", true, mock.Anything).Return()
metricsMock.On("ObservePluginHookDuration", pluginID, "OnDeactivate", true, mock.Anything).Return()
metricsMock.On("ObservePluginHookDuration", pluginID, "OnConfigurationChange", true, mock.Anything).Return()
metricsMock.On("ObservePluginHookDuration", pluginID, "UserHasBeenCreated", true, mock.Anything).Return()
// Don't care about these calls.
metricsMock.On("ObservePluginAPIDuration", 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()
_, _, activationErr := env.Activate(pluginID)
require.NoError(t, activationErr)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.PluginSettings.PluginStates[pluginID] = &model.PluginState{
Enable: true,
}
})
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
user1 := &model.User{
Email: "success+test@example.com",
Nickname: "testnickname",
Username: "testusername",
Password: "testpassword",
AuthService: "",
}
_, appErr := th.App.CreateUser(th.Context, user1)
require.Nil(t, appErr)
time.Sleep(2 * 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 TestHookReactionHasBeenAdded(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
var mockAPI plugintest.API
mockAPI.On("LogDebug", "smile").Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) ReactionHasBeenAdded(c *plugin.Context, reaction *model.Reaction) {
p.API.LogDebug(reaction.EmojiName)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
reaction := &model.Reaction{
UserId: th.BasicUser.Id,
PostId: th.BasicPost.Id,
EmojiName: "smile",
CreateAt: model.GetMillis() - 10000,
}
_, err := th.App.SaveReactionForPost(th.Context, reaction)
require.Nil(t, err)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
mockAPI.AssertExpectations(&testutils.CollectTWithLogf{CollectT: c})
}, 5*time.Second, 100*time.Millisecond)
}
func TestHookReactionHasBeenRemoved(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
var mockAPI plugintest.API
mockAPI.On("LogDebug", "star").Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) ReactionHasBeenRemoved(c *plugin.Context, reaction *model.Reaction) {
p.API.LogDebug(reaction.EmojiName)
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
reaction := &model.Reaction{
UserId: th.BasicUser.Id,
PostId: th.BasicPost.Id,
EmojiName: "star",
CreateAt: model.GetMillis() - 10000,
}
err := th.App.DeleteReactionForPost(th.Context, reaction)
require.Nil(t, err)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
mockAPI.AssertExpectations(&testutils.CollectTWithLogf{CollectT: c})
}, 5*time.Second, 100*time.Millisecond)
}
func TestHookRunDataRetention(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) RunDataRetention(nowMillis, batchSize int64) (int64, error){
return 100, nil
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
require.Len(t, pluginIDs, 1)
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
hookCalled := false
th.App.Channels().RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
n, _ := hooks.RunDataRetention(0, 0)
// Ensure return it correct
assert.Equal(t, int64(100), n)
hookCalled = true
return hookCalled
}, plugin.RunDataRetentionID)
require.True(t, hookCalled)
}
func TestHookOnSendDailyTelemetry(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) OnSendDailyTelemetry() {
return
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
require.Len(t, pluginIDs, 1)
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
hookCalled := false
th.App.Channels().RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
hooks.OnSendDailyTelemetry()
hookCalled = true
return hookCalled
}, plugin.OnSendDailyTelemetryID)
require.True(t, hookCalled)
}
func TestHookOnCloudLimitsUpdated(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t,
[]string{
`
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) OnCloudLimitsUpdated(_ *model.ProductLimits) {
return
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, th.NewPluginAPI)
defer tearDown()
require.Len(t, pluginIDs, 1)
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
hookCalled := false
th.App.Channels().RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
hooks.OnCloudLimitsUpdated(nil)
hookCalled = true
return hookCalled
}, plugin.OnCloudLimitsUpdatedID)
require.True(t, hookCalled)
}
//go:embed test_templates/hook_notification_will_be_pushed.tmpl
var hookNotificationWillBePushedTmpl string
func TestHookNotificationWillBePushed(t *testing.T) {
mainHelper.Parallel(t)
if testing.Short() {
t.Skip("skipping TestHookNotificationWillBePushed test in short mode")
}
tests := []struct {
name string
testCode string
expectedNotifications int
expectedNotificationMessage string
}{
{
name: "successfully pushed",
testCode: `return nil, ""`,
expectedNotifications: 6,
},
{
name: "push notification rejected",
testCode: `return nil, "rejected"`,
expectedNotifications: 0,
},
{
name: "push notification modified",
testCode: `notification.Message = "brand new message"
return notification, ""`,
expectedNotifications: 6,
expectedNotificationMessage: "brand new message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
templatedPlugin := fmt.Sprintf(hookNotificationWillBePushedTmpl, tt.testCode)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{templatedPlugin}, th.App, th.NewPluginAPI)
defer tearDown()
// Create 3 users, each having 2 sessions.
type userSession struct {
user *model.User
session *model.Session
}
var userSessions []userSession
for range 3 {
u := th.CreateUser()
sess, err := th.App.CreateSession(th.Context, &model.Session{
UserId: u.Id,
DeviceId: "deviceID" + u.Id,
ExpiresAt: model.GetMillis() + 100000,
})
require.Nil(t, err)
// We don't need to track the 2nd session.
_, err = th.App.CreateSession(th.Context, &model.Session{
UserId: u.Id,
DeviceId: "deviceID" + u.Id,
ExpiresAt: model.GetMillis() + 100000,
})
require.Nil(t, err)
_, err = th.App.AddTeamMember(th.Context, th.BasicTeam.Id, u.Id)
require.Nil(t, err)
th.AddUserToChannel(u, th.BasicChannel)
userSessions = append(userSessions, userSession{
user: u,
session: sess,
})
}
handler := &testPushNotificationHandler{
t: t,
behavior: "simple",
}
pushServer := httptest.NewServer(
http.HandlerFunc(handler.handleReq),
)
defer pushServer.Close()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.PushNotificationContents = model.GenericNotification
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
})
var wg sync.WaitGroup
for _, data := range userSessions {
wg.Add(1)
go func(user model.User) {
defer wg.Done()
notification := &PostNotification{
Post: th.CreatePost(th.BasicChannel),
Channel: th.BasicChannel,
ProfileMap: map[string]*model.User{
user.Id: &user,
},
Sender: &user,
}
th.App.sendPushNotification(notification, &user, true, false, model.CommentsNotifyAny)
}(*data.user)
}
wg.Wait()
// Hack to let the worker goroutines complete.
time.Sleep(2 * time.Second)
// Server side verification.
assert.Equal(t, tt.expectedNotifications, handler.numReqs())
var numMessages int
for _, n := range handler.notifications() {
switch n.Type {
case model.PushTypeMessage:
numMessages++
assert.Equal(t, th.BasicChannel.Id, n.ChannelId)
if tt.expectedNotificationMessage != "" {
assert.Equal(t, tt.expectedNotificationMessage, n.Message)
} else {
assert.Contains(t, n.Message, "mentioned you")
}
default:
assert.Fail(t, "should not receive any other push notification types")
}
}
assert.Equal(t, tt.expectedNotifications, numMessages)
})
}
}
//go:embed test_templates/hook_email_notification_will_be_sent.tmpl
var hookEmailNotificationWillBeSentTmpl string
func TestHookEmailNotificationWillBeSent(t *testing.T) {
mainHelper.Parallel(t)
tests := []struct {
name string
testCode string
expectedNotificationSubject string
expectedNotificationTitle string
expectedButtonText string
expectedFooterText string
}{
{
name: "successfully sent",
testCode: `return nil, ""`,
},
{
name: "email notification rejected",
testCode: `return nil, "rejected"`,
},
{
name: "email notification modified",
testCode: `content := &model.EmailNotificationContent{
Subject: "Modified Subject by Plugin",
Title: "Modified Title by Plugin",
ButtonText: "Modified Button by Plugin",
FooterText: "Modified Footer by Plugin",
}
return content, ""`,
expectedNotificationSubject: "Modified Subject by Plugin",
expectedNotificationTitle: "Modified Title by Plugin",
expectedButtonText: "Modified Button by Plugin",
expectedFooterText: "Modified Footer by Plugin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Create a test user for email notifications
user := th.CreateUser()
th.LinkUserToTeam(user, th.BasicTeam)
th.AddUserToChannel(user, th.BasicChannel)
// Set up email notification preferences to disable batching
appErr := th.App.UpdatePreferences(th.Context, user.Id, model.Preferences{
{
UserId: user.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: model.PreferenceEmailIntervalNoBatchingSeconds,
},
})
require.Nil(t, appErr)
// Disable email batching in config
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.EnableEmailBatching = false
})
// Create and set up plugin
templatedPlugin := fmt.Sprintf(hookEmailNotificationWillBeSentTmpl, tt.testCode)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{templatedPlugin}, th.App, th.NewPluginAPI)
defer tearDown()
// For the modification test, create a simple test that verifies the hook is called
// The detailed verification would require more complex mocking which is beyond this test's scope
// Create a post that will trigger email notification
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "@" + user.Username + " test message",
CreateAt: model.GetMillis(),
}
// Create notification
notification := &PostNotification{
Post: post,
Channel: th.BasicChannel,
ProfileMap: map[string]*model.User{
user.Id: user,
},
Sender: th.BasicUser,
}
// Send email notification (this will trigger the hook)
// Use assert.Eventually to handle any potential race conditions with plugin activation/deactivation
assert.Eventually(t, func() bool {
modifiedNotification, err := th.App.sendNotificationEmail(th.Context, notification, user, th.BasicTeam, nil)
// For the rejected test case, we expect the notification to be rejected
if tt.name == "email notification rejected" {
// When rejected, sendNotificationEmail returns nil for the notification
return modifiedNotification == nil && err == nil
}
if err != nil || modifiedNotification == nil {
return false
}
// Verify the modified notification fields
if tt.expectedNotificationSubject != "" && modifiedNotification.Subject != tt.expectedNotificationSubject {
return false
}
if tt.expectedNotificationTitle != "" && modifiedNotification.Title != tt.expectedNotificationTitle {
return false
}
if tt.expectedButtonText != "" && modifiedNotification.ButtonText != tt.expectedButtonText {
return false
}
if tt.expectedFooterText != "" && modifiedNotification.FooterText != tt.expectedFooterText {
return false
}
return true
}, 2*time.Second, 100*time.Millisecond)
})
}
}
func TestHookMessagesWillBeConsumed(t *testing.T) {
mainHelper.Parallel(t)
setupPlugin := func(t *testing.T, th *TestHelper) {
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil)
mockAPI.On("LogDebug", "message").Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessagesWillBeConsumed(posts []*model.Post) []*model.Post {
for _, post := range posts {
post.Message = "mwbc_plugin:" + post.Message
}
return posts
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
t.Cleanup(tearDown)
}
t.Run("feature flag disabled", func(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.ConsumePostHook = false
}).InitBasic()
t.Cleanup(th.TearDown)
setupPlugin(t, th)
newPost := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
CreateAt: model.GetMillis() - 10000,
}
_, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
post, err := th.App.GetSinglePost(th.Context, newPost.Id, true)
require.Nil(t, err)
assert.Equal(t, "message", post.Message)
})
t.Run("feature flag enabled", func(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.ConsumePostHook = true
}).InitBasic()
t.Cleanup(th.TearDown)
setupPlugin(t, th)
newPost := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
CreateAt: model.GetMillis() - 10000,
}
_, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
post, err := th.App.GetSinglePost(th.Context, newPost.Id, true)
require.Nil(t, err)
assert.Equal(t, "mwbc_plugin:message", post.Message)
})
}
func TestHookPreferencesHaveChanged(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should be called when preferences are changed by non-plugin code", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Setup plugin
var mockAPI plugintest.API
tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{`
package main
import (
"fmt"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) PreferencesHaveChanged(c *plugin.Context, preferences []model.Preference) {
for _, preference := range preferences {
p.API.LogDebug(fmt.Sprintf("category=%s name=%s value=%s", preference.Category, preference.Name, preference.Value))
}
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
// Confirm plugin is actually running
require.Len(t, pluginIDs, 1)
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
// Setup test
preferences := model.Preferences{
{
UserId: th.BasicUser.Id,
Category: "test_category",
Name: "test_name_1",
Value: "test_value_1",
},
{
UserId: th.BasicUser.Id,
Category: "test_category",
Name: "test_name_2",
Value: "test_value_2",
},
}
mockAPI.On("LogDebug", "category=test_category name=test_name_1 value=test_value_1")
mockAPI.On("LogDebug", "category=test_category name=test_name_2 value=test_value_2")
// Run test
err := th.App.UpdatePreferences(th.Context, th.BasicUser.Id, preferences)
require.Nil(t, err)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
mockAPI.AssertExpectations(&testutils.CollectTWithLogf{CollectT: c})
}, 5*time.Second, 100*time.Millisecond)
})
t.Run("should be called when preferences are changed by plugin code", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Setup plugin
pluginCode := `
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
const (
userID = "` + th.BasicUser.Id + `"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) PreferencesHaveChanged(c *plugin.Context, preferences []model.Preference) {
// Note that plugin hooks can trigger themselves, and this test sets a preference to trigger that
// it has run, so be careful not to introduce an infinite loop here
if len(preferences) == 1 && preferences[0].Category == "test_category" && preferences[0].Name == "test_name" {
if preferences[0].Value == "test_value_first" {
appErr := p.API.UpdatePreferencesForUser(userID, []model.Preference{
{
UserId: userID,
Category: "test_category",
Name: "test_name",
Value: "test_value_second",
},
})
if appErr != nil {
panic("error setting preference to second value")
}
} else if preferences[0].Value == "test_value_second" {
appErr := p.API.UpdatePreferencesForUser(userID, []model.Preference{
{
UserId: userID,
Category: "test_category",
Name: "test_name",
Value: "test_value_third",
},
})
if appErr != nil {
panic("error setting preference to third value")
}
}
}
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`
pluginID := "testplugin"
pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}`
setupPluginAPITest(t, pluginCode, pluginManifest, pluginID, th.App, th.Context)
// Confirm plugin is actually running
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
appErr := th.App.UpdatePreferences(th.Context, th.BasicUser.Id, model.Preferences{
{
UserId: th.BasicUser.Id,
Category: "test_category",
Name: "test_name",
Value: "test_value_first",
},
})
require.Nil(t, appErr)
assert.EventuallyWithT(t, func(t *assert.CollectT) {
preference, appErr := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, "test_category", "test_name")
require.Nil(t, appErr)
assert.Equal(t, "test_value_third", preference.Value)
}, 5*time.Second, 100*time.Millisecond)
})
}
func TestChannelHasBeenCreated(t *testing.T) {
mainHelper.Parallel(t)
getPluginCode := func(th *TestHelper) string {
return `
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
const (
adminUserID = "` + th.SystemAdminUser.Id + `"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) ChannelHasBeenCreated(c *plugin.Context, channel *model.Channel) {
_, appErr := p.API.CreatePost(&model.Post{
UserId: adminUserID,
ChannelId: channel.Id,
Message: "ChannelHasBeenCreated has been called for " + channel.Id,
})
if appErr != nil {
panic(appErr)
}
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`
}
pluginID := "testplugin"
pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}`
t.Run("should call hook when a regular channel is created", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Setup plugin
setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context)
user1 := th.CreateUser()
channel, appErr := th.App.CreateChannel(th.Context, &model.Channel{
CreatorId: user1.Id,
TeamId: th.BasicTeam.Id,
Name: "test_channel",
Type: model.ChannelTypeOpen,
}, false)
require.Nil(t, appErr)
require.NotNil(t, channel)
assert.EventuallyWithT(t, func(t *assert.CollectT) {
posts, appErr := th.App.GetPosts(th.Context, channel.Id, 0, 1)
require.Nil(t, appErr)
if assert.NotEmpty(t, posts.Order) {
post := posts.Posts[posts.Order[0]]
assert.Equal(t, channel.Id, post.ChannelId)
assert.Equal(t, "ChannelHasBeenCreated has been called for "+channel.Id, post.Message)
}
}, 5*time.Second, 100*time.Millisecond)
})
t.Run("should call hook when a DM is created", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Setup plugin
setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context)
user1 := th.CreateUser()
user2 := th.CreateUser()
channel, appErr := th.App.GetOrCreateDirectChannel(th.Context, user1.Id, user2.Id)
require.Nil(t, appErr)
require.NotNil(t, channel)
assert.EventuallyWithT(t, func(t *assert.CollectT) {
posts, appErr := th.App.GetPosts(th.Context, channel.Id, 0, 1)
require.Nil(t, appErr)
if assert.NotEmpty(t, posts.Order) {
post := posts.Posts[posts.Order[0]]
assert.Equal(t, channel.Id, post.ChannelId)
assert.Equal(t, "ChannelHasBeenCreated has been called for "+channel.Id, post.Message)
}
}, 5*time.Second, 100*time.Millisecond)
})
t.Run("should call hook when a GM is created", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Setup plugin
setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context)
user1 := th.CreateUser()
user2 := th.CreateUser()
user3 := th.CreateUser()
channel, appErr := th.App.CreateGroupChannel(th.Context, []string{user1.Id, user2.Id, user3.Id}, user1.Id)
require.Nil(t, appErr)
require.NotNil(t, channel)
assert.EventuallyWithT(t, func(t *assert.CollectT) {
posts, appErr := th.App.GetPosts(th.Context, channel.Id, 0, 1)
require.Nil(t, appErr)
if assert.NotEmpty(t, posts.Order) {
post := posts.Posts[posts.Order[0]]
assert.Equal(t, channel.Id, post.ChannelId)
assert.Equal(t, "ChannelHasBeenCreated has been called for "+channel.Id, post.Message)
}
}, 5*time.Second, 100*time.Millisecond)
})
}
func TestUserHasJoinedChannel(t *testing.T) {
mainHelper.Parallel(t)
getPluginCode := func(th *TestHelper) string {
return `
package main
import (
"fmt"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
const (
adminUserID = "` + th.SystemAdminUser.Id + `"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) UserHasJoinedChannel(c *plugin.Context, channelMember *model.ChannelMember, actor *model.User) {
message := fmt.Sprintf("Test: User %s joined %s", channelMember.UserId, channelMember.ChannelId)
if actor != nil && actor.Id != channelMember.UserId {
message = fmt.Sprintf("Test: User %s added to %s by %s", channelMember.UserId, channelMember.ChannelId, actor.Id)
}
_, appErr := p.API.CreatePost(&model.Post{
UserId: adminUserID,
ChannelId: channelMember.ChannelId,
Message: message,
})
if appErr != nil {
panic(appErr)
}
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`
}
pluginID := "testplugin"
pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}`
t.Run("should call hook when a user joins an existing channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
user1 := th.CreateUser()
th.LinkUserToTeam(user1, th.BasicTeam)
user2 := th.CreateUser()
th.LinkUserToTeam(user2, th.BasicTeam)
channel, appErr := th.App.CreateChannel(th.Context, &model.Channel{
CreatorId: user1.Id,
TeamId: th.BasicTeam.Id,
Name: "test_channel",
Type: model.ChannelTypeOpen,
}, false)
require.Nil(t, appErr)
require.NotNil(t, channel)
// Setup plugin after creating the channel
setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context)
_, appErr = th.App.AddChannelMember(th.Context, user2.Id, channel, ChannelMemberOpts{
UserRequestorID: user2.Id,
})
require.Nil(t, appErr)
assert.EventuallyWithT(t, func(t *assert.CollectT) {
posts, appErr := th.App.GetPosts(th.Context, channel.Id, 0, 30)
require.Nil(t, appErr)
assert.True(t, len(posts.Order) > 0)
found := false
for _, post := range posts.Posts {
if post.Message == fmt.Sprintf("Test: User %s joined %s", user2.Id, channel.Id) {
found = true
}
}
if !found {
assert.Fail(t, "Couldn't find user joined channel hook message post")
}
}, 5*time.Second, 100*time.Millisecond)
})
t.Run("should call hook when a user is added to an existing channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
user1 := th.CreateUser()
th.LinkUserToTeam(user1, th.BasicTeam)
user2 := th.CreateUser()
th.LinkUserToTeam(user2, th.BasicTeam)
channel, appErr := th.App.CreateChannel(th.Context, &model.Channel{
CreatorId: user1.Id,
TeamId: th.BasicTeam.Id,
Name: "test_channel",
Type: model.ChannelTypeOpen,
}, false)
require.Nil(t, appErr)
require.NotNil(t, channel)
// Setup plugin after creating the channel
setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context)
_, appErr = th.App.AddChannelMember(th.Context, user2.Id, channel, ChannelMemberOpts{
UserRequestorID: user1.Id,
})
require.Nil(t, appErr)
expectedMessage := fmt.Sprintf("Test: User %s added to %s by %s", user2.Id, channel.Id, user1.Id)
assert.Eventually(t, func() bool {
// Typically, the post we're looking for will be the latest, but there's a race between the plugin and
// "User has joined the channel" post which means the plugin post may not the the latest one
posts, appErr := th.App.GetPosts(th.Context, channel.Id, 0, 10)
require.Nil(t, appErr)
for _, postId := range posts.Order {
post := posts.Posts[postId]
if post.Message == expectedMessage {
return true
}
}
return false
}, 5*time.Second, 100*time.Millisecond)
})
t.Run("should not call hook when a regular channel is created", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Setup plugin
setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context)
user1 := th.CreateUser()
channel, appErr := th.App.CreateChannel(th.Context, &model.Channel{
CreatorId: user1.Id,
TeamId: th.BasicTeam.Id,
Name: "test_channel",
Type: model.ChannelTypeOpen,
}, false)
require.Nil(t, appErr)
require.NotNil(t, channel)
var posts *model.PostList
require.EventuallyWithT(t, func(c *assert.CollectT) {
posts, appErr = th.App.GetPosts(th.Context, channel.Id, 0, 10)
assert.Nil(t, appErr)
}, 2*time.Second, 100*time.Millisecond)
for _, postID := range posts.Order {
post := posts.Posts[postID]
if strings.HasPrefix(post.Message, "Test: ") {
t.Log("Plugin message found:", post.Message)
t.FailNow()
}
}
})
t.Run("should not call hook when a DM is created", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Setup plugin
setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context)
user1 := th.CreateUser()
user2 := th.CreateUser()
channel, appErr := th.App.GetOrCreateDirectChannel(th.Context, user1.Id, user2.Id)
require.Nil(t, appErr)
require.NotNil(t, channel)
var posts *model.PostList
require.EventuallyWithT(t, func(c *assert.CollectT) {
posts, appErr = th.App.GetPosts(th.Context, channel.Id, 0, 10)
assert.Nil(t, appErr)
}, 2*time.Second, 100*time.Millisecond)
for _, postID := range posts.Order {
post := posts.Posts[postID]
if strings.HasPrefix(post.Message, "Test: ") {
t.Log("Plugin message found:", post.Message)
t.FailNow()
}
}
})
t.Run("should not call hook when a GM is created", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Setup plugin
setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context)
user1 := th.CreateUser()
user2 := th.CreateUser()
user3 := th.CreateUser()
channel, appErr := th.App.CreateGroupChannel(th.Context, []string{user1.Id, user2.Id, user3.Id}, user1.Id)
require.Nil(t, appErr)
require.NotNil(t, channel)
var posts *model.PostList
require.EventuallyWithT(t, func(c *assert.CollectT) {
posts, appErr = th.App.GetPosts(th.Context, channel.Id, 0, 10)
assert.Nil(t, appErr)
}, 2*time.Second, 100*time.Millisecond)
for _, postID := range posts.Order {
post := posts.Posts[postID]
if strings.HasPrefix(post.Message, "Test: ") {
t.Log("Plugin message found:", post.Message)
t.FailNow()
}
}
})
}