// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package app import ( "bytes" "fmt" "image" "image/png" "io" "net/http" "net/http/httptest" "net/url" "os" "strconv" "strings" "sync/atomic" "testing" "time" "github.com/mattermost/mattermost/server/v8/channels/store" "github.com/mattermost/mattermost/server/v8/channels/store/sqlstore" "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks" "github.com/stretchr/testify/mock" "github.com/dyatlov/go-opengraph/opengraph" ogimage "github.com/dyatlov/go-opengraph/opengraph/types/image" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/httpservice" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/app/platform" "github.com/mattermost/mattermost/server/v8/channels/utils/testutils" "github.com/mattermost/mattermost/server/v8/platform/services/imageproxy" ) func TestPreparePostListForClient(t *testing.T) { mainHelper.Parallel(t) // Most of this logic is covered by TestPreparePostForClient, so this just tests handling of multiple posts th := Setup(t) postList := model.NewPostList() for range 5 { postList.AddPost(&model.Post{}) } clientPostList := th.App.PreparePostListForClient(th.Context, postList) t.Run("doesn't mutate provided post list", func(t *testing.T) { assert.NotEqual(t, clientPostList, postList, "should've returned a new post list") assert.NotEqual(t, clientPostList.Posts, postList.Posts, "should've returned a new PostList.Posts") assert.Equal(t, clientPostList.Order, postList.Order, "should've returned the existing PostList.Order") for id, originalPost := range postList.Posts { assert.NotEqual(t, clientPostList.Posts[id], originalPost, "should've returned new post objects") assert.Equal(t, clientPostList.Posts[id].Id, originalPost.Id, "should've returned the same posts") } }) t.Run("adds metadata to each post", func(t *testing.T) { for _, clientPost := range clientPostList.Posts { assert.NotNil(t, clientPost.Metadata, "should've populated metadata for each post") } }) } func TestPreparePostForClient(t *testing.T) { mainHelper.Parallel(t) var serverURL string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/": w.Header().Set("Content-Type", "text/html") _, err := w.Write([]byte(`
`)) require.NoError(t, err) case "/test-image1.png": file, err := testutils.ReadTestFile("test.png") require.NoError(t, err) w.Header().Set("Content-Type", "image/png") _, err = w.Write(file) require.NoError(t, err) case "/test-image2.png": file, err := testutils.ReadTestFile("test-data-graph.png") require.NoError(t, err) w.Header().Set("Content-Type", "image/png") _, err = w.Write(file) require.NoError(t, err) case "/test-image3.png": file, err := testutils.ReadTestFile("qa-data-graph.png") require.NoError(t, err) w.Header().Set("Content-Type", "image/png") _, err = w.Write(file) require.NoError(t, err) default: require.Fail(t, "Invalid path", r.URL.Path) } })) serverURL = server.URL defer server.Close() setup := func(t *testing.T) *TestHelper { th := Setup(t).InitBasic(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableLinkPreviews = true *cfg.ImageProxySettings.Enable = false *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" }) return th } t.Run("no metadata needed", func(t *testing.T) { th := setup(t) message := model.NewId() post := &model.Post{ Message: message, } clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsEditPost: true}) t.Run("doesn't mutate provided post", func(t *testing.T) { assert.NotEqual(t, clientPost, post, "should've returned a new post") assert.Equal(t, message, post.Message, "shouldn't have mutated post.Message") assert.Equal(t, (*model.PostMetadata)(nil), post.Metadata, "shouldn't have mutated post.Metadata") }) t.Run("populates all fields", func(t *testing.T) { assert.Equal(t, message, clientPost.Message, "shouldn't have changed Message") assert.NotEqual(t, nil, clientPost.Metadata, "should've populated Metadata") assert.Empty(t, clientPost.Metadata.Embeds, "should've populated Embeds") assert.Empty(t, clientPost.Metadata.Reactions, "should've populated Reactions") assert.Empty(t, clientPost.Metadata.Files, "should've populated Files") assert.Empty(t, clientPost.Metadata.Emojis, "should've populated Emojis") assert.Empty(t, clientPost.Metadata.Images, "should've populated Images") }) }) t.Run("metadata already set", func(t *testing.T) { th := setup(t) post := th.CreatePost(t, th.BasicChannel) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) assert.False(t, clientPost == post, "should've returned a new post") assert.Equal(t, clientPost, post, "shouldn't have changed any metadata") }) t.Run("priority read from master when context flag set", func(t *testing.T) { // Verifies priority is read from master DB when master context flag is set th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.PostPriority = true }) th.Context.Session().UserId = th.BasicUser.Id // Create a real post with priority post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "test message with priority", Metadata: &model.PostMetadata{ Priority: &model.PostPriority{ Priority: model.NewPointer(model.PostPriorityUrgent), RequestedAck: new(true), }, }, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) // Clear metadata to simulate fresh fetch postWithoutPriority := post.Clone() postWithoutPriority.Metadata = &model.PostMetadata{} // Use master context to ensure we read from master DB masterCtx := sqlstore.RequestContextWithMaster(th.Context) clientPost := th.App.PreparePostForClient(masterCtx, postWithoutPriority, &model.PreparePostForClientOpts{ IncludePriority: true, }) require.NotNil(t, clientPost.Metadata) require.NotNil(t, clientPost.Metadata.Priority, "priority should be fetched from master DB") assert.Equal(t, model.PostPriorityUrgent, *clientPost.Metadata.Priority.Priority) assert.True(t, *clientPost.Metadata.Priority.RequestedAck) }) t.Run("priority fetched from database when IncludePriority is true", func(t *testing.T) { // Verifies priority is fetched from DB when it exists. th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.PostPriority = true }) th.Context.Session().UserId = th.BasicUser.Id post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "test message with priority", Metadata: &model.PostMetadata{ Priority: &model.PostPriority{ Priority: model.NewPointer(model.PostPriorityUrgent), RequestedAck: new(true), }, }, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) // Clear metadata to simulate fresh fetch postWithoutPriority := post.Clone() postWithoutPriority.Metadata = &model.PostMetadata{} clientPost := th.App.PreparePostForClient(th.Context, postWithoutPriority, &model.PreparePostForClientOpts{ IncludePriority: true, }) require.NotNil(t, clientPost.Metadata) require.NotNil(t, clientPost.Metadata.Priority, "priority should be fetched from DB") assert.Equal(t, model.PostPriorityUrgent, *clientPost.Metadata.Priority.Priority) assert.True(t, *clientPost.Metadata.Priority.RequestedAck) }) t.Run("burn on read post priority read from master", func(t *testing.T) { // Verifies BoR post priority is correctly fetched when using master context th := setup(t) // Enable BurnOnRead feature flag th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.BurnOnRead = true }) // Enable BoR feature with license and config th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.PostPriority = true *cfg.ServiceSettings.EnableBurnOnRead = true }) th.Context.Session().UserId = th.BasicUser.Id // Create a burn-on-read post with priority post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "burn on read message with priority", Type: model.PostTypeBurnOnRead, Metadata: &model.PostMetadata{ Priority: &model.PostPriority{ Priority: model.NewPointer(model.PostPriorityUrgent), RequestedAck: new(true), }, }, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) require.Equal(t, model.PostTypeBurnOnRead, post.Type) // Clear metadata to simulate websocket broadcast scenario postWithoutPriority := post.Clone() postWithoutPriority.Metadata = &model.PostMetadata{} // Use master context (as processBroadcastHookForBurnOnRead does) masterCtx := sqlstore.RequestContextWithMaster(th.Context) clientPost := th.App.PreparePostForClient(masterCtx, postWithoutPriority, &model.PreparePostForClientOpts{ IncludePriority: true, RetainContent: true, }) require.NotNil(t, clientPost.Metadata) require.NotNil(t, clientPost.Metadata.Priority, "BoR post priority should be fetched from master DB") assert.Equal(t, model.PostPriorityUrgent, *clientPost.Metadata.Priority.Priority) assert.True(t, *clientPost.Metadata.Priority.RequestedAck) }) t.Run("reactions", func(t *testing.T) { th := setup(t) post := th.CreatePost(t, th.BasicChannel) reaction1 := th.AddReactionToPost(t, post, th.BasicUser, "smile") reaction2 := th.AddReactionToPost(t, post, th.BasicUser2, "smile") reaction3 := th.AddReactionToPost(t, post, th.BasicUser2, "ice_cream") reactions := []*model.Reaction{reaction1, reaction2, reaction3} post.HasReactions = true clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) assert.Len(t, clientPost.Metadata.Reactions, 3, "should've populated Reactions") assert.ElementsMatch(t, reactions, clientPost.Metadata.Reactions) }) t.Run("files", func(t *testing.T) { th := setup(t) fileInfo, err := th.App.DoUploadFile(th.Context, time.Now(), th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser.Id, "test.txt", []byte("test"), true) require.Nil(t, err) post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, FileIds: []string{fileInfo.Id}, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) // DoUploadFile kicks off async content extraction; wait for attach + extraction on // master before PreparePostForClient so the file-info cache is not seeded early. require.EventuallyWithT(t, func(c *assert.CollectT) { attached, storeErr := th.App.Srv().Store().FileInfo().GetFromMaster(fileInfo.Id) if !assert.NoError(c, storeErr) || attached == nil { return } assert.Equal(c, post.Id, attached.PostId) if *th.App.Config().FileSettings.ExtractContent { assert.Equal(c, "test", attached.Content) } }, 10*time.Second, 25*time.Millisecond) expectedFileInfo, storeErr := th.App.Srv().Store().FileInfo().GetFromMaster(fileInfo.Id) require.NoError(t, storeErr) var clientPost *model.Post assert.EventuallyWithT(t, func(c *assert.CollectT) { clientPost = th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) assert.True(c, assert.ObjectsAreEqual([]*model.FileInfo{expectedFileInfo}, clientPost.Metadata.Files)) }, 10*time.Second, 25*time.Millisecond) assert.Equal(t, []*model.FileInfo{expectedFileInfo}, clientPost.Metadata.Files, "should've populated Files") }) t.Run("emojis without custom emojis enabled", func(t *testing.T) { th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = false }) emoji := th.CreateEmoji(t) post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: ":" + emoji.Name + ": :taco:", Props: map[string]any{ model.PostPropsAttachments: []*model.MessageAttachment{ { Text: ":" + emoji.Name + ":", }, }, }, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) th.AddReactionToPost(t, post, th.BasicUser, "smile") th.AddReactionToPost(t, post, th.BasicUser, "angry") th.AddReactionToPost(t, post, th.BasicUser2, "angry") post.HasReactions = true clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) t.Run("populates emojis", func(t *testing.T) { assert.ElementsMatch(t, []*model.Emoji{}, clientPost.Metadata.Emojis, "should've populated empty Emojis") }) t.Run("populates reaction counts", func(t *testing.T) { reactions := clientPost.Metadata.Reactions assert.Len(t, reactions, 3, "should've populated Reactions") }) }) t.Run("emojis with custom emojis enabled", func(t *testing.T) { th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true }) emoji1 := th.CreateEmoji(t) emoji2 := th.CreateEmoji(t) emoji3 := th.CreateEmoji(t) emoji4 := th.CreateEmoji(t) post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: ":" + emoji3.Name + ": :taco:", Props: map[string]any{ model.PostPropsAttachments: []*model.MessageAttachment{ { Text: ":" + emoji4.Name + ":", }, }, }, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) th.AddReactionToPost(t, post, th.BasicUser, emoji1.Name) th.AddReactionToPost(t, post, th.BasicUser, emoji2.Name) th.AddReactionToPost(t, post, th.BasicUser2, emoji2.Name) th.AddReactionToPost(t, post, th.BasicUser2, "angry") post.HasReactions = true clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) t.Run("populates emojis", func(t *testing.T) { assert.ElementsMatch(t, []*model.Emoji{emoji1, emoji2, emoji3, emoji4}, clientPost.Metadata.Emojis, "should've populated post.Emojis") }) t.Run("populates reaction counts", func(t *testing.T) { reactions := clientPost.Metadata.Reactions assert.Len(t, reactions, 4, "should've populated Reactions") }) }) t.Run("emojis overriding profile icon", func(t *testing.T) { th := setup(t) prepare := func(override bool, url, emoji string) *model.Post { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnablePostIconOverride = override }) post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "Test", }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) post.AddProp(model.PostPropsOverrideIconURL, url) post.AddProp(model.PostPropsOverrideIconEmoji, emoji) return th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) } emoji := "basketball" url := "http://host.com/image.png" overriddenURL := "/static/emoji/1f3c0.png" t.Run("does not override icon URL", func(t *testing.T) { clientPost := prepare(false, url, emoji) s, ok := clientPost.GetProps()[model.PostPropsOverrideIconURL] assert.True(t, ok) assert.EqualValues(t, url, s) s, ok = clientPost.GetProps()[model.PostPropsOverrideIconEmoji] assert.True(t, ok) assert.EqualValues(t, emoji, s) }) t.Run("overrides icon URL", func(t *testing.T) { clientPost := prepare(true, url, emoji) s, ok := clientPost.GetProps()[model.PostPropsOverrideIconURL] assert.True(t, ok) assert.EqualValues(t, overriddenURL, s) s, ok = clientPost.GetProps()[model.PostPropsOverrideIconEmoji] assert.True(t, ok) assert.EqualValues(t, emoji, s) }) t.Run("overrides icon URL with name surrounded by colons", func(t *testing.T) { colonEmoji := ":basketball:" clientPost := prepare(true, url, colonEmoji) s, ok := clientPost.GetProps()[model.PostPropsOverrideIconURL] assert.True(t, ok) assert.EqualValues(t, overriddenURL, s) s, ok = clientPost.GetProps()[model.PostPropsOverrideIconEmoji] assert.True(t, ok) assert.EqualValues(t, colonEmoji, s) }) }) t.Run("markdown image dimensions", func(t *testing.T) { th := setup(t) post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: fmt.Sprintf("This is  and ", server.URL, server.URL), }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) t.Run("populates image dimensions", func(t *testing.T) { imageDimensions := clientPost.Metadata.Images require.Len(t, imageDimensions, 2) assert.Equal(t, &model.PostImage{ Format: "png", Width: 1280, Height: 1780, }, imageDimensions[server.URL+"/test-image2.png"]) assert.Equal(t, &model.PostImage{ Format: "png", Width: 408, Height: 336, }, imageDimensions[server.URL+"/test-image1.png"]) }) }) t.Run("post props has invalid fields", func(t *testing.T) { th := setup(t) post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "some post", }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) // this value expected to be a string post.AddProp(model.PostPropsOverrideIconEmoji, true) require.NotPanics(t, func() { _ = th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) }) }) t.Run("proxy linked images", func(t *testing.T) { th := setup(t) testProxyLinkedImage(t, th, false) }) t.Run("proxy opengraph images", func(t *testing.T) { th := setup(t) testProxyOpenGraphImage(t, th, false) }) t.Run("image embed", func(t *testing.T) { th := setup(t) post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our logo: ` + server.URL + `/test-image2.png And this is our icon: ` + server.URL + `/test-image1.png`, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) post.Metadata.Embeds = nil clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, &model.PreparePostForClientOpts{}) // Reminder that only the first link gets an embed and dimensions t.Run("populates embeds", func(t *testing.T) { assert.ElementsMatch(t, []*model.PostEmbed{ { Type: model.PostEmbedImage, URL: server.URL + "/test-image2.png", }, }, clientPost.Metadata.Embeds) }) t.Run("populates image dimensions", func(t *testing.T) { imageDimensions := clientPost.Metadata.Images require.Len(t, imageDimensions, 1) assert.Equal(t, &model.PostImage{ Format: "png", Width: 1280, Height: 1780, }, imageDimensions[server.URL+"/test-image2.png"]) }) }) t.Run("opengraph embed", func(t *testing.T) { th := setup(t) post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our web page: ` + server.URL, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) firstEmbed := clientPost.Metadata.Embeds[0] ogData := firstEmbed.Data.(*opengraph.OpenGraph) t.Run("populates embeds", func(t *testing.T) { assert.Equal(t, firstEmbed.Type, model.PostEmbedOpengraph) assert.Equal(t, firstEmbed.URL, server.URL) assert.Equal(t, ogData.Description, "Contribute to hmhealey/test-files development by creating an account on GitHub.") assert.Equal(t, ogData.SiteName, "GitHub") assert.Equal(t, ogData.Title, "hmhealey/test-files") assert.Equal(t, ogData.Type, "object") assert.Equal(t, ogData.URL, server.URL) assert.Equal(t, ogData.Images[0].URL, server.URL+"/test-image3.png") }) t.Run("populates image dimensions", func(t *testing.T) { imageDimensions := clientPost.Metadata.Images require.Len(t, imageDimensions, 1) assert.Equal(t, &model.PostImage{ Format: "png", Width: 1790, Height: 1340, }, imageDimensions[server.URL+"/test-image3.png"]) }) }) t.Run("opengraph unsafe links", func(t *testing.T) { th := setup(t) noAccessServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Fail(t, "acessed server") })) for _, tc := range []struct { name string link string notImplemented bool }{ { name: "normal link", link: "%s", }, { name: "normal image", link: "%s/test-image1.png", }, { name: "markdown", link: "[markdown](%s) link", // This is because markdown links are not currently supported in the opengraph fetching code // if you just implemented this, remove the `notImplemented` field notImplemented: true, }, { name: "markdown image", link: " link", }, } { t.Run(tc.name, func(t *testing.T) { t.Run("prop set", func(t *testing.T) { prepost := &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `Bla bla bla: ` + fmt.Sprintf(tc.link, noAccessServer.URL), } prepost.AddProp(model.PostPropsUnsafeLinks, "true") post, _, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) assert.Len(t, clientPost.Metadata.Embeds, 0) assert.Len(t, clientPost.Metadata.Images, 0) }) if !tc.notImplemented { t.Run("prop not set", func(t *testing.T) { prepost := &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `Bla bla bla: ` + fmt.Sprintf(tc.link, server.URL), } post, _, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) assert.Greater(t, len(clientPost.Metadata.Embeds)+len(clientPost.Metadata.Images), 0) }) } }) } }) t.Run("message attachment embed", func(t *testing.T) { th := setup(t) post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Props: map[string]any{ model.PostPropsAttachments: []any{ map[string]any{ "text": "", }, }, }, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) post.Metadata.Embeds = nil clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, &model.PreparePostForClientOpts{}) t.Run("populates embeds", func(t *testing.T) { assert.ElementsMatch(t, []*model.PostEmbed{ { Type: model.PostEmbedMessageAttachment, }, }, clientPost.Metadata.Embeds) }) t.Run("populates image dimensions", func(t *testing.T) { imageDimensions := clientPost.Metadata.Images require.Len(t, imageDimensions, 1) assert.Equal(t, &model.PostImage{ Format: "png", Width: 408, Height: 336, }, imageDimensions[server.URL+"/test-image1.png"]) }) }) t.Run("no metadata for deleted posts", func(t *testing.T) { th := setup(t) fileInfo, err := th.App.DoUploadFile(th.Context, time.Now(), th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser.Id, "test.txt", []byte("test"), true) require.Nil(t, err) post, _, err := th.App.CreatePost(th.Context, &model.Post{ Message: "test", FileIds: []string{fileInfo.Id}, UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) post.Metadata.Embeds = nil th.AddReactionToPost(t, post, th.BasicUser, "taco") post, err = th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id) require.Nil(t, err) // DeleteAt isn't set on the post returned by App.DeletePost post.DeleteAt = model.GetMillis() clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) assert.NotEqual(t, nil, clientPost.Metadata, "should've populated Metadata“") assert.Equal(t, "", clientPost.Message, "should've cleaned post content") assert.Nil(t, clientPost.Metadata.Reactions, "should not have populated Reactions") assert.Nil(t, clientPost.Metadata.Files, "should not have populated Files") }) t.Run("permalink preview", func(t *testing.T) { th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://mymattermost.com" }) th.Context.Session().UserId = th.BasicUser.Id referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "hello world", }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) referencedPost.Metadata.Embeds = nil link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) previewPost.Metadata.Embeds = nil clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{}) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview.PostID) }) t.Run("permalink preview includes post priority metadata", func(t *testing.T) { th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://mymattermost.com" *cfg.ServiceSettings.PostPriority = true }) th.Context.Session().UserId = th.BasicUser.Id referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "hello world with priority", Metadata: &model.PostMetadata{ Priority: &model.PostPriority{ Priority: model.NewPointer(model.PostPriorityUrgent), RequestedAck: new(true), }, }, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) referencedPost.Metadata.Embeds = nil link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) previewPost.Metadata.Embeds = nil clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{}) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview.PostID) require.NotNil(t, preview.Post.Metadata) require.NotNil(t, preview.Post.Metadata.Priority) assert.Equal(t, model.PostPriorityUrgent, *preview.Post.Metadata.Priority.Priority) assert.True(t, *preview.Post.Metadata.Priority.RequestedAck) }) t.Run("permalink previews for direct and group messages", func(t *testing.T) { th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://mymattermost.com" }) th.Context.Session().UserId = th.BasicUser.Id directChannel, err := th.App.createDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id) require.Nil(t, err) groupChannel, err := th.App.createGroupChannel(th.Context, []string{th.BasicUser.Id, th.BasicUser2.Id, th.CreateUser(t).Id}, th.BasicUser.Id) require.Nil(t, err) testCases := []struct { Description string Channel *model.Channel Expected model.ChannelType }{ { Description: "direct message permalink preview", Channel: directChannel, Expected: model.ChannelType("D"), }, { Description: "group message permalink preview", Channel: groupChannel, Expected: model.ChannelType("G"), }, } for _, testCase := range testCases { t.Run(testCase.Description, func(t *testing.T) { referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: testCase.Channel.Id, Message: "hello world", }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) referencedPost.Metadata.Embeds = nil link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) previewPost.Metadata.Embeds = nil clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{}) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) assert.Empty(t, preview.TeamName) assert.Equal(t, testCase.Expected, preview.ChannelType) }) } }) t.Run("permalink with nested preview should have referenced post metadata", func(t *testing.T) { th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://mymattermost.com" }) th.Context.Session().UserId = th.BasicUser.Id referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our logo: ` + server.URL + `/test-image2.png`, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) referencedPost.Metadata.Embeds = nil link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) previewPost.Metadata.Embeds = nil clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{}) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) referencedPostFirstEmbed := preview.Post.Metadata.Embeds[0] require.Equal(t, referencedPost.Id, preview.PostID) require.Equal(t, referencedPostFirstEmbed.URL, serverURL+`/test-image2.png`) }) t.Run("permalink with nested permalink should not have referenced post metadata", func(t *testing.T) { th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://mymattermost.com" }) th.Context.Session().UserId = th.BasicUser.Id nestedPermalinkPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our logo: ` + server.URL + `/test-image2.png`, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) nestedPermalinkPost.Metadata.Embeds = nil nestedLink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, nestedPermalinkPost.Id) referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: nestedLink, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) referencedPost.Metadata.Embeds = nil link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) previewPost.Metadata.Embeds = nil clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{}) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) referencedPostMetadata := preview.Post.Metadata require.Equal(t, referencedPost.Id, preview.PostID) require.Equal(t, referencedPostMetadata, (*model.PostMetadata)(nil)) }) t.Run("permalink preview renders after toggling off the feature", func(t *testing.T) { th := setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://mymattermost.com" }) th.Context.Session().UserId = th.BasicUser.Id referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "hello world", }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) clientPost := th.App.PreparePostForClient(th.Context, previewPost, &model.PreparePostForClientOpts{}) firstEmbed := clientPost.Metadata.Embeds[0] preview := firstEmbed.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview.PostID) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnablePermalinkPreviews = false }) th.App.PreparePostForClient(th.Context, previewPost, &model.PreparePostForClientOpts{}) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnablePermalinkPreviews = true }) clientPost2 := th.App.PreparePostForClient(th.Context, previewPost, &model.PreparePostForClientOpts{}) firstEmbed2 := clientPost2.Metadata.Embeds[0] preview2 := firstEmbed2.Data.(*model.PreviewPost) require.Equal(t, referencedPost.Id, preview2.PostID) }) } func TestPreparePostForClientWithImageProxy(t *testing.T) { mainHelper.Parallel(t) setup := func(t *testing.T) *TestHelper { th := Setup(t).InitBasic(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableLinkPreviews = true *cfg.ServiceSettings.SiteURL = "http://mymattermost.com" *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" *cfg.ImageProxySettings.Enable = true *cfg.ImageProxySettings.ImageProxyType = "local" }) th.App.ch.imageProxy = imageproxy.MakeImageProxy(th.Server.platform, th.Server.HTTPService(), th.Server.Log()) return th } t.Run("proxy linked images", func(t *testing.T) { th := setup(t) testProxyLinkedImage(t, th, true) }) t.Run("proxy opengraph images", func(t *testing.T) { th := setup(t) testProxyOpenGraphImage(t, th, true) }) } func testProxyLinkedImage(t *testing.T, th *TestHelper, shouldProxy bool) { postTemplate := "" imageURL := "http://mydomain.com/myimage" proxiedImageURL := "http://mymattermost.com/api/v4/image?url=http%3A%2F%2Fmydomain.com%2Fmyimage" post := &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: fmt.Sprintf(postTemplate, imageURL), } clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) if shouldProxy { assert.Equal(t, fmt.Sprintf(postTemplate, imageURL), post.Message, "should not have mutated original post") assert.Equal(t, fmt.Sprintf(postTemplate, proxiedImageURL), clientPost.Message, "should've replaced linked image URLs") } else { assert.Equal(t, fmt.Sprintf(postTemplate, imageURL), clientPost.Message, "shouldn't have replaced linked image URLs") } } func testProxyOpenGraphImage(t *testing.T, th *TestHelper, shouldProxy bool) { var serverURL string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/": w.Header().Set("Content-Type", "text/html") _, err := w.Write([]byte(` `)) require.NoError(t, err) case "/test-image3.png": file, err := testutils.ReadTestFile("qa-data-graph.png") require.NoError(t, err) w.Header().Set("Content-Type", "image/png") _, err = w.Write(file) require.NoError(t, err) default: require.Fail(t, "Invalid path", r.URL.Path) } })) serverURL = server.URL defer server.Close() post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our web page: ` + server.URL, }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) post.Metadata.Embeds = nil embeds := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, &model.PreparePostForClientOpts{}).Metadata.Embeds require.Len(t, embeds, 1, "should have one embed") embed := embeds[0] assert.Equal(t, model.PostEmbedOpengraph, embed.Type, "embed type should be OpenGraph") assert.Equal(t, server.URL, embed.URL, "embed URL should be correct") og, ok := embed.Data.(*opengraph.OpenGraph) assert.True(t, ok, "data should be non-nil OpenGraph data") assert.NotNil(t, og, "data should be non-nil OpenGraph data") assert.Equal(t, "GitHub", og.SiteName, "OpenGraph data should be correctly populated") require.Len(t, og.Images, 1, "OpenGraph data should have one image") image := og.Images[0] if shouldProxy { assert.Equal(t, "", image.URL, "image URL should not be set with proxy") assert.Equal(t, "http://mymattermost.com/api/v4/image?url="+url.QueryEscape(server.URL+"/test-image3.png"), image.SecureURL, "secure image URL should be sent through proxy") } else { assert.Equal(t, server.URL+"/test-image3.png", image.URL, "image URL should be set") assert.Equal(t, "", image.SecureURL, "secure image URL should not be set") } } func TestGetEmbedForPost(t *testing.T) { mainHelper.Parallel(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/index.html" { w.Header().Set("Content-Type", "text/html") if r.Header.Get("Accept-Language") == "fr" { w.Header().Set("Content-Language", "fr") _, err := w.Write([]byte(` `)) require.NoError(t, err) } else { _, err := w.Write([]byte(` `)) require.NoError(t, err) } } else if r.URL.Path == "/image.png" { file, err := testutils.ReadTestFile("test.png") require.NoError(t, err) w.Header().Set("Content-Type", "image/png") _, err = w.Write(file) require.NoError(t, err) } else if r.URL.Path == "/other" { w.Header().Set("Content-Type", "text/html") _, err := w.Write([]byte(` `)) require.NoError(t, err) } else { require.Fail(t, "Invalid path", r.URL.Path) } })) defer server.Close() ogURL := server.URL + "/index.html" imageURL := server.URL + "/image.png" otherURL := server.URL + "/other" t.Run("with link previews enabled", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" *cfg.ServiceSettings.EnableLinkPreviews = true }) t.Run("should return a message attachment when the post has one", func(t *testing.T) { embed, err := th.App.getEmbedForPost(th.Context, &model.Post{ Props: model.StringInterface{ model.PostPropsAttachments: []*model.MessageAttachment{ { Text: "test", }, }, }, }, "", false) assert.Equal(t, &model.PostEmbed{ Type: model.PostEmbedMessageAttachment, }, embed) assert.NoError(t, err) }) t.Run("should return an image embed when the first link is an image", func(t *testing.T) { embed, err := th.App.getEmbedForPost(th.Context, &model.Post{}, imageURL, false) assert.Equal(t, &model.PostEmbed{ Type: model.PostEmbedImage, URL: imageURL, }, embed) assert.NoError(t, err) }) t.Run("should return an opengraph embed", func(t *testing.T) { embed, err := th.App.getEmbedForPost(th.Context, &model.Post{}, ogURL, false) assert.Equal(t, &model.PostEmbed{ Type: model.PostEmbedOpengraph, URL: ogURL, Data: &opengraph.OpenGraph{ Title: "Title", Description: "Hello world", }, }, embed) assert.NoError(t, err) }) t.Run("should return an opengraph embed in different Server Language", func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.LocalizationSettings.DefaultServerLocale = "fr" }) embed, err := th.App.getEmbedForPost(th.Context, &model.Post{}, ogURL, false) assert.Equal(t, &model.PostEmbed{ Type: model.PostEmbedOpengraph, URL: ogURL, Data: &opengraph.OpenGraph{ Title: "Title-FR", Description: "Bonjour le monde", }, }, embed) assert.NoError(t, err) }) t.Run("should return a link embed", func(t *testing.T) { embed, err := th.App.getEmbedForPost(th.Context, &model.Post{}, otherURL, false) assert.Equal(t, &model.PostEmbed{ Type: model.PostEmbedLink, URL: otherURL, }, embed) assert.NoError(t, err) }) }) t.Run("with link previews disabled", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" *cfg.ServiceSettings.EnableLinkPreviews = false }) t.Run("should return an embedded message attachment", func(t *testing.T) { embed, err := th.App.getEmbedForPost(th.Context, &model.Post{ Props: model.StringInterface{ model.PostPropsAttachments: []*model.MessageAttachment{ { Text: "test", }, }, }, }, "", false) assert.Equal(t, &model.PostEmbed{ Type: model.PostEmbedMessageAttachment, }, embed) assert.NoError(t, err) }) t.Run("should not return an opengraph embed", func(t *testing.T) { embed, err := th.App.getEmbedForPost(th.Context, &model.Post{}, ogURL, false) assert.Nil(t, embed) assert.NoError(t, err) }) t.Run("should not return an image embed", func(t *testing.T) { embed, err := th.App.getEmbedForPost(th.Context, &model.Post{}, imageURL, false) assert.Nil(t, embed) assert.NoError(t, err) }) t.Run("should not return a link embed", func(t *testing.T) { embed, err := th.App.getEmbedForPost(th.Context, &model.Post{}, otherURL, false) assert.Nil(t, embed) assert.NoError(t, err) }) }) } func TestGetImagesForPost(t *testing.T) { mainHelper.Parallel(t) t.Run("with an image link", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { file, err := testutils.ReadTestFile("test.png") require.NoError(t, err) w.Header().Set("Content-Type", "image/png") _, err = w.Write(file) require.NoError(t, err) })) imageURL := server.URL + "/image.png" post := &model.Post{ Message: "", Metadata: &model.PostMetadata{}, } images := th.App.getImagesForPost(th.Context, post, false) assert.Equal(t, images, map[string]*model.PostImage{ imageURL: { Format: "png", Width: 408, Height: 336, }, }) }) t.Run("with an invalid image link", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) imageURL := server.URL + "/bad_image.png" post := &model.Post{ Message: "", Metadata: &model.PostMetadata{}, } images := th.App.getImagesForPost(th.Context, post, false) assert.Equal(t, images, map[string]*model.PostImage{}) }) t.Run("for an OpenGraph image", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/image.png" { w.Header().Set("Content-Type", "image/png") img := image.NewGray(image.Rect(0, 0, 200, 300)) var encoder png.Encoder err := encoder.Encode(w, img) require.NoError(t, err) } else { w.WriteHeader(http.StatusNotFound) } })) defer server.Close() ogURL := server.URL + "/index.html" imageURL := server.URL + "/image.png" post := &model.Post{ Metadata: &model.PostMetadata{ Embeds: []*model.PostEmbed{ { Type: model.PostEmbedOpengraph, URL: ogURL, Data: &opengraph.OpenGraph{ Images: []*ogimage.Image{ { URL: imageURL, }, }, }, }, }, }, } images := th.App.getImagesForPost(th.Context, post, false) assert.Equal(t, images, map[string]*model.PostImage{ imageURL: { Format: "png", Width: 200, Height: 300, }, }) }) t.Run("with an OpenGraph image with a secure_url", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/secure_image.png" { w.Header().Set("Content-Type", "image/png") img := image.NewGray(image.Rect(0, 0, 300, 400)) var encoder png.Encoder err := encoder.Encode(w, img) require.NoError(t, err) } else { w.WriteHeader(http.StatusNotFound) } })) defer server.Close() ogURL := server.URL + "/index.html" imageURL := server.URL + "/secure_image.png" post := &model.Post{ Metadata: &model.PostMetadata{ Embeds: []*model.PostEmbed{ { Type: model.PostEmbedOpengraph, URL: ogURL, Data: &opengraph.OpenGraph{ Images: []*ogimage.Image{ { SecureURL: imageURL, }, }, }, }, }, }, } images := th.App.getImagesForPost(th.Context, post, false) assert.Equal(t, images, map[string]*model.PostImage{ imageURL: { Format: "png", Width: 300, Height: 400, }, }) }) t.Run("with an OpenGraph image with a secure_url and no dimensions", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/secure_image.png" { w.Header().Set("Content-Type", "image/png") img := image.NewGray(image.Rect(0, 0, 400, 500)) var encoder png.Encoder err := encoder.Encode(w, img) require.NoError(t, err) } else { w.WriteHeader(http.StatusNotFound) } })) ogURL := server.URL + "/index.html" imageURL := server.URL + "/secure_image.png" post := &model.Post{ Metadata: &model.PostMetadata{ Embeds: []*model.PostEmbed{ { Type: model.PostEmbedOpengraph, URL: ogURL, Data: &opengraph.OpenGraph{ Images: []*ogimage.Image{ { URL: server.URL + "/image.png", SecureURL: imageURL, }, }, }, }, }, }, } images := th.App.getImagesForPost(th.Context, post, false) assert.Equal(t, images, map[string]*model.PostImage{ imageURL: { Format: "png", Width: 400, Height: 500, }, }) }) t.Run("with an invalid OpenGraph image data", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" }) post := &model.Post{ Metadata: &model.PostMetadata{ Embeds: []*model.PostEmbed{ { Type: model.PostEmbedOpengraph, Data: map[string]any{}, }, }, }, } images := th.App.getImagesForPost(th.Context, post, false) assert.Equal(t, images, map[string]*model.PostImage{}) }) t.Run("should not process OpenGraph image that's a Mattermost permalink", func(t *testing.T) { th := SetupWithStoreMock(t) ogURL := "https://example.com/index.html" imageURL := th.App.GetSiteURL() + "/team/pl/qwertyuiopasdfghjklzxcvbnm" post := &model.Post{ Id: "qwertyuiopasdfghjklzxcvbnm", Metadata: &model.PostMetadata{ Embeds: []*model.PostEmbed{ { Type: model.PostEmbedOpengraph, URL: ogURL, Data: &opengraph.OpenGraph{ Images: []*ogimage.Image{ { URL: imageURL, }, }, }, }, }, }, } mockPostStore := mocks.PostStore{} mockPostStore.On("GetSingle", "qwertyuiopasdfghjklzxcvbnm", false).RunFn = func(args mock.Arguments) { assert.Fail(t, "should not have tried to process Mattermost permalink in OG image URL") } mockLinkMetadataStore := mocks.LinkMetadataStore{} mockLinkMetadataStore.On("Get", mock.Anything, mock.Anything).Return(nil, store.NewErrNotFound("mock resource", "mock ID")) mockStore := th.App.Srv().Store().(*mocks.Store) mockStore.On("Post").Return(&mockPostStore) mockStore.On("LinkMetadata").Return(&mockLinkMetadataStore) images := th.App.getImagesForPost(th.Context, post, false) assert.Equal(t, 0, len(images)) assert.Equal(t, images, map[string]*model.PostImage{}) }) t.Run("with message attachment image URLs", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { file, err := testutils.ReadTestFile("test.png") require.NoError(t, err) w.Header().Set("Content-Type", "image/png") _, err = w.Write(file) require.NoError(t, err) })) defer server.Close() imageURL := server.URL + "/attachment.png" thumbURL := server.URL + "/thumb.png" authorIconURL := server.URL + "/author.png" footerIconURL := server.URL + "/footer.png" post := &model.Post{ Metadata: &model.PostMetadata{}, Props: model.StringInterface{ model.PostPropsAttachments: []*model.MessageAttachment{ { ImageURL: imageURL, ThumbURL: thumbURL, AuthorIcon: authorIconURL, FooterIcon: footerIconURL, }, }, }, } images := th.App.getImagesForPost(th.Context, post, false) expected := &model.PostImage{ Format: "png", Width: 408, Height: 336, } assert.Equal(t, map[string]*model.PostImage{ imageURL: expected, thumbURL: expected, authorIconURL: expected, footerIconURL: expected, }, images) }) t.Run("skips all when unsafe links including interactive props", func(t *testing.T) { th := Setup(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatalf("unexpected HTTP request to test server: %s %s", r.Method, r.URL.String()) })) t.Cleanup(server.Close) post := &model.Post{ Metadata: &model.PostMetadata{ Embeds: []*model.PostEmbed{ {Type: model.PostEmbedImage, URL: server.URL + "/embed.png"}, }, }, Props: model.StringInterface{ model.PostPropsUnsafeLinks: "true", model.PostPropsMmBlocks: []any{ map[string]any{"type": "image", "url": server.URL + "/mm1.png"}, map[string]any{"type": "image", "url": server.URL + "/mm2.png"}, }, model.PostPropsBlockKitBlocks: []any{ map[string]any{"type": "image", "image_url": server.URL + "/bk1.png"}, map[string]any{ "type": "section", "accessory": map[string]any{ "type": "image", "image_url": server.URL + "/bk2.png", }, }, }, model.PostPropsAdaptiveCards: []any{ map[string]any{ "type": "AdaptiveCard", "body": []any{ map[string]any{"type": "Image", "url": server.URL + "/ac1.png"}, map[string]any{"type": "Image", "url": server.URL + "/ac2.png"}, }, }, }, }, } out := th.App.getImagesForPost(th.Context, post, false) assert.Empty(t, out) }) t.Run("skips interactive prop images when mmBlocksEnabled is false", func(t *testing.T) { th := Setup(t) th.ConfigStore.SetReadOnlyFF(false) defer th.ConfigStore.SetReadOnlyFF(true) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" cfg.FeatureFlags.MmBlocksEnabled = false }) defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.MmBlocksEnabled = true }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/attach.png": file, err := testutils.ReadTestFile("test.png") require.NoError(t, err) w.Header().Set("Content-Type", "image/png") _, err = w.Write(file) require.NoError(t, err) default: t.Fatalf("unexpected HTTP request: %s", r.URL.String()) } })) defer server.Close() attachURL := server.URL + "/attach.png" post := &model.Post{ Metadata: &model.PostMetadata{}, Props: model.StringInterface{ model.PostPropsMmBlocks: []any{ map[string]any{"type": "image", "url": server.URL + "/mm.png"}, }, model.PostPropsBlockKitBlocks: []any{ map[string]any{"type": "image", "image_url": server.URL + "/bk.png"}, }, model.PostPropsAdaptiveCards: []any{ map[string]any{ "type": "AdaptiveCard", "body": []any{ map[string]any{"type": "Image", "url": server.URL + "/ac.png"}, }, }, }, model.PostPropsAttachments: []*model.MessageAttachment{ {ImageURL: attachURL}, }, }, } images := th.App.getImagesForPost(th.Context, post, false) require.Len(t, images, 1) require.Contains(t, images, attachURL) }) } func TestGetEmojiNamesForString(t *testing.T) { mainHelper.Parallel(t) testCases := []struct { Description string Input string Expected []string }{ { Description: "no emojis", Input: "this is a string", Expected: []string{}, }, { Description: "one emoji", Input: "this is an :emoji1: string", Expected: []string{"emoji1"}, }, { Description: "two emojis", Input: "this is a :emoji3: :emoji2: string", Expected: []string{"emoji3", "emoji2"}, }, { Description: "punctuation around emojis", Input: ":emoji3:/:emoji1: (:emoji2:)", Expected: []string{"emoji3", "emoji1", "emoji2"}, }, { Description: "adjacent emojis", Input: ":emoji3::emoji1:", Expected: []string{"emoji3", "emoji1"}, }, { Description: "duplicate emojis", Input: ":emoji1: :emoji1: :emoji1::emoji2::emoji2: :emoji1:", Expected: []string{"emoji1", "emoji1", "emoji1", "emoji2", "emoji2", "emoji1"}, }, { Description: "fake emojis", Input: "these don't exist :tomato: :potato: :rotato:", Expected: []string{"tomato", "potato", "rotato"}, }, } for _, testCase := range testCases { t.Run(testCase.Description, func(t *testing.T) { emojis := getEmojiNamesForString(testCase.Input) assert.ElementsMatch(t, emojis, testCase.Expected, "received incorrect emoji names") }) } } func TestGetEmojiNamesForPost(t *testing.T) { mainHelper.Parallel(t) testCases := []struct { Description string Post *model.Post Reactions []*model.Reaction Expected []string }{ { Description: "no emojis", Post: &model.Post{ Message: "this is a post", }, Expected: []string{}, }, { Description: "in post message", Post: &model.Post{ Message: "this is :emoji:", }, Expected: []string{"emoji"}, }, { Description: "in reactions", Post: &model.Post{}, Reactions: []*model.Reaction{ { EmojiName: "emoji1", }, { EmojiName: "emoji2", }, }, Expected: []string{"emoji1", "emoji2"}, }, { Description: "in message attachments", Post: &model.Post{ Message: "this is a post", Props: map[string]any{ model.PostPropsAttachments: []*model.MessageAttachment{ { Text: ":emoji1:", Pretext: ":emoji2:", }, { Fields: []*model.MessageAttachmentField{ { Value: ":emoji3:", }, { Value: ":emoji4:", }, }, }, { Title: "This is the title: :emoji5:", }, }, }, }, Expected: []string{"emoji1", "emoji2", "emoji3", "emoji4", "emoji5"}, }, { Description: "with duplicates", Post: &model.Post{ Message: "this is :emoji1", Props: map[string]any{ model.PostPropsAttachments: []*model.MessageAttachment{ { Text: ":emoji2:", Pretext: ":emoji2:", Fields: []*model.MessageAttachmentField{ { Value: ":emoji3:", }, { Value: ":emoji1:", }, }, }, }, }, }, Expected: []string{"emoji1", "emoji2", "emoji3"}, }, { Description: "in interactive mm_blocks, blocks, and cards", Post: &model.Post{ Message: "body :smile:", Props: map[string]any{ model.PostPropsMmBlocks: []any{ map[string]any{"type": "text", "text": "mm :coffee:"}, map[string]any{"type": "button", "text": "btn :thumbsup:", "action_id": "a"}, }, model.PostPropsBlockKitBlocks: []any{ map[string]any{ "type": "section", "text": map[string]any{ "type": "mrkdwn", "text": "block kit :rocket:", }, }, }, model.PostPropsAdaptiveCards: []any{ map[string]any{ "type": "AdaptiveCard", "body": []any{ map[string]any{"type": "TextBlock", "text": "card :tada:"}, }, }, }, }, }, // Button labels are not included in Post.AllStrings (see model.TestPost_AllStrings_interactiveProps). Expected: []string{"smile", "coffee", "rocket", "tada"}, }, } for _, testCase := range testCases { t.Run(testCase.Description, func(t *testing.T) { emojis := getEmojiNamesForPost(testCase.Post, testCase.Reactions, true) assert.ElementsMatch(t, emojis, testCase.Expected, "received incorrect emoji names") }) } t.Run("omits interactive block emojis when mmBlocksEnabled is false", func(t *testing.T) { post := &model.Post{ Message: "body :smile:", Props: map[string]any{ model.PostPropsMmBlocks: []any{ map[string]any{"type": "text", "text": "mm :coffee:"}, }, model.PostPropsBlockKitBlocks: []any{ map[string]any{ "type": "section", "text": map[string]any{ "type": "mrkdwn", "text": "block kit :rocket:", }, }, }, model.PostPropsAdaptiveCards: []any{ map[string]any{ "type": "AdaptiveCard", "body": []any{ map[string]any{"type": "TextBlock", "text": "card :tada:"}, }, }, }, }, } reactions := []*model.Reaction{{EmojiName: "wave"}} emojis := getEmojiNamesForPost(post, reactions, false) assert.ElementsMatch(t, []string{"smile", "wave"}, emojis) }) } func TestGetCustomEmojisForPost(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true }) emojis := []*model.Emoji{ th.CreateEmoji(t), th.CreateEmoji(t), th.CreateEmoji(t), th.CreateEmoji(t), th.CreateEmoji(t), th.CreateEmoji(t), } t.Run("from different parts of the post", func(t *testing.T) { reactions := []*model.Reaction{ { UserId: th.BasicUser.Id, EmojiName: emojis[0].Name, }, } post := &model.Post{ Message: ":" + emojis[1].Name + ":", Props: map[string]any{ model.PostPropsAttachments: []*model.MessageAttachment{ { Pretext: ":" + emojis[2].Name + ":", Text: ":" + emojis[3].Name + ":", Fields: []*model.MessageAttachmentField{ { Value: ":" + emojis[4].Name + ":", }, { Value: ":" + emojis[5].Name + ":", }, }, }, }, }, } emojisForPost, err := th.App.getCustomEmojisForPost(th.Context, post, reactions) assert.Nil(t, err, "failed to get emojis for post") assert.ElementsMatch(t, emojisForPost, emojis, "received incorrect emojis") }) t.Run("with emojis that don't exist", func(t *testing.T) { post := &model.Post{ Message: ":secret: :" + emojis[0].Name + ":", Props: map[string]any{ model.PostPropsAttachments: []*model.MessageAttachment{ { Text: ":imaginary:", }, }, }, } emojisForPost, err := th.App.getCustomEmojisForPost(th.Context, post, nil) assert.Nil(t, err, "failed to get emojis for post") assert.ElementsMatch(t, emojisForPost, []*model.Emoji{emojis[0]}, "received incorrect emojis") }) t.Run("with no emojis", func(t *testing.T) { post := &model.Post{ Message: "this post is boring", Props: map[string]any{}, } emojisForPost, err := th.App.getCustomEmojisForPost(th.Context, post, nil) assert.Nil(t, err, "failed to get emojis for post") assert.ElementsMatch(t, emojisForPost, []*model.Emoji{}, "should have received no emojis") }) t.Run("includes interactive block emojis when mmBlocksEnabled is true", func(t *testing.T) { th.ConfigStore.SetReadOnlyFF(false) defer th.ConfigStore.SetReadOnlyFF(true) th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.MmBlocksEnabled = true }) defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.MmBlocksEnabled = true }) messageEmoji := emojis[0] blockEmoji := th.CreateEmoji(t) post := &model.Post{ Message: ":" + messageEmoji.Name + ":", Props: map[string]any{ model.PostPropsMmBlocks: []any{ map[string]any{"type": "text", "text": "mm :" + blockEmoji.Name + ":"}, }, }, } emojisForPost, err := th.App.getCustomEmojisForPost(th.Context, post, nil) require.Nil(t, err) assert.ElementsMatch(t, []*model.Emoji{messageEmoji, blockEmoji}, emojisForPost) }) t.Run("omits interactive block emojis when mmBlocksEnabled is false", func(t *testing.T) { th.ConfigStore.SetReadOnlyFF(false) defer th.ConfigStore.SetReadOnlyFF(true) th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.MmBlocksEnabled = false }) defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.MmBlocksEnabled = true }) messageEmoji := emojis[0] blockEmoji := th.CreateEmoji(t) post := &model.Post{ Message: ":" + messageEmoji.Name + ":", Props: map[string]any{ model.PostPropsMmBlocks: []any{ map[string]any{"type": "text", "text": "mm :" + blockEmoji.Name + ":"}, }, }, } emojisForPost, err := th.App.getCustomEmojisForPost(th.Context, post, nil) require.Nil(t, err) assert.ElementsMatch(t, []*model.Emoji{messageEmoji}, emojisForPost) }) } func TestGetFirstLink(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) for name, testCase := range map[string]struct { Input string ExpectedFirstLink string ExpectedImages []string }{ "no links or images": { Input: "this is a string", ExpectedFirstLink: "", ExpectedImages: []string{}, }, "http link": { Input: "this is a http://example.com", ExpectedFirstLink: "http://example.com", ExpectedImages: []string{}, }, "www link": { Input: "this is a www.example.com", ExpectedFirstLink: "http://www.example.com", ExpectedImages: []string{}, }, "image": { Input: "this is a ", ExpectedFirstLink: "", ExpectedImages: []string{"http://example.com/logo"}, }, "multiple images": { Input: "this is a  and  and ", ExpectedFirstLink: "", ExpectedImages: []string{"http://example.com/logo", "http://example.com/logo2", "http://example.com/logo3"}, }, "multiple images with duplicate": { Input: "this is a  and  and ", ExpectedFirstLink: "", ExpectedImages: []string{"http://example.com/logo", "http://example.com/logo2", "http://example.com/logo2"}, }, "reference image": { Input: `this is a ![our logo][logo] [logo]: http://example.com/logo`, ExpectedFirstLink: "", ExpectedImages: []string{"http://example.com/logo"}, }, "image and link": { Input: "this is a https://example.com and ", ExpectedFirstLink: "https://example.com", ExpectedImages: []string{"https://example.com/logo"}, }, "markdown links (not returned)": { Input: `this is a [our page](http://example.com) and [another page][] [another page]: http://www.example.com/another_page`, ExpectedFirstLink: "", ExpectedImages: []string{}, }, "http link in angle brackets": { Input: "this is a