// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package api4 import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/channels/testlib" ) type testHandler struct { t *testing.T } func (th *testHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { bb, err := io.ReadAll(r.Body) assert.NoError(th.t, err) assert.NotEmpty(th.t, string(bb)) var poir model.PostActionIntegrationRequest jsonErr := json.Unmarshal(bb, &poir) assert.NoError(th.t, jsonErr) assert.NotEmpty(th.t, poir.UserId) assert.NotEmpty(th.t, poir.UserName) assert.NotEmpty(th.t, poir.ChannelId) assert.NotEmpty(th.t, poir.ChannelName) assert.NotEmpty(th.t, poir.TeamId) assert.NotEmpty(th.t, poir.TeamName) assert.NotEmpty(th.t, poir.PostId) assert.NotEmpty(th.t, poir.TriggerId) assert.Equal(th.t, model.PostActionTypeButton, poir.Type) assert.Equal(th.t, "test-value", poir.Context["test-key"]) _, err = w.Write([]byte("{}")) require.NoError(th.t, err) w.WriteHeader(200) } func TestPostActionCookies(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() client := th.Client th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" }) handler := &testHandler{t} server := httptest.NewServer(handler) for name, test := range map[string]struct { Action model.PostAction ExpectedSuccess bool ExpectedStatusCode int }{ "32 character ID": { Action: model.PostAction{ Id: model.NewId(), Name: "Test-action", Type: model.PostActionTypeButton, Integration: &model.PostActionIntegration{ URL: server.URL, Context: map[string]any{ "test-key": "test-value", }, }, }, ExpectedSuccess: true, ExpectedStatusCode: http.StatusOK, }, "6 character ID": { Action: model.PostAction{ Id: "someID", Name: "Test-action", Type: model.PostActionTypeButton, Integration: &model.PostActionIntegration{ URL: server.URL, Context: map[string]any{ "test-key": "test-value", }, }, }, ExpectedSuccess: true, ExpectedStatusCode: http.StatusOK, }, "Empty ID": { Action: model.PostAction{ Id: "", Name: "Test-action", Type: model.PostActionTypeButton, Integration: &model.PostActionIntegration{ URL: server.URL, Context: map[string]any{ "test-key": "test-value", }, }, }, ExpectedSuccess: false, ExpectedStatusCode: http.StatusNotFound, }, } { t.Run(name, func(t *testing.T) { post := &model.Post{ Id: model.NewId(), Type: model.PostTypeEphemeral, UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, CreateAt: model.GetMillis(), UpdateAt: model.GetMillis(), Props: map[string]any{ model.PostPropsAttachments: []*model.SlackAttachment{ { Title: "some-title", TitleLink: "https://some-url.com", Text: "some-text", ImageURL: "https://some-other-url.com", Actions: []*model.PostAction{&test.Action}, }, }, }, } assert.Equal(t, 32, len(th.App.PostActionCookieSecret())) post = model.AddPostActionCookies(post, th.App.PostActionCookieSecret()) resp, err := client.DoPostActionWithCookie(context.Background(), post.Id, test.Action.Id, "", test.Action.Cookie) require.NotNil(t, resp) if test.ExpectedSuccess { assert.NoError(t, err) } else { assert.Error(t, err) } assert.Equal(t, test.ExpectedStatusCode, resp.StatusCode) assert.NotNil(t, resp.RequestId) assert.NotNil(t, resp.ServerVersion) }) } } func TestOpenDialog(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() client := th.Client th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" }) _, triggerId, appErr := model.GenerateTriggerId(th.BasicUser.Id, th.App.AsymmetricSigningKey()) require.Nil(t, appErr) request := model.OpenDialogRequest{ TriggerId: triggerId, URL: "http://localhost:8065", Dialog: model.Dialog{ CallbackId: "callbackid", Title: "Some Title", Elements: []model.DialogElement{ { DisplayName: "Element Name", Name: "element_name", Type: "text", Placeholder: "Enter a value", }, }, SubmitLabel: "Submit", NotifyOnCancel: false, State: "somestate", }, } t.Run("Should pass with valid request", func(t *testing.T) { _, err := client.OpenInteractiveDialog(context.Background(), request) require.NoError(t, err) }) t.Run("Should fail on bad trigger ID", func(t *testing.T) { request.TriggerId = "junk" resp, err := client.OpenInteractiveDialog(context.Background(), request) require.Error(t, err) CheckBadRequestStatus(t, resp) }) t.Run("URL is required", func(t *testing.T) { request.TriggerId = triggerId request.URL = "" resp, err := client.OpenInteractiveDialog(context.Background(), request) require.Error(t, err) CheckBadRequestStatus(t, resp) }) t.Run("Should pass with markdown formatted introduction text", func(t *testing.T) { request.URL = "http://localhost:8065" request.Dialog.IntroductionText = "**Some** _introduction text" _, err := client.OpenInteractiveDialog(context.Background(), request) require.NoError(t, err) }) t.Run("Should pass with empty introduction text", func(t *testing.T) { request.Dialog.IntroductionText = "" _, err := client.OpenInteractiveDialog(context.Background(), request) require.NoError(t, err) }) t.Run("Should pass with too long display name of elements", func(t *testing.T) { request.Dialog.Elements = []model.DialogElement{ { DisplayName: "Very very long Element Name", Name: "element_name", Type: "text", Placeholder: "Enter a value", }, } buffer := &mlog.Buffer{} err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...) require.NoError(t, err) _, err = client.OpenInteractiveDialog(context.Background(), request) require.NoError(t, err) require.NoError(t, th.TestLogger.Flush()) testlib.AssertLog(t, buffer, mlog.LvlWarn.Name, "Interactive dialog is invalid") }) t.Run("Should pass with same elements", func(t *testing.T) { request.Dialog.Elements = []model.DialogElement{ { DisplayName: "Element Name", Name: "element_name", Type: "text", Placeholder: "Enter a value", }, { DisplayName: "Element Name", Name: "element_name", Type: "text", Placeholder: "Enter a value", }, } buffer := &mlog.Buffer{} err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...) require.NoError(t, err) _, err = client.OpenInteractiveDialog(context.Background(), request) require.NoError(t, err) require.NoError(t, th.TestLogger.Flush()) testlib.AssertLog(t, buffer, mlog.LvlWarn.Name, "Interactive dialog is invalid") }) t.Run("Should pass with nil elements slice", func(t *testing.T) { request.Dialog.Elements = nil _, err := client.OpenInteractiveDialog(context.Background(), request) require.NoError(t, err) }) t.Run("Should pass with empty elements slice", func(t *testing.T) { request.Dialog.Elements = []model.DialogElement{} _, err := client.OpenInteractiveDialog(context.Background(), request) require.NoError(t, err) }) t.Run("Should fail if trigger timeout is extended", func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = model.NewPointer(int64(1)) }) time.Sleep(2 * time.Second) _, err := client.OpenInteractiveDialog(context.Background(), request) require.Error(t, err) assert.Contains(t, err.Error(), "Trigger ID for interactive dialog is expired.") }) } func TestSubmitDialog(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() defer th.TearDown() client := th.Client th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" }) submit := model.SubmitDialogRequest{ CallbackId: "callbackid", State: "somestate", UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, TeamId: th.BasicTeam.Id, Submission: map[string]any{"somename": "somevalue"}, } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var request model.SubmitDialogRequest err := json.NewDecoder(r.Body).Decode(&request) require.NoError(t, err) assert.Equal(t, request.URL, "") assert.Equal(t, request.UserId, submit.UserId) assert.Equal(t, request.ChannelId, submit.ChannelId) assert.Equal(t, request.TeamId, submit.TeamId) assert.Equal(t, request.CallbackId, submit.CallbackId) assert.Equal(t, request.State, submit.State) val, ok := request.Submission["somename"].(string) require.True(t, ok) assert.Equal(t, "somevalue", val) })) defer ts.Close() submit.URL = ts.URL submitResp, _, err := client.SubmitInteractiveDialog(context.Background(), submit) require.NoError(t, err) assert.NotNil(t, submitResp) submit.URL = "" submitResp, resp, err := client.SubmitInteractiveDialog(context.Background(), submit) require.Error(t, err) CheckBadRequestStatus(t, resp) assert.Nil(t, submitResp) submit.URL = ts.URL submit.ChannelId = model.NewId() submitResp, resp, err = client.SubmitInteractiveDialog(context.Background(), submit) require.Error(t, err) CheckNotFoundStatus(t, resp) assert.Nil(t, submitResp) submit.URL = ts.URL submit.ChannelId = th.BasicChannel.Id submit.TeamId = model.NewId() submitResp, resp, err = client.SubmitInteractiveDialog(context.Background(), submit) require.Error(t, err) CheckForbiddenStatus(t, resp) assert.Nil(t, submitResp) } func newAttachmentActionPostInChannel(t *testing.T, th *TestHelper, channelID, userID, upstreamURL string) (*model.Post, string) { t.Helper() post := &model.Post{ Message: "attachment action post", ChannelId: channelID, UserId: userID, Props: model.StringInterface{ model.PostPropsAttachments: []*model.SlackAttachment{ { Text: "hello", Actions: []*model.PostAction{ { Type: model.PostActionTypeButton, Name: "click", Integration: &model.PostActionIntegration{URL: upstreamURL}, }, }, }, }, }, } created, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) require.Nil(t, appErr) withCookies := model.AddPostActionCookies(created, th.App.PostActionCookieSecret()) attachments, ok := withCookies.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) require.NotEmpty(t, attachments) require.NotEmpty(t, attachments[0].Actions) action := attachments[0].Actions[0] require.NotEmpty(t, action.Id) return withCookies, action.Id } func TestDoPostActionCookieChannelAuthorization(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" }) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("{}")) })) defer ts.Close() privateChannel := th.CreatePrivateChannel() privatePost, privateActionID := newAttachmentActionPostInChannel(t, th, privateChannel.Id, th.BasicUser.Id, ts.URL) _, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false) require.Nil(t, appErr) readablePost, _ := newAttachmentActionPostInChannel(t, th, th.BasicChannel.Id, th.BasicUser.Id, ts.URL) readableAttachments, ok := readablePost.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) readableCookie := readableAttachments[0].Actions[0].Cookie require.NotEmpty(t, readableCookie) nonMember := th.CreateClient() th.LoginBasic2WithClient(nonMember) t.Run("non-member cannot act on the private post without a cookie", func(t *testing.T) { resp, err := nonMember.DoPostAction(context.Background(), privatePost.Id, privateActionID) require.Error(t, err) CheckForbiddenStatus(t, resp) }) t.Run("a cookie from a readable channel cannot authorize a different post", func(t *testing.T) { resp, err := nonMember.DoPostActionWithCookie(context.Background(), privatePost.Id, privateActionID, "", readableCookie) require.Error(t, err) CheckForbiddenStatus(t, resp) }) t.Run("a member can still act using the post's own cookie", func(t *testing.T) { legitAttachments, ok := privatePost.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) legitCookie := legitAttachments[0].Actions[0].Cookie require.NotEmpty(t, legitCookie) resp, err := th.Client.DoPostActionWithCookie(context.Background(), privatePost.Id, privateActionID, "", legitCookie) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) }) }