Moderate severityNVD Advisory· Published Aug 23, 2024· Updated Aug 23, 2024
Excessive Resource Consumption via `/export`
CVE-2024-43105
Description
Mattermost Plugin Channel Export versions <=1.0.0 fail to restrict concurrent runs of the /export command which allows a user to consume excessive resource by running the /export command multiple times at once.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/mattermost/mattermost-plugin-channel-exportGo | < 1.0.1 | 1.0.1 |
Affected products
1- Range: 0
Patches
1bb6da1f6beddMM-59032 Disallow concurrent exports (#43)
18 files changed · +488 −67
.github/workflows/ci.yml+3 −2 modified@@ -1,7 +1,5 @@ name: ci on: - schedule: - - cron: "0 0 * * *" push: branches: - master @@ -15,4 +13,7 @@ permissions: jobs: plugin-ci: uses: mattermost/actions-workflows/.github/workflows/plugin-ci.yml@main + with: + golangci-lint-version: "v1.54.2" + golang-version: "1.21" secrets: inherit
go.mod+2 −1 modified@@ -1,6 +1,6 @@ module github.com/mattermost/mattermost-plugin-channel-export -go 1.14 +go 1.16 require ( github.com/golang/mock v1.4.4 @@ -10,4 +10,5 @@ require ( github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.8.1 github.com/stretchr/testify v1.7.0 + github.com/wiggin77/merror v1.0.3 )
Makefile+15 −0 modified@@ -213,6 +213,21 @@ else endif endif +## Generate mocks +mock: +ifneq ($(HAS_SERVER),) + go install github.com/golang/mock/mockgen + mockgen -destination server/pluginapi/mock_pluginapi/channel.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Channel + mockgen -destination server/pluginapi/mock_pluginapi/file.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi File + mockgen -destination server/pluginapi/mock_pluginapi/log.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Log + mockgen -destination server/pluginapi/mock_pluginapi/post.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Post + mockgen -destination server/pluginapi/mock_pluginapi/slashcommand.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi SlashCommand + mockgen -destination server/pluginapi/mock_pluginapi/user.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi User + mockgen -destination server/pluginapi/mock_pluginapi/system.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi System + mockgen -destination server/pluginapi/mock_pluginapi/configuration.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Configuration + mockgen -destination server/pluginapi/mock_pluginapi/cluster.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Cluster +endif + ## Disable the plugin. .PHONY: disable disable: detach
server/api.go+26 −1 modified@@ -1,9 +1,11 @@ package main import ( + "context" "encoding/json" "fmt" "net/http" + "time" "github.com/gorilla/mux" "github.com/sirupsen/logrus" @@ -12,22 +14,34 @@ import ( "github.com/mattermost/mattermost-server/v6/model" ) +const ( + KeyClusterMutex = "mutex_exporter" +) + // Handler encapsulates the context necessary for the channel export API. type Handler struct { client *pluginapi.Wrapper makePostsIterator func(*model.Channel, bool) PostIterator + clusterMutex pluginapi.ClusterMutex } // registerAPI registers the API against the given router. -func registerAPI(router *mux.Router, client *pluginapi.Wrapper, makePostsIterator func(*model.Channel, bool) PostIterator) { +func registerAPI(router *mux.Router, client *pluginapi.Wrapper, makePostsIterator func(*model.Channel, bool) PostIterator) error { + clusterMutex, err := client.Cluster.NewMutex(KeyClusterMutex) + if err != nil { + return fmt.Errorf("cannot create cluster mutex: %w", err) + } + handler := &Handler{ client: client, makePostsIterator: makePostsIterator, + clusterMutex: clusterMutex, } api := router.PathPrefix("/api/v1").Subrouter() api.Use(mattermostAuthorizationRequired) api.HandleFunc("/export", handler.Export) + return nil } // APIError is a type of error returned by the API. @@ -88,6 +102,17 @@ func (h *Handler) hasPermissionToChannel(userID, channelID string) (*model.Chann // Export handles /api/v1/export, exporting the requested channel. func (h *Handler) Export(w http.ResponseWriter, r *http.Request) { + // only allow one export at a time + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100) + defer cancel() + if err := h.clusterMutex.LockWithContext(ctx); err != nil { + handleError(w, http.StatusServiceUnavailable, "a channel export is already running.") + return + } + defer func() { + h.clusterMutex.Unlock() + }() + license := h.client.System.GetLicense() if !isLicensed(license, h.client) { handleError(w, http.StatusBadRequest, "the channel export plugin requires a valid E20 license.")
server/api_test.go+123 −16 modified@@ -2,24 +2,29 @@ package main import ( "bytes" + "io" "io/ioutil" "net/http" "net/http/httptest" + "sync" "testing" "time" "github.com/golang/mock/gomock" "github.com/gorilla/mux" - "github.com/mattermost/mattermost-server/v6/model" "github.com/stretchr/testify/require" + "github.com/wiggin77/merror" + + "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi" "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi/mock_pluginapi" ) -func setupAPI(t *testing.T, mockAPI *pluginapi.Wrapper, now time.Time, userID, channelID string) string { +func setupAPI(t *testing.T, mockAPI *pluginapi.Wrapper, now time.Time, userID, _ /*channelID*/ string) string { router := mux.NewRouter() - registerAPI(router, mockAPI, makeTestPostsIterator(t, now)) + err := registerAPI(router, mockAPI, makeTestPostsIterator(t, now)) + require.NoError(t, err) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Simulate what the Mattermost server would normally do after validating a token. @@ -41,10 +46,25 @@ func TestHandler(t *testing.T) { falseValue := false t.Run("unauthorized", func(t *testing.T) { - address := setupAPI(t, nil, time.Now(), "", "channel_id") + mockCtrl := gomock.NewController(t) + + mockChannel := mock_pluginapi.NewMockChannel(mockCtrl) + mockFile := mock_pluginapi.NewMockFile(mockCtrl) + mockLog := mock_pluginapi.NewMockLog(mockCtrl) + mockPost := mock_pluginapi.NewMockPost(mockCtrl) + mockSlashCommand := mock_pluginapi.NewMockSlashCommand(mockCtrl) + mockUser := mock_pluginapi.NewMockUser(mockCtrl) + mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) + mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) + + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) + + address := setupAPI(t, mockAPI, time.Now(), "", "channel_id") client := NewClient(address) - err := client.ExportChannel(ioutil.Discard, "channel_id", FormatCSV) + err := client.ExportChannel(io.Discard, "channel_id", FormatCSV) require.EqualError(t, err, "failed with status code 401") }) @@ -59,8 +79,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) address := setupAPI(t, mockAPI, time.Now(), "user_id", "channel_id") client := NewClient(address) @@ -74,7 +96,7 @@ func TestHandler(t *testing.T) { }, }).Times(1) - err := client.ExportChannel(ioutil.Discard, "channel_id", FormatCSV) + err := client.ExportChannel(io.Discard, "channel_id", FormatCSV) require.EqualError(t, err, "the channel export plugin requires a valid E20 license.") }) @@ -89,8 +111,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)) userID := "user_id" @@ -137,8 +161,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) address := setupAPI(t, mockAPI, time.Now(), "user_id", "channel_id") client := NewClient(address) @@ -164,8 +190,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) address := setupAPI(t, mockAPI, time.Now(), "user_id", "channel_id") client := NewClient(address) @@ -191,8 +219,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) address := setupAPI(t, mockAPI, time.Now(), "user_id", "channel_id") client := NewClient(address) @@ -218,8 +248,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) channelID := "channel_id" address := setupAPI(t, mockAPI, time.Now(), "user_id", channelID) @@ -247,8 +279,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) channelID := "channel_id" address := setupAPI(t, mockAPI, time.Now(), "user_id", channelID) @@ -276,8 +310,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) userID := "user_id" channelID := "channel_id" @@ -307,8 +343,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)) userID := "user_id" @@ -352,8 +390,10 @@ func TestHandler(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)) userID := "user_id" @@ -385,4 +425,71 @@ func TestHandler(t *testing.T) { require.Equal(t, expected, buffer.String()) }) + + t.Run("don't allow concurrent exports", func(t *testing.T) { + mockCtrl := gomock.NewController(t) + + mockChannel := mock_pluginapi.NewMockChannel(mockCtrl) + mockFile := mock_pluginapi.NewMockFile(mockCtrl) + mockLog := mock_pluginapi.NewMockLog(mockCtrl) + mockPost := mock_pluginapi.NewMockPost(mockCtrl) + mockSlashCommand := mock_pluginapi.NewMockSlashCommand(mockCtrl) + mockUser := mock_pluginapi.NewMockUser(mockCtrl) + mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) + mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) + + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) + + now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)) + userID := "user_id" + channelID := "channel_id" + address := setupAPI(t, mockAPI, now, userID, channelID) + client := NewClient(address) + client.SetToken("token") + + mockSystem.EXPECT().GetLicense().Do(func() { + t.Log("about to sleep for 3s") + time.Sleep(time.Second * 3) + }).Return(&model.License{Features: &model.Features{ + FutureFeatures: &trueValue, + }}).Times(2) + mockConfiguration.EXPECT().GetConfig().Return(&model.Config{}).Times(1) + mockChannel.EXPECT().Get(channelID).Return(&model.Channel{Id: channelID}, nil).Times(1) + mockUser.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionReadChannel).Return(true).Times(1) + mockUser.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1) + mockConfiguration.EXPECT().GetConfig().Return(&model.Config{ + PrivacySettings: model.PrivacySettings{ + ShowEmailAddress: &trueValue, + }, + }) + + countExec := 3 + merr := merror.New() + wg := sync.WaitGroup{} + + for i := 0; i < countExec; i++ { + wg.Add(1) + go func() { + defer wg.Done() + var buffer bytes.Buffer + if err := client.ExportChannel(&buffer, channelID, FormatCSV); err != nil { + merr.Append(err) + } else { + expected := `Post Creation Time,User Id,User Email,User Type,User Name,Post Id,Parent Post Id,Post Message,Post Type +2009-11-11 07:00:00 +0000 UTC,post_user_id,post_user_email,user,post_user_nickname,post_id,post_parent_id,post_message,message +` + require.Equal(t, expected, buffer.String()) + } + }() + } + + wg.Wait() + + require.Equal(t, countExec-1, merr.Len()) + for _, err := range merr.Errors() { + require.Equal(t, "a channel export is already running.", err.Error()) + } + }) }
server/command_hooks.go+32 −1 modified@@ -1,9 +1,12 @@ package main import ( + "context" "fmt" "io" "strings" + "sync" + "time" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -28,7 +31,7 @@ func (p *Plugin) registerCommands() error { // ExecuteCommand executes a command that has been previously registered via the RegisterCommand // API. -func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { +func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { trigger := strings.TrimPrefix(strings.Fields(args.Command)[0], "/") switch trigger { case exportCommandTrigger: @@ -43,6 +46,22 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo } func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandResponse { + // only allow one export at a time + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100) + defer cancel() + if err := p.clusterMutex.LockWithContext(ctx); err != nil { + return &model.CommandResponse{ + ResponseType: model.CommandResponseTypeEphemeral, + Text: "An export is already running.", + } + } + var active bool + defer func() { + if !active { + p.clusterMutex.Unlock() + } + }() + license := p.client.System.GetLicense() if !isLicensed(license, p.client) { return &model.CommandResponse{ @@ -84,7 +103,12 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes }) exportedFileReader, exportedFileWriter := io.Pipe() + wg := sync.WaitGroup{} + wg.Add(2) + active = true + go func() { + defer wg.Done() err := exporter.Export(p.makeChannelPostsIterator(channelToExport, showEmailAddress(p.client, args.UserId)), exportedFileWriter) if err != nil { logger.WithError(err).Warn("failed to export channel") @@ -107,6 +131,7 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes }() go func() { + defer wg.Done() file, err := p.uploadFileTo(fileName, exportedFileReader, channelDM.Id) if err != nil { logger.WithError(err).Warn("failed to upload exported channel") @@ -137,6 +162,12 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes } }() + // wait until both goroutines above are completed then mark exporter inactive + go func() { + defer p.clusterMutex.Unlock() + wg.Wait() + }() + return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: fmt.Sprintf("Exporting ~%s. @%s will send you a direct message when the export is ready.",
server/command_hooks_test.go+108 −17 modified@@ -2,25 +2,30 @@ package main import ( "io" - "io/ioutil" + "sync" "testing" "time" "github.com/golang/mock/gomock" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi" "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi/mock_pluginapi" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" - "github.com/pkg/errors" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func setupPlugin(t *testing.T, mockAPI *pluginapi.Wrapper, now time.Time) (*Plugin, *plugin.Context) { + clusterMutex, err := mockAPI.Cluster.NewMutex(KeyClusterMutex) + require.NoError(t, err) + return &Plugin{ client: mockAPI, botID: "bot_id", makeChannelPostsIterator: makeTestPostsIterator(t, now), + clusterMutex: clusterMutex, }, &plugin.Context{} } @@ -39,8 +44,10 @@ func TestExecuteCommand(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) plugin, pluginContext := setupPlugin(t, mockAPI, time.Now()) @@ -64,8 +71,10 @@ func TestExecuteCommand(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) plugin, pluginContext := setupPlugin(t, mockAPI, time.Now()) @@ -97,8 +106,10 @@ func TestExecuteCommand(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)) plugin, pluginContext := setupPlugin(t, mockAPI, now) @@ -118,8 +129,8 @@ func TestExecuteCommand(t *testing.T) { ShowEmailAddress: &trueValue, }, }) - mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, fileName, channelID string) { - contents, err := ioutil.ReadAll(reader) + mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, _ /*fileName*/, _ /*channelID*/ string) { + contents, err := io.ReadAll(reader) require.NoError(t, err) expected := `Post Creation Time,User Id,User Email,User Type,User Name,Post Id,Parent Post Id,Post Message,Post Type 2009-11-11 07:00:00 +0000 UTC,post_user_id,post_user_email,user,post_user_nickname,post_id,post_parent_id,post_message,message @@ -160,8 +171,10 @@ func TestExecuteCommand(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) plugin, pluginContext := setupPlugin(t, mockAPI, time.Now()) @@ -193,8 +206,10 @@ func TestExecuteCommand(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) plugin, pluginContext := setupPlugin(t, mockAPI, time.Now()) @@ -228,8 +243,10 @@ func TestExecuteCommand(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)) plugin, pluginContext := setupPlugin(t, mockAPI, now) @@ -242,8 +259,8 @@ func TestExecuteCommand(t *testing.T) { mockChannel.EXPECT().GetDirect("user_id", "bot_id").Return(&model.Channel{Id: "direct"}, nil) mockUser.EXPECT().HasPermissionTo("user_id", model.PermissionManageSystem).Return(false).Times(1) mockConfiguration.EXPECT().GetConfig().Return(&model.Config{}).Times(1) - mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, fileName, channelID string) { - contents, err := ioutil.ReadAll(reader) + mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, _ /*fileName*/, _ /*channelID*/ string) { + contents, err := io.ReadAll(reader) require.NoError(t, err) expected := `Post Creation Time,User Id,User Email,User Type,User Name,Post Id,Parent Post Id,Post Message,Post Type 2009-11-11 07:00:00 +0000 UTC,post_user_id,,user,post_user_nickname,post_id,post_parent_id,post_message,message @@ -284,8 +301,10 @@ func TestExecuteCommand(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)) plugin, pluginContext := setupPlugin(t, mockAPI, now) @@ -302,8 +321,8 @@ func TestExecuteCommand(t *testing.T) { ShowEmailAddress: &trueValue, }, }) - mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, fileName, channelID string) { - contents, err := ioutil.ReadAll(reader) + mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, _ /*fileName*/, _ /*channelID*/ string) { + contents, err := io.ReadAll(reader) require.NoError(t, err) expected := `Post Creation Time,User Id,User Email,User Type,User Name,Post Id,Parent Post Id,Post Message,Post Type 2009-11-11 07:00:00 +0000 UTC,post_user_id,post_user_email,user,post_user_nickname,post_id,post_parent_id,post_message,message @@ -332,4 +351,76 @@ func TestExecuteCommand(t *testing.T) { // mock assertions. time.Sleep(1 * time.Second) }) + + t.Run("disallow concurrent exports", func(t *testing.T) { + mockCtrl := gomock.NewController(t) + + mockChannel := mock_pluginapi.NewMockChannel(mockCtrl) + mockFile := mock_pluginapi.NewMockFile(mockCtrl) + mockLog := mock_pluginapi.NewMockLog(mockCtrl) + mockPost := mock_pluginapi.NewMockPost(mockCtrl) + mockSlashCommand := mock_pluginapi.NewMockSlashCommand(mockCtrl) + mockUser := mock_pluginapi.NewMockUser(mockCtrl) + mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) + mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) + mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil) + + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) + + now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60)) + plugin, pluginContext := setupPlugin(t, mockAPI, now) + + mockSystem.EXPECT().GetLicense().Return(&model.License{Features: &model.Features{ + FutureFeatures: &trueValue, + }}).Times(2) + mockConfiguration.EXPECT().GetConfig().Return(&model.Config{}).Times(1) + mockChannel.EXPECT().Get("channel_id").Return(&model.Channel{Id: "channel_id", Name: "channel_name"}, nil) + mockChannel.EXPECT().GetDirect("user_id", "bot_id").Return(&model.Channel{Id: "direct"}, nil) + mockUser.EXPECT().HasPermissionTo("user_id", model.PermissionManageSystem).Return(false).Times(1) + mockConfiguration.EXPECT().GetConfig().Return(&model.Config{ + PrivacySettings: model.PrivacySettings{ + ShowEmailAddress: &trueValue, + }, + }) + wg := sync.WaitGroup{} + wg.Add(1) + mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, _ /*fileName*/, _ /*channelID*/ string) { + defer wg.Done() + t.Log("about to sleep for 3s") + time.Sleep(time.Second * 3) + _, err := io.ReadAll(reader) + require.NoError(t, err) + }).Return(&model.FileInfo{Id: "file_id"}, nil) + mockPost.EXPECT().CreatePost(&model.Post{ + UserId: "bot_id", + ChannelId: "direct", + Message: "Channel ~channel_name exported:", + FileIds: []string{"file_id"}, + }) + + commandResponse, appError := plugin.ExecuteCommand(pluginContext, &model.CommandArgs{ + Command: "/export", + ChannelId: "channel_id", + UserId: "user_id", + }) + + require.Nil(t, appError) + assert.Equal(t, model.CommandResponseTypeEphemeral, commandResponse.ResponseType) + assert.Equal(t, "Exporting ~channel_name. @channelexport will send you a direct message when the export is ready.", commandResponse.Text) + + // concurrent executions should fail + for i := 0; i < 3; i++ { + commandResponse, appError = plugin.ExecuteCommand(pluginContext, &model.CommandArgs{ + Command: "/export", + ChannelId: "channel_id", + UserId: "user_id", + }) + require.Nil(t, appError) + require.Equal(t, "An export is already running.", commandResponse.Text) + } + + // wait for upload to complete + wg.Wait() + }) }
server/common_test.go+2 −2 modified@@ -7,7 +7,7 @@ import ( "github.com/mattermost/mattermost-server/v6/model" ) -func makeTestPostsIterator(t *testing.T, now time.Time) func(channel *model.Channel, showEmailAddress bool) PostIterator { +func makeTestPostsIterator(_ *testing.T, now time.Time) func(channel *model.Channel, showEmailAddress bool) PostIterator { exportedPosts := []*ExportedPost{ { CreateAt: now.Round(time.Millisecond).UTC(), @@ -22,7 +22,7 @@ func makeTestPostsIterator(t *testing.T, now time.Time) func(channel *model.Chan }, } - return func(channel *model.Channel, showEmailAddress bool) PostIterator { + return func(_ *model.Channel, showEmailAddress bool) PostIterator { return func() ([]*ExportedPost, error) { retExportedPosts := exportedPosts if !showEmailAddress {
server/csv.go+4 −0 modified@@ -4,6 +4,7 @@ import ( "encoding/csv" "fmt" "io" + "time" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -68,6 +69,9 @@ func (e *CSV) Export(nextPosts PostIterator, writer io.Writer) error { if len(posts) == 0 { break } + + // don't peg the CPU. + time.Sleep(time.Millisecond * 20) } csvWriter.Flush()
server/exporter.go+5 −3 modified@@ -3,16 +3,19 @@ package main import ( "time" + "github.com/pkg/errors" + "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/utils" - "github.com/pkg/errors" ) const ( userTypeRegular = "user" userTypeBot = "bot" userTypeSystem = "system" + + PerPage = 500 ) // PostIterator returns the next batch of posts when called @@ -38,9 +41,8 @@ type ExportedPost struct { func channelPostsIterator(client *pluginapi.Wrapper, channel *model.Channel, showEmailAddress bool) PostIterator { usersCache := make(map[string]*model.User) page := 0 - perPage := 1000 return func() ([]*ExportedPost, error) { - postList, err := client.Post.GetPostsForChannel(channel.Id, page, perPage) + postList, err := client.Post.GetPostsForChannel(channel.Id, page, PerPage) if err != nil { return nil, err }
server/exporter_test.go+16 −13 modified@@ -7,11 +7,12 @@ import ( "time" "github.com/golang/mock/gomock" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi" "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi/mock_pluginapi" "github.com/mattermost/mattermost-server/v6/model" - "github.com/pkg/errors" - "github.com/stretchr/testify/require" ) func TestChannelPostsIterator(t *testing.T) { @@ -25,8 +26,9 @@ func TestChannelPostsIterator(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) channel := &model.Channel{ Id: "jx2289hnvko3dypmc3thfcafpb", @@ -60,7 +62,7 @@ func TestChannelPostsIterator(t *testing.T) { t.Run("One post iterator", func(t *testing.T) { postIterator := channelPostsIterator(mockAPI, channel, false) - mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(&postList, nil).Times(1) + mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(&postList, nil).Times(1) mockUser.EXPECT().Get(post.UserId).Return(&user, nil).Times(1) posts, err := postIterator() @@ -71,7 +73,7 @@ func TestChannelPostsIterator(t *testing.T) { t.Run("Paging is correct", func(t *testing.T) { postIterator := channelPostsIterator(mockAPI, channel, false) - length := 1000 + length := PerPage posts := make(map[string]*model.Post, length) order := make([]string, length) @@ -93,8 +95,8 @@ func TestChannelPostsIterator(t *testing.T) { secondPage := postList gomock.InOrder( - mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(&firstPage, nil).Times(1), - mockPost.EXPECT().GetPostsForChannel(channel.Id, 1, 1000).Return(&secondPage, nil).Times(1), + mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(&firstPage, nil).Times(1), + mockPost.EXPECT().GetPostsForChannel(channel.Id, 1, PerPage).Return(&secondPage, nil).Times(1), ) // Called only once because we are setting the same user in all posts, @@ -128,7 +130,7 @@ func TestChannelPostsIterator(t *testing.T) { Order: []string{editedPost.Id}, } - mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(&editedPostList, nil).Times(1) + mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(&editedPostList, nil).Times(1) posts, err := postIterator() require.NoError(t, err) @@ -139,7 +141,7 @@ func TestChannelPostsIterator(t *testing.T) { postIterator := channelPostsIterator(mockAPI, channel, false) expectedError := errors.New("error retreiving posts") - mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(nil, expectedError).Times(1) + mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(nil, expectedError).Times(1) posts, err := postIterator() require.Nil(t, posts) @@ -151,7 +153,7 @@ func TestChannelPostsIterator(t *testing.T) { expectedError := fmt.Errorf("new error") mockUser.EXPECT().Get(post.UserId).Return(nil, expectedError).Times(1) - mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(&postList, nil).Times(1) + mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(&postList, nil).Times(1) posts, err := postIterator() require.Nil(t, posts) @@ -170,8 +172,9 @@ func TestToExportedPost(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) now := time.Now().Round(time.Millisecond) userID := "h6itnszvtit5k2jhi2c1o3p7ox" @@ -237,8 +240,8 @@ func TestToExportedPost(t *testing.T) { require.NoError(t, post.ShallowCopy(&postWithoutUserID)) postWithoutUserID.UserId = "unknown_user_id" - error := fmt.Errorf("new error") - mockUser.EXPECT().Get(postWithoutUserID.UserId).Return(nil, error).Times(1) + err := fmt.Errorf("new error") + mockUser.EXPECT().Get(postWithoutUserID.UserId).Return(nil, err).Times(1) usersCache := make(map[string]*model.User) actualExportedPost, err := toExportedPost(mockAPI, &postWithoutUserID, false, usersCache)
server/license.go+1 −1 modified@@ -7,6 +7,6 @@ import ( "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi" ) -func isLicensed(license *model.License, api *pluginapi.Wrapper) bool { +func isLicensed(_ *model.License, api *pluginapi.Wrapper) bool { return originalapi.IsE20LicensedOrDevelopment(api.Configuration.GetConfig(), api.System.GetLicense()) }
server/permission_test.go+8 −4 modified@@ -4,10 +4,11 @@ import ( "testing" "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi" "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi/mock_pluginapi" "github.com/mattermost/mattermost-server/v6/model" - "github.com/stretchr/testify/assert" ) func TestShowEmailAddress(t *testing.T) { @@ -25,8 +26,9 @@ func TestShowEmailAddress(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) userID := "user_id" @@ -45,8 +47,9 @@ func TestShowEmailAddress(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) userID := "user_id" @@ -70,8 +73,9 @@ func TestShowEmailAddress(t *testing.T) { mockUser := mock_pluginapi.NewMockUser(mockCtrl) mockSystem := mock_pluginapi.NewMockSystem(mockCtrl) mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl) + mockCluster := mock_pluginapi.NewMockCluster(mockCtrl) - mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration) + mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster) userID := "user_id"
server/pluginapi/cluster.go+29 −0 added@@ -0,0 +1,29 @@ +package pluginapi + +import ( + "context" + + "github.com/mattermost/mattermost-plugin-api/cluster" + "github.com/mattermost/mattermost-server/v6/plugin" +) + +type ClusterMutex interface { + LockWithContext(ctx context.Context) error + Unlock() +} + +// ClusterService exposes methods from the mm server cluster package. +type ClusterService struct { + api plugin.API +} + +func NewClusterService(api plugin.API) *ClusterService { + return &ClusterService{ + api: api, + } +} + +// NewMutex creates a mutex with the given key name. +func (c *ClusterService) NewMutex(key string) (ClusterMutex, error) { + return cluster.NewMutex(c.api, key) +}
server/pluginapi/mock_pluginapi/cluster.go+49 −0 added@@ -0,0 +1,49 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi (interfaces: Cluster) + +// Package mock_pluginapi is a generated GoMock package. +package mock_pluginapi + +import ( + gomock "github.com/golang/mock/gomock" + pluginapi "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi" + reflect "reflect" +) + +// MockCluster is a mock of Cluster interface +type MockCluster struct { + ctrl *gomock.Controller + recorder *MockClusterMockRecorder +} + +// MockClusterMockRecorder is the mock recorder for MockCluster +type MockClusterMockRecorder struct { + mock *MockCluster +} + +// NewMockCluster creates a new mock instance +func NewMockCluster(ctrl *gomock.Controller) *MockCluster { + mock := &MockCluster{ctrl: ctrl} + mock.recorder = &MockClusterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockCluster) EXPECT() *MockClusterMockRecorder { + return m.recorder +} + +// NewMutex mocks base method +func (m *MockCluster) NewMutex(arg0 string) (pluginapi.ClusterMutex, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewMutex", arg0) + ret0, _ := ret[0].(pluginapi.ClusterMutex) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewMutex indicates an expected call of NewMutex +func (mr *MockClusterMockRecorder) NewMutex(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewMutex", reflect.TypeOf((*MockCluster)(nil).NewMutex), arg0) +}
server/pluginapi/mutex_mock.go+41 −0 added@@ -0,0 +1,41 @@ +package pluginapi + +import ( + "context" + "errors" + "sync/atomic" + "time" +) + +var ( + ErrLockTimeout = errors.New("timeout") +) + +type ClusterMutexMock struct { + locked int32 +} + +func NewClusterMutexMock() *ClusterMutexMock { + return &ClusterMutexMock{} +} + +func (m *ClusterMutexMock) LockWithContext(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if atomic.CompareAndSwapInt32(&m.locked, 0, 1) { + // we have the lock + return nil + } + } + time.Sleep(time.Millisecond * 20) + } +} + +func (m *ClusterMutexMock) Unlock() { + if !atomic.CompareAndSwapInt32(&m.locked, 1, 0) { + panic("not locked") + } +}
server/pluginapi/pluginapi.go+10 −1 modified@@ -4,6 +4,7 @@ import ( "io" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/plugin" pluginapi "github.com/mattermost/mattermost-plugin-api" ) @@ -60,6 +61,10 @@ type Configuration interface { GetConfig() *model.Config } +type Cluster interface { + NewMutex(key string) (ClusterMutex, error) +} + // Wrapper is a wrapper over the mattermost-plugin-api layer, defining // interfaces implemented by that package, that are also mockable type Wrapper struct { @@ -71,6 +76,7 @@ type Wrapper struct { User User System System Configuration Configuration + Cluster Cluster } // CustomWrapper builds a Wrapper with the implementations of the different @@ -84,6 +90,7 @@ func CustomWrapper( user User, system System, configuration Configuration, + cluster Cluster, ) *Wrapper { return &Wrapper{ Channel: channel, @@ -94,12 +101,13 @@ func CustomWrapper( User: user, System: system, Configuration: configuration, + Cluster: cluster, } } // Wrap wraps a plugin.API with the mattermost-plugin-api layer, interfaced by // this package -func Wrap(client *pluginapi.Client) *Wrapper { +func Wrap(client *pluginapi.Client, api plugin.API) *Wrapper { return CustomWrapper( &client.Channel, &client.File, @@ -109,5 +117,6 @@ func Wrap(client *pluginapi.Client) *Wrapper { &client.User, &client.System, &client.Configuration, + &ClusterService{api: api}, ) }
server/plugin.go+14 −5 modified@@ -1,6 +1,7 @@ package main import ( + "fmt" "net/http" "sync" @@ -36,6 +37,9 @@ type Plugin struct { // configurationLock synchronizes access to the configuration. configurationLock sync.RWMutex + + // clusterMutex is used to ensure only one export can be done at a time across the cluster + clusterMutex pluginAPIWrapper.ClusterMutex } const ( @@ -47,10 +51,17 @@ const ( // OnActivate is invoked when the plugin is activated. func (p *Plugin) OnActivate() error { client := pluginapi.NewClient(p.API, p.Driver) - p.client = pluginAPIWrapper.Wrap(client) + p.client = pluginAPIWrapper.Wrap(client, p.API) p.clientPluginAPI = client pluginapi.ConfigureLogrus(logrus.New(), client) + clusterService := pluginAPIWrapper.NewClusterService(p.API) + clusterMutex, err := clusterService.NewMutex(KeyClusterMutex) + if err != nil { + return fmt.Errorf("cannot create cluster mutex: %w", err) + } + p.clusterMutex = clusterMutex + botID, err := p.clientPluginAPI.Bot.EnsureBot(&model.Bot{ Username: botUsername, DisplayName: botDisplayName, @@ -70,12 +81,10 @@ func (p *Plugin) OnActivate() error { p.makeChannelPostsIterator = func(channel *model.Channel, showEmailAddress bool) PostIterator { return channelPostsIterator(p.client, channel, showEmailAddress) } - registerAPI(p.router, p.client, p.makeChannelPostsIterator) - - return nil + return registerAPI(p.router, p.client, p.makeChannelPostsIterator) } // ServeHTTP handles requests to /plugins/com.mattermost.plugin-incident-response -func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { +func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) { p.router.ServeHTTP(w, r) }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.