Files
team/server/channels/app/webhook_test.go
7cdde511ef MM-69010: Validate incoming webhook user membership (#36811)
* MM-69010: Validate incoming webhook user membership

Incoming webhook creation/update did not verify that the assigned
user_id had legitimate access to the target team or channel, allowing a
team admin to attribute persisted posts to an arbitrary user.

Validate that the assigned user can read the target channel and does not
hold privileges the requester lacks at creation, re-check channel access
when a hook is moved, and require a shared team before a webhook creates
a direct message via an @username payload.

* MM-69010: Add regression test for owner+channel update

Verify that changing both the channel and the supplied user_id in a
single update still validates against the retained owner, since the
owner is immutable on update.

---------

Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-02 20:26:42 -04:00

1273 lines
40 KiB
Go

// 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 TestHandleIncomingWebhookRootId(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(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() {
require.Nil(t, th.App.DeleteIncomingWebhook(hook.Id))
}()
root := th.CreatePost(t, th.BasicChannel)
reply := th.CreatePostReply(t, root)
otherChannel := th.CreateChannel(t, th.BasicTeam)
otherPost := th.CreatePost(t, otherChannel)
t.Run("creates reply in thread when root_id is the thread root", func(t *testing.T) {
err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{
Text: "webhook thread reply",
RootId: root.Id,
})
require.Nil(t, err)
list, err2 := th.App.GetPosts(th.Context, th.BasicChannel.Id, 0, 5)
require.Nil(t, err2)
var found *model.Post
for _, p := range list.Posts {
if p.Message == "webhook thread reply" {
found = p
break
}
}
require.NotNil(t, found)
assert.Equal(t, root.Id, found.RootId)
})
t.Run("rejects root_id pointing at a reply post", func(t *testing.T) {
err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{
Text: "webhook via reply id",
RootId: reply.Id,
})
require.NotNil(t, err)
assert.Equal(t, "api.post.create_post.root_id.app_error", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("rejects non-existent root_id", func(t *testing.T) {
err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{
Text: "missing root",
RootId: model.NewId(),
})
require.NotNil(t, err)
assert.Equal(t, "api.post.create_post.root_id.app_error", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("rejects root_id in a different channel", func(t *testing.T) {
err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{
Text: "wrong channel",
RootId: otherPost.Id,
})
require.NotNil(t, err)
assert.Equal(t, "api.post.create_post.channel_root_id.app_error", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("rejects invalid root_id", func(t *testing.T) {
err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{
Text: "bad id",
RootId: "not-a-valid-id",
})
require.NotNil(t, err)
assert.Equal(t, "api.context.invalid_param.app_error", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
}
func TestHandleIncomingWebhookDirectMessage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(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, ChannelLocked: false})
require.Nil(t, appErr)
defer func() {
require.Nil(t, th.App.DeleteIncomingWebhook(hook.Id))
}()
t.Run("rejects DM to a user the owner shares no team with", func(t *testing.T) {
stranger := th.CreateUser(t)
err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{
Text: "out of team dm",
ChannelName: "@" + stranger.Username,
})
require.NotNil(t, err)
assert.Equal(t, http.StatusForbidden, err.StatusCode)
})
t.Run("allows DM to a user the owner shares a team with", func(t *testing.T) {
err := th.App.HandleIncomingWebhook(th.Context, hook.Id, &model.IncomingWebhookRequest{
Text: "team dm",
ChannelName: "@" + th.BasicUser2.Username,
})
require.Nil(t, err)
})
}
func TestCreateIncomingWebhookForChannel(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
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(t)
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(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() {
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.MessageAttachment{
{
Text: "text",
},
},
model.PostPropsWebhookDisplayName: hook.DisplayName,
},
model.PostTypeMessageAttachment,
"", 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.MessageAttachment{
{
Text: "text",
},
},
model.PostPropsWebhookDisplayName: hook.DisplayName,
}, model.PostTypeMessageAttachment, "", 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.MessageAttachment{
{
Text: "text",
},
},
model.PostPropsWebhookDisplayName: hook.DisplayName,
}, model.PostTypeMessageAttachment, "", nil)
require.Nil(t, appErr)
assert.Equal(t, expectedText, post.Message)
expectedText = `commit bc95839e4a430ace453e8b209a3723c000c1729a
Author: foo <[email protected]>
Date: Thu Mar 1 19:46:54 2018 +0300
commit message 2
test | 1 +
1 file changed, 1 insertion(+)
commit 5df78b7139b543997838071cd912e375d8bd69b2
Author: foo <[email protected]>
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.MessageAttachment{
{
Text: "text",
},
},
model.PostPropsWebhookDisplayName: hook.DisplayName,
}, model.PostTypeMessageAttachment, "", 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(t)
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(t)
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(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() {
appErr := th.App.DeleteIncomingWebhook(hook.Id)
require.Nil(t, appErr, "Error cleaning up webhook")
}()
testConditions := []model.PostPriority{
{
Priority: new("high"),
RequestedAck: new(true),
PersistentNotifications: new(false),
},
{
Priority: new(""),
RequestedAck: new(true),
PersistentNotifications: new(false),
},
{
Priority: new("urgent"),
RequestedAck: new(false),
PersistentNotifications: new(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.PostTypeMessageAttachment,
"",
&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(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() {
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: "<https://mattermost.com|Mattermost>",
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.MessageAttachment{
{
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.MessageAttachment{
{
Text: strings.Repeat("本", 1000),
},
{
Text: strings.Repeat("本", 2000),
},
},
},
},
{
Props: map[string]any{
model.PostPropsAttachments: []*model.MessageAttachment{
{
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.MessageAttachment, 0, len(attachments))
for _, a := range attachments {
attach := &model.MessageAttachment{
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(t)
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(t)
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(t, 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(t)
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(t, 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)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = new("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 = new(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 = new(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)
})
}