// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package app import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/v8/channels/testlib" ) func TestCreateIncomingWebhookForChannel(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() type TestCase struct { EnableIncomingHooks bool EnablePostUsernameOverride bool EnablePostIconOverride bool IncomingWebhook model.IncomingWebhook ExpectedError bool ExpectedIncomingWebhook *model.IncomingWebhook } for name, tc := range map[string]TestCase{ "webhooks not enabled": { EnableIncomingHooks: false, EnablePostUsernameOverride: false, EnablePostIconOverride: false, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, }, ExpectedError: true, ExpectedIncomingWebhook: nil, }, "valid: username and post icon url ignored, since override not enabled": { EnableIncomingHooks: true, EnablePostUsernameOverride: false, EnablePostIconOverride: false, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, Username: ":invalid and ignored:", IconURL: "ignored", }, ExpectedError: false, ExpectedIncomingWebhook: &model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, }, }, "invalid username, override enabled": { EnableIncomingHooks: true, EnablePostUsernameOverride: true, EnablePostIconOverride: false, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, Username: ":invalid:", }, ExpectedError: true, ExpectedIncomingWebhook: nil, }, "valid, no username or post icon url provided": { EnableIncomingHooks: true, EnablePostUsernameOverride: true, EnablePostIconOverride: true, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, }, ExpectedError: false, ExpectedIncomingWebhook: &model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, }, }, "valid, with username and post icon": { EnableIncomingHooks: true, EnablePostUsernameOverride: true, EnablePostIconOverride: true, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, Username: "valid", IconURL: "http://example.com/icon", }, ExpectedError: false, ExpectedIncomingWebhook: &model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, Username: "valid", IconURL: "http://example.com/icon", }, }, } { t.Run(name, func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = tc.EnableIncomingHooks }) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnablePostUsernameOverride = tc.EnablePostUsernameOverride }) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnablePostIconOverride = tc.EnablePostIconOverride }) createdHook, appErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &tc.IncomingWebhook) if tc.ExpectedError { require.NotNil(t, appErr, "should have failed") } else { require.Nil(t, appErr, "should not have failed") } if createdHook != nil { defer func() { appErr := th.App.DeleteIncomingWebhook(createdHook.Id) require.Nil(t, appErr, "Error cleaning up webhook") }() } if tc.ExpectedIncomingWebhook == nil { assert.Nil(t, createdHook, "expected nil webhook") } else if assert.NotNil(t, createdHook, "expected non-nil webhook") { assert.Equal(t, tc.ExpectedIncomingWebhook.DisplayName, createdHook.DisplayName) assert.Equal(t, tc.ExpectedIncomingWebhook.Description, createdHook.Description) assert.Equal(t, tc.ExpectedIncomingWebhook.ChannelId, createdHook.ChannelId) assert.Equal(t, tc.ExpectedIncomingWebhook.Username, createdHook.Username) assert.Equal(t, tc.ExpectedIncomingWebhook.IconURL, createdHook.IconURL) } }) } } func TestUpdateIncomingWebhook(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() type TestCase struct { EnableIncomingHooks bool EnablePostUsernameOverride bool EnablePostIconOverride bool IncomingWebhook model.IncomingWebhook ExpectedError bool ExpectedIncomingWebhook *model.IncomingWebhook } for name, tc := range map[string]TestCase{ "webhooks not enabled": { EnableIncomingHooks: false, EnablePostUsernameOverride: false, EnablePostIconOverride: false, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, }, ExpectedError: true, ExpectedIncomingWebhook: nil, }, "valid: username and post icon url ignored, since override not enabled": { EnableIncomingHooks: true, EnablePostUsernameOverride: false, EnablePostIconOverride: false, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, Username: ":invalid and ignored:", IconURL: "ignored", }, ExpectedError: false, ExpectedIncomingWebhook: &model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, }, }, "invalid username, override enabled": { EnableIncomingHooks: true, EnablePostUsernameOverride: true, EnablePostIconOverride: false, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, Username: ":invalid:", }, ExpectedError: true, ExpectedIncomingWebhook: nil, }, "valid, no username or post icon url provided": { EnableIncomingHooks: true, EnablePostUsernameOverride: true, EnablePostIconOverride: true, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, }, ExpectedError: false, ExpectedIncomingWebhook: &model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, }, }, "valid, with username and post icon": { EnableIncomingHooks: true, EnablePostUsernameOverride: true, EnablePostIconOverride: true, IncomingWebhook: model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, Username: "valid", IconURL: "http://example.com/icon", }, ExpectedError: false, ExpectedIncomingWebhook: &model.IncomingWebhook{ DisplayName: "title", Description: "description", ChannelId: th.BasicChannel.Id, Username: "valid", IconURL: "http://example.com/icon", }, }, } { t.Run(name, func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) hook, appErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ ChannelId: th.BasicChannel.Id, }) require.Nil(t, appErr) defer func() { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) appErr = th.App.DeleteIncomingWebhook(hook.Id) require.Nil(t, appErr, "Error cleaning up webhook") }() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = tc.EnableIncomingHooks }) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnablePostUsernameOverride = tc.EnablePostUsernameOverride }) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnablePostIconOverride = tc.EnablePostIconOverride }) updatedHook, appErr := th.App.UpdateIncomingWebhook(hook, &tc.IncomingWebhook) if tc.ExpectedError { require.NotNil(t, appErr, "should have failed") } else { require.Nil(t, appErr, "should not have failed") } if tc.ExpectedIncomingWebhook == nil { assert.Nil(t, updatedHook, "expected nil webhook") } else if assert.NotNil(t, updatedHook, "expected non-nil webhook") { assert.Equal(t, tc.ExpectedIncomingWebhook.DisplayName, updatedHook.DisplayName) assert.Equal(t, tc.ExpectedIncomingWebhook.Description, updatedHook.Description) assert.Equal(t, tc.ExpectedIncomingWebhook.ChannelId, updatedHook.ChannelId) assert.Equal(t, tc.ExpectedIncomingWebhook.Username, updatedHook.Username) assert.Equal(t, tc.ExpectedIncomingWebhook.IconURL, updatedHook.IconURL) } }) } } func TestCreateWebhookPost(t *testing.T) { mainHelper.Parallel(t) testCluster := &testlib.FakeClusterInterface{} th := SetupWithClusterMock(t, testCluster).InitBasic() defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) hook, appErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id}) require.Nil(t, appErr) defer func() { appErr = th.App.DeleteIncomingWebhook(hook.Id) require.Nil(t, appErr, "Error cleaning up webhook") }() post, appErr := th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, "foo", "user", "http://iconurl", "", model.StringInterface{ model.PostPropsAttachments: []*model.SlackAttachment{ { Text: "text", }, }, model.PostPropsWebhookDisplayName: hook.DisplayName, }, model.PostTypeSlackAttachment, "", nil) require.Nil(t, appErr) assert.Contains(t, post.GetProps(), model.PostPropsFromWebhook, "missing from_webhook prop") assert.Contains(t, post.GetProps(), model.PostPropsAttachments, "missing attachments prop") assert.Contains(t, post.GetProps(), model.PostPropsWebhookDisplayName, "missing webhook_display_name prop") _, appErr = th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, "foo", "user", "http://iconurl", "", nil, model.PostTypeSystemGeneric, "", nil) require.NotNil(t, appErr, "Should have failed - bad post type") expectedText := "`<>|<>|`" post, appErr = th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, expectedText, "user", "http://iconurl", "", model.StringInterface{ model.PostPropsAttachments: []*model.SlackAttachment{ { Text: "text", }, }, model.PostPropsWebhookDisplayName: hook.DisplayName, }, model.PostTypeSlackAttachment, "", nil) require.Nil(t, appErr) assert.Equal(t, expectedText, post.Message) expectedText = "< | \n|\n>" post, appErr = th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, expectedText, "user", "http://iconurl", "", model.StringInterface{ model.PostPropsAttachments: []*model.SlackAttachment{ { Text: "text", }, }, model.PostPropsWebhookDisplayName: hook.DisplayName, }, model.PostTypeSlackAttachment, "", nil) require.Nil(t, appErr) assert.Equal(t, expectedText, post.Message) expectedText = `commit bc95839e4a430ace453e8b209a3723c000c1729a Author: foo Date: Thu Mar 1 19:46:54 2018 +0300 commit message 2 test | 1 + 1 file changed, 1 insertion(+) commit 5df78b7139b543997838071cd912e375d8bd69b2 Author: foo Date: Thu Mar 1 19:46:48 2018 +0300 commit message 1 test | 3 +++ 1 file changed, 3 insertions(+)` post, appErr = th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, expectedText, "user", "http://iconurl", "", model.StringInterface{ model.PostPropsAttachments: []*model.SlackAttachment{ { Text: "text", }, }, model.PostPropsWebhookDisplayName: hook.DisplayName, }, model.PostTypeSlackAttachment, "", nil) require.Nil(t, appErr) assert.Equal(t, expectedText, post.Message) t.Run("should set webhook creator status to online", func(t *testing.T) { testCluster.ClearMessages() _, appErr := th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, "text", "", "", "", model.StringInterface{}, model.PostTypeDefault, "", nil) require.Nil(t, appErr) msgs := testCluster.SelectMessages(func(msg *model.ClusterMessage) bool { event, err := model.WebSocketEventFromJSON(bytes.NewReader(msg.Data)) return err == nil && event.EventType() == model.WebsocketEventPosted }) require.Len(t, msgs, 1) // We know there will be no error from the filter condition. event, _ := model.WebSocketEventFromJSON(bytes.NewReader(msgs[0].Data)) assert.Equal(t, false, event.GetData()["set_online"]) }) } func TestCreateWebhookPostWithOverriddenIcon(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true *cfg.ServiceSettings.EnablePostIconOverride = true }) hook, appErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id}) require.Nil(t, appErr) t.Run("should set props based on icon_url", func(t *testing.T) { post, appErr := th.App.CreateWebhookPost( th.Context, hook.UserId, th.BasicChannel, "test post", "", "https://example.com/icon.png", "", nil, "", "", nil, ) require.Nil(t, appErr) assert.Equal(t, "https://example.com/icon.png", post.GetProp(model.PostPropsOverrideIconURL)) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true}) assert.Equal(t, "https://example.com/icon.png", clientPost.GetProp(model.PostPropsOverrideIconURL)) }) t.Run("should set props based on icon_emoji", func(t *testing.T) { post, appErr := th.App.CreateWebhookPost( th.Context, hook.UserId, th.BasicChannel, "test post", "", "", "smile", nil, "", "", nil, ) require.Nil(t, appErr) assert.Equal(t, "smile", post.GetProp(model.PostPropsOverrideIconEmoji)) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true}) assert.Equal(t, "/static/emoji/1f604.png", clientPost.GetProp(model.PostPropsOverrideIconURL)) }) t.Run("should set props based on icon_emoji (using a custom emoji)", func(t *testing.T) { emoji := th.CreateEmoji() post, appErr := th.App.CreateWebhookPost( th.Context, hook.UserId, th.BasicChannel, "test post", "", "", emoji.Name, nil, "", "", nil, ) require.Nil(t, appErr) assert.Equal(t, emoji.Name, post.GetProp(model.PostPropsOverrideIconEmoji)) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true}) assert.Equal(t, fmt.Sprintf("/api/v4/emoji/%s/image", emoji.Id), clientPost.GetProp(model.PostPropsOverrideIconURL)) }) t.Run("should set props based on icon_emoji (with colons around emoji name)", func(t *testing.T) { post, appErr := th.App.CreateWebhookPost( th.Context, hook.UserId, th.BasicChannel, "test post", "", "", ":smile:", nil, "", "", nil, ) require.Nil(t, appErr) assert.Equal(t, ":smile:", post.GetProp(model.PostPropsOverrideIconEmoji)) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true}) assert.Equal(t, "/static/emoji/1f604.png", clientPost.GetProp(model.PostPropsOverrideIconURL)) }) } func TestCreateWebhookPostWithPriority(t *testing.T) { mainHelper.Parallel(t) testCluster := &testlib.FakeClusterInterface{} th := SetupWithClusterMock(t, testCluster).InitBasic() defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) hook, appErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id}) require.Nil(t, appErr) defer func() { appErr := th.App.DeleteIncomingWebhook(hook.Id) require.Nil(t, appErr, "Error cleaning up webhook") }() testConditions := []model.PostPriority{ { Priority: model.NewPointer("high"), RequestedAck: model.NewPointer(true), PersistentNotifications: model.NewPointer(false), }, { Priority: model.NewPointer(""), RequestedAck: model.NewPointer(true), PersistentNotifications: model.NewPointer(false), }, { Priority: model.NewPointer("urgent"), RequestedAck: model.NewPointer(false), PersistentNotifications: model.NewPointer(true), }, } for _, conditions := range testConditions { post, appErr := th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, "foo @"+th.BasicUser.Username, "user", "http://iconurl", "", model.StringInterface{model.PostPropsWebhookDisplayName: hook.DisplayName}, model.PostTypeSlackAttachment, "", &conditions, ) require.Nil(t, appErr) assert.Equal(t, post.Message, "foo @"+th.BasicUser.Username) assert.Contains(t, post.GetProps(), model.PostPropsFromWebhook, "missing from_webhook prop") assert.Equal(t, *conditions.Priority, *post.GetPriority().Priority) assert.Equal(t, *conditions.RequestedAck, *post.GetPriority().RequestedAck) assert.Equal(t, *conditions.PersistentNotifications, *post.GetPriority().PersistentNotifications) } } func TestCreateWebhookPostLinks(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) hook, appErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id}) require.Nil(t, appErr) defer func() { appErr := th.App.DeleteIncomingWebhook(hook.Id) require.Nil(t, appErr, "Error cleaning up webhook") }() for name, tc := range map[string]struct { input string expectedOutput string }{ "if statement": { input: "`if(status_int < QUERY_UNKNOWN || status_int >= QUERY_STATUS_MAX)`", expectedOutput: "`if(status_int < QUERY_UNKNOWN || status_int >= QUERY_STATUS_MAX)`", }, "angle bracket link": { input: "", expectedOutput: "[Mattermost](https://mattermost.com)", }, } { t.Run(name, func(t *testing.T) { post, appErr := th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, tc.input, "", "", "", model.StringInterface{}, "", "", nil) require.Nil(t, appErr) require.Equal(t, tc.expectedOutput, post.Message) }) } } func TestSplitWebhookPost(t *testing.T) { mainHelper.Parallel(t) type TestCase struct { Post *model.Post Expected []*model.Post } maxPostSize := 10000 for name, tc := range map[string]TestCase{ "LongPost": { Post: &model.Post{ Message: strings.Repeat("本", maxPostSize*3/2), }, Expected: []*model.Post{ { Message: strings.Repeat("本", maxPostSize), }, { Message: strings.Repeat("本", maxPostSize/2), }, }, }, "LongPostAndMultipleAttachments": { Post: &model.Post{ Message: strings.Repeat("本", maxPostSize*3/2), Props: map[string]any{ model.PostPropsAttachments: []*model.SlackAttachment{ { Text: strings.Repeat("本", 1000), }, { Text: strings.Repeat("本", 2000), }, { Text: strings.Repeat("本", model.PostPropsMaxUserRunes-1000), }, }, }, }, Expected: []*model.Post{ { Message: strings.Repeat("本", maxPostSize), }, { Message: strings.Repeat("本", maxPostSize/2), Props: map[string]any{ model.PostPropsAttachments: []*model.SlackAttachment{ { Text: strings.Repeat("本", 1000), }, { Text: strings.Repeat("本", 2000), }, }, }, }, { Props: map[string]any{ model.PostPropsAttachments: []*model.SlackAttachment{ { Text: strings.Repeat("本", model.PostPropsMaxUserRunes-1000), }, }, }, }, }, }, "UnsplittableProps": { Post: &model.Post{ Message: "foo", Props: map[string]any{ "foo": strings.Repeat("x", model.PostPropsMaxUserRunes*2), }, }, }, } { t.Run(name, func(t *testing.T) { splits, err := splitWebhookPost(tc.Post, maxPostSize) if tc.Expected == nil { require.NotNil(t, err) } else { require.Nil(t, err) } assert.Equal(t, len(tc.Expected), len(splits)) for i, split := range splits { if i < len(tc.Expected) { assert.Equal(t, tc.Expected[i].Message, split.Message) assert.Equal(t, tc.Expected[i].GetProp(model.PostPropsAttachments), split.GetProp(model.PostPropsAttachments)) } } }) } } func makePost(message int, attachments []int) *model.Post { var props model.StringInterface if len(attachments) > 0 { sa := make([]*model.SlackAttachment, 0, len(attachments)) for _, a := range attachments { attach := &model.SlackAttachment{ Text: strings.Repeat("那", a), } sa = append(sa, attach) } props = map[string]any{model.PostPropsAttachments: sa} } post := &model.Post{ Message: strings.Repeat("那", message), Props: props, } return post } func TestSplitWebhookPostAttachments(t *testing.T) { mainHelper.Parallel(t) maxPostSize := 10000 testCases := []struct { name string post *model.Post expected []*model.Post }{ { // makePost(messageLength, []int{attachmentLength, ...}) name: "no split", post: makePost(10, []int{100, 150, 200}), expected: []*model.Post{makePost(10, []int{100, 150, 200})}, }, { name: "split into 2", post: makePost(maxPostSize-1, []int{model.PostPropsMaxUserRunes * 3 / 4, model.PostPropsMaxUserRunes * 1 / 4}), expected: []*model.Post{ makePost(maxPostSize-1, []int{model.PostPropsMaxUserRunes * 3 / 4}), makePost(0, []int{model.PostPropsMaxUserRunes * 1 / 4}), }, }, { name: "split into 3", post: makePost(maxPostSize*3/2, []int{1000, 2000, model.PostPropsMaxUserRunes - 1000}), expected: []*model.Post{ makePost(maxPostSize, nil), makePost(maxPostSize/2, []int{1000, 2000}), makePost(0, []int{model.PostPropsMaxUserRunes - 1000}), }, }, { name: "MM-24644 split into 3", post: makePost(maxPostSize*3/2, []int{5150, 2000, model.PostPropsMaxUserRunes - 1000}), expected: []*model.Post{ makePost(maxPostSize, nil), makePost(maxPostSize/2, []int{5150, 2000}), makePost(0, []int{model.PostPropsMaxUserRunes - 1000}), }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { splits, err := splitWebhookPost(tc.post, maxPostSize) if tc.expected == nil { require.NotNil(t, err) } else { require.Nil(t, err) } assert.Equal(t, len(tc.expected), len(splits)) for i, split := range splits { if i < len(tc.expected) { assert.Equal(t, tc.expected[i].Message, split.Message, i) assert.Equal(t, tc.expected[i].GetProp(model.PostPropsAttachments), split.GetProp(model.PostPropsAttachments), i) } } }) } } func TestCreateOutGoingWebhookWithUsernameAndIconURL(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() outgoingWebhook := model.OutgoingWebhook{ ChannelId: th.BasicChannel.Id, TeamId: th.BasicChannel.TeamId, CallbackURLs: []string{"http://nowhere.com"}, Username: "some-user-name", IconURL: "http://some-icon/", DisplayName: "some-display-name", Description: "some-description", CreatorId: th.BasicUser.Id, } th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = true }) createdHook, err := th.App.CreateOutgoingWebhook(&outgoingWebhook) require.Nil(t, err) assert.NotNil(t, createdHook, "should not be null") assert.Equal(t, createdHook.ChannelId, outgoingWebhook.ChannelId) assert.Equal(t, createdHook.TeamId, outgoingWebhook.TeamId) assert.Equal(t, createdHook.CallbackURLs, outgoingWebhook.CallbackURLs) assert.Equal(t, createdHook.Username, outgoingWebhook.Username) assert.Equal(t, createdHook.IconURL, outgoingWebhook.IconURL) assert.Equal(t, createdHook.DisplayName, outgoingWebhook.DisplayName) assert.Equal(t, createdHook.Description, outgoingWebhook.Description) } func TestTriggerOutGoingWebhookWithUsernameAndIconURL(t *testing.T) { mainHelper.Parallel(t) getPayload := func(hook *model.OutgoingWebhook, th *TestHelper, channel *model.Channel) *model.OutgoingWebhookPayload { return &model.OutgoingWebhookPayload{ Token: hook.Token, TeamId: hook.TeamId, TeamDomain: th.BasicTeam.Name, ChannelId: channel.Id, ChannelName: channel.Name, Timestamp: th.BasicPost.CreateAt, UserId: th.BasicPost.UserId, UserName: th.BasicUser.Username, PostId: th.BasicPost.Id, Text: th.BasicPost.Message, TriggerWord: "Abracadabra", FileIds: strings.Join(th.BasicPost.FileIds, ","), } } waitUntilWebhookResponseIsCreatedAsPost := func(channel *model.Channel, th *TestHelper, createdPost chan *model.Post) { go func() { for range 5 { time.Sleep(time.Second) posts, _ := th.App.GetPosts(th.Context, channel.Id, 0, 5) if len(posts.Posts) > 0 { for _, post := range posts.Posts { createdPost <- post return } } } }() } type TestCaseOutgoing struct { EnablePostUsernameOverride bool EnablePostIconOverride bool ExpectedUsername string ExpectedIconURL string WebhookResponse *model.OutgoingWebhookResponse } createOutgoingWebhook := func(channel *model.Channel, testCallBackURL string, th *TestHelper) (*model.OutgoingWebhook, *model.AppError) { outgoingWebhook := model.OutgoingWebhook{ ChannelId: channel.Id, TeamId: channel.TeamId, CallbackURLs: []string{testCallBackURL}, Username: "some-user-name", IconURL: "http://some-icon/", DisplayName: "some-display-name", Description: "some-description", CreatorId: th.BasicUser.Id, TriggerWords: []string{"Abracadabra"}, ContentType: "application/json", } return th.App.CreateOutgoingWebhook(&outgoingWebhook) } getTestCases := func() map[string]TestCaseOutgoing { webHookResponse := "sample response text from test server" testCasesOutgoing := map[string]TestCaseOutgoing{ "Should override username and Icon": { EnablePostUsernameOverride: true, EnablePostIconOverride: true, ExpectedUsername: "some-user-name", ExpectedIconURL: "http://some-icon/", }, "Should not override username and Icon": { EnablePostUsernameOverride: false, EnablePostIconOverride: false, }, "Should not override username and Icon if the webhook response already has it": { EnablePostUsernameOverride: true, EnablePostIconOverride: true, ExpectedUsername: "webhookuser", ExpectedIconURL: "http://webhook/icon", WebhookResponse: &model.OutgoingWebhookResponse{Text: &webHookResponse, Username: "webhookuser", IconURL: "http://webhook/icon"}, }, } return testCasesOutgoing } th := Setup(t).InitBasic() defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" }) createdPost := make(chan *model.Post) for name, testCase := range getTestCases() { t.Run(name, func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = true *cfg.ServiceSettings.EnablePostUsernameOverride = testCase.EnablePostUsernameOverride *cfg.ServiceSettings.EnablePostIconOverride = testCase.EnablePostIconOverride }) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if testCase.WebhookResponse != nil { js, jsonErr := json.Marshal(testCase.WebhookResponse) require.NoError(t, jsonErr) _, err := w.Write(js) require.NoError(t, err) } else { _, err := w.Write([]byte(`{"text": "sample response text from test server"}`)) require.NoError(t, err) } })) defer ts.Close() channel := th.CreateChannel(th.Context, th.BasicTeam) hook, _ := createOutgoingWebhook(channel, ts.URL, th) payload := getPayload(hook, th, channel) th.App.TriggerWebhook(th.Context, payload, hook, th.BasicPost, channel) waitUntilWebhookResponseIsCreatedAsPost(channel, th, createdPost) select { case webhookPost := <-createdPost: assert.Equal(t, webhookPost.Message, "sample response text from test server") assert.Equal(t, webhookPost.GetProp(model.PostPropsFromWebhook), "true") if testCase.ExpectedIconURL != "" { assert.Equal(t, webhookPost.GetProp(model.PostPropsOverrideIconURL), testCase.ExpectedIconURL) } else { assert.Nil(t, webhookPost.GetProp(model.PostPropsOverrideIconURL)) } if testCase.ExpectedUsername != "" { assert.Equal(t, webhookPost.GetProp(model.PostPropsOverrideUsername), testCase.ExpectedUsername) } else { assert.Nil(t, webhookPost.GetProp(model.PostPropsOverrideUsername)) } case <-time.After(5 * time.Second): require.Fail(t, "Timeout, webhook response not created as post") } }) } } func TestTriggerOutGoingWebhookWithMultipleURLs(t *testing.T) { mainHelper.Parallel(t) getPayload := func(hook *model.OutgoingWebhook, th *TestHelper, channel *model.Channel) *model.OutgoingWebhookPayload { return &model.OutgoingWebhookPayload{ Token: hook.Token, TeamId: hook.TeamId, TeamDomain: th.BasicTeam.Name, ChannelId: channel.Id, ChannelName: channel.Name, Timestamp: th.BasicPost.CreateAt, UserId: th.BasicPost.UserId, UserName: th.BasicUser.Username, PostId: th.BasicPost.Id, Text: th.BasicPost.Message, TriggerWord: "Abracadabra", FileIds: strings.Join(th.BasicPost.FileIds, ","), } } createOutgoingWebhook := func(channel *model.Channel, testCallBackURLs []string, th *TestHelper) (*model.OutgoingWebhook, *model.AppError) { outgoingWebhook := model.OutgoingWebhook{ ChannelId: channel.Id, TeamId: channel.TeamId, CallbackURLs: testCallBackURLs, Username: "some-user-name", IconURL: "http://some-website.com/assets/some-icon.png", DisplayName: "some-display-name", Description: "some-description", CreatorId: th.BasicUser.Id, TriggerWords: []string{"Abracadabra"}, ContentType: "application/json", } return th.App.CreateOutgoingWebhook(&outgoingWebhook) } chanTs1 := make(chan string, 1) ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { chanTs1 <- "webhook received!" })) defer ts1.Close() chanTs2 := make(chan string, 1) ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { chanTs2 <- "webhook received!" })) defer ts2.Close() th := Setup(t).InitBasic() defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" }) for name, testCase := range map[string]struct { CallBackURLs []string }{ "One WebhookURL": { CallBackURLs: []string{ts1.URL}, }, "Two WebhookURLs": { CallBackURLs: []string{ts1.URL, ts2.URL}, }, } { t.Run(name, func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOutgoingWebhooks = true }) channel := th.CreateChannel(th.Context, th.BasicTeam) hook, _ := createOutgoingWebhook(channel, testCase.CallBackURLs, th) payload := getPayload(hook, th, channel) th.App.TriggerWebhook(th.Context, payload, hook, th.BasicPost, channel) select { case webhookResponse := <-chanTs1: require.Equal(t, "webhook received!", webhookResponse) case <-time.After(5 * time.Second): require.Fail(t, "Timeout, webhook URL 1 response not received") } if len(testCase.CallBackURLs) > 1 { select { case webhookResponse := <-chanTs2: require.Equal(t, "webhook received!", webhookResponse) case <-time.After(5 * time.Second): require.Fail(t, "Timeout, webhook URL 2 response not received") } } }) } } type InfiniteReader struct { Prefix string } func (r InfiniteReader) Read(p []byte) (n int, err error) { for i := range p { p[i] = 'a' } return len(p), nil } func TestDoOutgoingWebhookRequest(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) defer th.TearDown() th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.AllowedUntrustedInternalConnections = model.NewPointer("127.0.0.1") *cfg.ServiceSettings.EnableOutgoingWebhooks = true }) t.Run("with a valid response", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := io.Copy(w, strings.NewReader(`{"text": "Hello, World!"}`)) require.NoError(t, err) })) defer server.Close() resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.NoError(t, err) require.NotNil(t, resp) assert.NotNil(t, resp.Text) assert.Equal(t, "Hello, World!", *resp.Text) }) t.Run("with an invalid response", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := io.Copy(w, strings.NewReader("aaaaaaaa")) require.NoError(t, err) })) defer server.Close() _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.Error(t, err) require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id) }) t.Run("with a large, valid response", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Don't check the error here as the client may disconnect after hitting // the response size limit, causing a broken pipe error that we can't avoid _, _ = io.Copy(w, io.MultiReader(strings.NewReader(`{"text": "`), InfiniteReader{}, strings.NewReader(`"}`))) })) defer server.Close() _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.Error(t, err) require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id) }) t.Run("with a large, invalid response", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Don't check the error here as the client may disconnect after hitting // the response size limit, causing a broken pipe error that we can't avoid _, _ = io.Copy(w, InfiniteReader{}) })) defer server.Close() _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.Error(t, err) require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id) }) t.Run("with a slow response", func(t *testing.T) { releaseHandler := make(chan any) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Don't actually handle the response, allowing the app to timeout. <-releaseHandler })) defer server.Close() defer close(releaseHandler) th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = model.NewPointer(int64(1)) }) _, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.Error(t, err) require.IsType(t, &url.Error{}, err) }) t.Run("with a slow response, long timeout configured", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(1 * time.Second) _, err := io.Copy(w, strings.NewReader(`{"text": "Hello, World!"}`)) require.NoError(t, err) })) defer server.Close() th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = model.NewPointer(int64(2)) }) resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.NoError(t, err) require.NotNil(t, resp) assert.NotNil(t, resp.Text) assert.Equal(t, "Hello, World!", *resp.Text) }) t.Run("without response", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { })) defer server.Close() resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil) require.NoError(t, err) require.Nil(t, resp) }) t.Run("with auth token", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := io.Copy(w, strings.NewReader(fmt.Sprintf(`{"text":"%s"}`, r.Header.Get("Authorization")))) require.NoError(t, err) })) defer server.Close() resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", &model.OutgoingOAuthConnectionToken{ AccessToken: "test", TokenType: "Bearer", }) require.NoError(t, err) require.Equal(t, `Bearer test`, *resp.Text) }) }