Files
team/server/channels/app/post_metadata_test.go
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

3891 lines
122 KiB
Go

// 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"
"testing"
"time"
"github.com/hanzoteam/server/server/v8/channels/store"
"github.com/hanzoteam/server/server/v8/channels/store/sqlstore"
"github.com/hanzoteam/server/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/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/httpservice"
"github.com/hanzoteam/server/server/public/shared/mlog"
"github.com/hanzoteam/server/server/public/shared/request"
"github.com/hanzoteam/server/server/v8/channels/app/platform"
"github.com/hanzoteam/server/server/v8/channels/utils/testutils"
"github.com/hanzoteam/server/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(`
<html>
<head>
<meta property="og:image" content="` + serverURL + `/test-image3.png" />
<meta property="og:site_name" content="GitHub" />
<meta property="og:type" content="object" />
<meta property="og:title" content="hmhealey/test-files" />
<meta property="og:url" content="https://github.com/hmhealey/test-files" />
<meta property="og:description" content="Contribute to hmhealey/test-files development by creating an account on GitHub." />
</head>
</html>`))
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 ![our logo](%s/test-image2.png) and ![our icon](%s/test-image1.png)", 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: "![markdown](%s/test-image1.png) 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": "![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{})
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 = "atmos/camo"
*cfg.ImageProxySettings.RemoteImageProxyURL = "https://127.0.0.1"
*cfg.ImageProxySettings.RemoteImageProxyOptions = model.NewTestPassword()
})
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 := "![foo](%v)"
imageURL := "http://mydomain.com/myimage"
proxiedImageURL := "http://mymattermost.com/v1/workspace/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(`
<html>
<head>
<meta property="og:image" content="` + serverURL + `/test-image3.png" />
<meta property="og:site_name" content="GitHub" />
<meta property="og:type" content="object" />
<meta property="og:title" content="hmhealey/test-files" />
<meta property="og:url" content="https://github.com/hmhealey/test-files" />
<meta property="og:description" content="Contribute to hmhealey/test-files development by creating an account on GitHub." />
</head>
</html>`))
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/v1/workspace/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(`
<html>
<head>
<meta property="og:title" content="Title-FR" />
<meta property="og:description" content="Bonjour le monde" />
</head>
</html>`))
require.NoError(t, err)
} else {
_, err := w.Write([]byte(`
<html>
<head>
<meta property="og:title" content="Title" />
<meta property="og:description" content="Hello world" />
</head>
</html>`))
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(`
<html>
<head>
</head>
</html>`))
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: "![img](" + imageURL + ")",
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: "![img](" + imageURL + ")",
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 Hanzo Team 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 Hanzo Team 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 ![our logo](http://example.com/logo)",
ExpectedFirstLink: "",
ExpectedImages: []string{"http://example.com/logo"},
},
"multiple images": {
Input: "this is a ![our logo](http://example.com/logo) and ![their logo](http://example.com/logo2) and ![my logo](http://example.com/logo3)",
ExpectedFirstLink: "",
ExpectedImages: []string{"http://example.com/logo", "http://example.com/logo2", "http://example.com/logo3"},
},
"multiple images with duplicate": {
Input: "this is a ![our logo](http://example.com/logo) and ![their logo](http://example.com/logo2) and ![my logo which is their logo](http://example.com/logo2)",
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 ![our logo](https://example.com/logo)",
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 <http://example.com>",
ExpectedFirstLink: "http://example.com",
ExpectedImages: []string{},
},
"http link with only opening angle bracket": {
Input: "this is a <http://example.com",
ExpectedFirstLink: "http://example.com",
ExpectedImages: []string{},
},
"http link with only closing angle bracket": {
Input: "this is a http://example.com>",
ExpectedFirstLink: "http://example.com",
ExpectedImages: []string{},
},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, testCase.ExpectedFirstLink, th.App.getFirstLink(th.Context, testCase.Input))
assert.Equal(t, testCase.ExpectedImages, th.App.getImages(th.Context, testCase.Input))
})
}
for name, testCase := range map[string]struct {
Input string
ExpectedFirstLink string
ExpectedImages []string
}{
"http link domain is restricted": {
Input: "this is a http://example.com",
ExpectedFirstLink: "",
ExpectedImages: []string{},
},
"http link domain is not restricted": {
Input: "this is a http://example1.com",
ExpectedFirstLink: "http://example1.com",
ExpectedImages: []string{},
},
"www link domain is restricted": {
Input: "this is a www.example.com",
ExpectedFirstLink: "",
ExpectedImages: []string{},
},
"image domain is restricted": {
Input: "this is a ![our logo](http://example.com/logo)",
ExpectedFirstLink: "",
ExpectedImages: []string{},
},
"image domain is not restricted": {
Input: "this is a ![our logo](http://example1.com/logo)",
ExpectedFirstLink: "",
ExpectedImages: []string{"http://example1.com/logo"},
},
"multiple images is domain restricted": {
Input: "this is a ![our logo](http://example.com/logo) and ![their logo](http://example.com/logo2) and ![my logo](http://example.com/logo3)",
ExpectedFirstLink: "",
ExpectedImages: []string{},
},
"multiple images domain is not restricted": {
Input: "this is a ![our logo](http://example1.com/logo) and ![their logo](http://example1.com/logo2) and ![my logo](http://example1.com/logo3)",
ExpectedFirstLink: "",
ExpectedImages: []string{"http://example1.com/logo", "http://example1.com/logo2", "http://example1.com/logo3"},
},
"multiple images with duplicate domain is restricted": {
Input: "this is a ![our logo](http://example.com/logo) and ![their logo](http://example.com/logo2) and ![my logo which is their logo](http://example.com/logo2)",
ExpectedFirstLink: "",
ExpectedImages: []string{},
},
"reference image domain is restricted": {
Input: `this is a ![our logo][logo]
[logo]: http://example.com/logo`,
ExpectedFirstLink: "",
ExpectedImages: []string{},
},
"image and link domain is restricted": {
Input: "this is a https://example.com and ![our logo](https://example.com/logo)",
ExpectedFirstLink: "",
ExpectedImages: []string{},
},
"test with denied domain as username, password": {
Input: "link as http://example.com:[email protected]/link1",
ExpectedFirstLink: "http://example.com:[email protected]/link1",
ExpectedImages: []string{},
},
"test with URL encoded": {
Input: "link as https://example%E3%80%82com/link1",
ExpectedFirstLink: "",
ExpectedImages: []string{},
},
"test with unicode": {
Input: "link as https://example。com/link1",
ExpectedFirstLink: "",
ExpectedImages: []string{},
},
} {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.RestrictLinkPreviews = "example.com, test.com"
})
t.Run(name, func(t *testing.T) {
assert.Equal(t, testCase.ExpectedFirstLink, th.App.getFirstLink(th.Context, testCase.Input))
assert.Equal(t, testCase.ExpectedImages, th.App.getImages(th.Context, testCase.Input))
})
}
}
func TestGetLinkMetadata(t *testing.T) {
setup := func(t *testing.T) *TestHelper {
th := Setup(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1"
})
err := platform.PurgeLinkCache()
require.NoError(t, err)
return th
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
writeImage := func(height, width int) {
img := image.NewGray(image.Rect(0, 0, height, width))
var encoder png.Encoder
err := encoder.Encode(w, img)
require.NoError(t, err)
}
writeHTML := func(title string) {
w.Header().Set("Content-Type", "text/html")
_, err := w.Write([]byte(`
<html prefix="og:http://ogp.me/ns#">
<head>
<meta property="og:title" content="` + title + `" />
</head>
<body>
</body>
</html>`))
require.NoError(t, err)
}
if strings.HasPrefix(r.URL.Path, "/image") {
height, _ := strconv.ParseInt(params["height"][0], 10, 0)
width, _ := strconv.ParseInt(params["width"][0], 10, 0)
writeImage(int(height), int(width))
} else if strings.HasPrefix(r.URL.Path, "/opengraph") {
writeHTML(params["title"][0])
} else if strings.HasPrefix(r.URL.Path, "/json") {
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte("true"))
require.NoError(t, err)
} else if strings.HasPrefix(r.URL.Path, "/timeout") {
w.Header().Set("Content-Type", "text/html")
_, err := w.Write([]byte("<html>"))
require.NoError(t, err)
select {
case <-time.After(60 * time.Second):
case <-r.Context().Done():
}
_, err = w.Write([]byte("</html>"))
require.NoError(t, err)
} else if strings.HasPrefix(r.URL.Path, "/mixed") {
for _, acceptedType := range r.Header["Accept"] {
if strings.HasPrefix(acceptedType, "image/*") || strings.HasPrefix(acceptedType, "image/png") {
writeImage(10, 10)
} else if strings.HasPrefix(acceptedType, "text/html") {
writeHTML("mixed")
}
}
} else {
w.WriteHeader(http.StatusInternalServerError)
}
}))
defer server.Close()
t.Run("in-memory cache", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/cached"
timestamp := int64(1547510400000)
title := "from cache"
cacheLinkMetadata(th.Context, requestURL, timestamp, &opengraph.OpenGraph{Title: title}, nil, nil)
t.Run("should use cache if cached entry exists", func(t *testing.T) {
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.True(t, ok, "data should already exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
require.NotNil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
assert.Equal(t, title, og.Title)
})
t.Run("should use cache if cached entry exists near time", func(t *testing.T) {
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.True(t, ok, "data should already exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp+60*1000, false, "")
require.NotNil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
assert.Equal(t, title, og.Title)
})
t.Run("should not use cache if URL is different", func(t *testing.T) {
differentURL := server.URL + "/other"
_, _, _, ok := getLinkMetadataFromCache(differentURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(differentURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, differentURL, timestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
})
t.Run("should not use cache if timestamp is different", func(t *testing.T) {
differentTimestamp := timestamp + 60*60*1000
_, _, _, ok := getLinkMetadataFromCache(requestURL, differentTimestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, differentTimestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, differentTimestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
})
})
t.Run("database cache", func(t *testing.T) {
th := setup(t)
requestURL := server.URL
timestamp := int64(1547510400000)
title := "from database"
th.App.saveLinkMetadataToDatabase(requestURL, timestamp, &opengraph.OpenGraph{Title: title}, nil)
t.Run("should use database if saved entry exists", func(t *testing.T) {
err := platform.PurgeLinkCache()
require.NoError(t, err)
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.True(t, ok, "data should already exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
require.NotNil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
assert.Equal(t, title, og.Title)
})
t.Run("should use database if saved entry exists near time", func(t *testing.T) {
err := platform.PurgeLinkCache()
require.NoError(t, err)
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.True(t, ok, "data should already exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp+60*1000, false, "")
require.NotNil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
assert.Equal(t, title, og.Title)
})
t.Run("should not use database if URL is different", func(t *testing.T) {
err := platform.PurgeLinkCache()
require.NoError(t, err)
differentURL := requestURL + "/other"
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(differentURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, differentURL, timestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
})
t.Run("should not use database if timestamp is different", func(t *testing.T) {
err := platform.PurgeLinkCache()
require.NoError(t, err)
differentTimestamp := timestamp + 60*60*1000
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, differentTimestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, differentTimestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
})
})
t.Run("should get data from remote source", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/opengraph?title=Remote&name=" + t.Name()
timestamp := int64(1547510400000)
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.NotNil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
})
t.Run("should cache OpenGraph results", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/opengraph?title=Remote&name=" + t.Name()
timestamp := int64(1547510400000)
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.NotNil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
fromCache, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
assert.True(t, ok)
assert.Exactly(t, og, fromCache)
fromDatabase, _, ok := th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
assert.True(t, ok)
assert.Exactly(t, og, fromDatabase)
})
t.Run("should cache image results", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/image?height=300&width=400&name=" + t.Name()
timestamp := int64(1547510400000)
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.Nil(t, og)
assert.NotNil(t, img)
assert.NoError(t, err)
_, fromCache, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
assert.True(t, ok)
assert.Exactly(t, img, fromCache)
_, fromDatabase, ok := th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
assert.True(t, ok)
assert.Exactly(t, img, fromDatabase)
})
t.Run("should cache general errors", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/error"
timestamp := int64(1547510400000)
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
ogFromCache, imgFromCache, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
assert.True(t, ok)
assert.Nil(t, ogFromCache)
assert.Nil(t, imgFromCache)
ogFromDatabase, imageFromDatabase, ok := th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
assert.True(t, ok)
assert.Nil(t, ogFromDatabase)
assert.Nil(t, imageFromDatabase)
})
t.Run("should cache invalid URL errors", func(t *testing.T) {
th := setup(t)
requestURL := "http://notarealdomainthatactuallyexists.ca/?name=" + t.Name()
timestamp := int64(1547510400000)
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.IsType(t, &url.Error{}, err)
ogFromCache, imgFromCache, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
assert.True(t, ok)
assert.Nil(t, ogFromCache)
assert.Nil(t, imgFromCache)
ogFromDatabase, imageFromDatabase, ok := th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
assert.True(t, ok)
assert.Nil(t, ogFromDatabase)
assert.Nil(t, imageFromDatabase)
})
t.Run("should cache timeout errors", func(t *testing.T) {
th := setup(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ExperimentalSettings.LinkMetadataTimeoutMilliseconds = 100
})
requestURL := server.URL + "/timeout?name=" + t.Name()
timestamp := int64(1547510400000)
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.False(t, ok, "data should not exist in database")
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.Error(t, err)
assert.True(t, os.IsTimeout(err))
ogFromCache, imgFromCache, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
assert.True(t, ok)
assert.Nil(t, ogFromCache)
assert.Nil(t, imgFromCache)
ogFromDatabase, imageFromDatabase, ok := th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
assert.True(t, ok)
assert.Nil(t, ogFromDatabase)
assert.Nil(t, imageFromDatabase)
})
t.Run("should cache database results in memory", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/image?height=300&width=400&name=" + t.Name()
timestamp := int64(1547510400000)
_, _, _, ok := getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should not exist in in-memory cache")
_, _, ok = th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
require.False(t, ok, "data should not exist in database")
_, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
require.NoError(t, err)
_, _, _, ok = getLinkMetadataFromCache(requestURL, timestamp)
require.True(t, ok, "data should now exist in in-memory cache")
err = platform.PurgeLinkCache()
require.NoError(t, err)
_, _, _, ok = getLinkMetadataFromCache(requestURL, timestamp)
require.False(t, ok, "data should no longer exist in in-memory cache")
_, fromDatabase, ok := th.App.getLinkMetadataFromDatabase(requestURL, timestamp)
assert.True(t, ok, "data should be be in in-memory cache again")
assert.Exactly(t, img, fromDatabase)
})
t.Run("should reject non-html, non-image response", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/json?name=" + t.Name()
timestamp := int64(1547510400000)
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
})
t.Run("should check in-memory cache for new post", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/error?name=" + t.Name()
timestamp := int64(1547510400000)
cacheLinkMetadata(th.Context, requestURL, timestamp, &opengraph.OpenGraph{Title: "cached"}, nil, nil)
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, true, "")
assert.NotNil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
})
t.Run("should skip database cache for new post", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/error?name=" + t.Name()
timestamp := int64(1547510400000)
th.App.saveLinkMetadataToDatabase(requestURL, timestamp, &opengraph.OpenGraph{Title: "cached"}, nil)
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, true, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.NoError(t, err)
})
t.Run("should resolve relative URL", func(t *testing.T) {
th := setup(t)
// Fake the SiteURL to have the relative URL resolve to the external server
oldSiteURL := *th.App.Config().ServiceSettings.SiteURL
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = oldSiteURL
})
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = server.URL
})
requestURL := "/image?height=200&width=300&name=" + t.Name()
timestamp := int64(1547510400000)
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.Nil(t, og)
assert.NotNil(t, img)
assert.NoError(t, err)
})
t.Run("should error on local addresses other than the image proxy", func(t *testing.T) {
th := setup(t)
// Disable AllowedUntrustedInternalConnections since it's turned on for the previous tests
oldAllowUntrusted := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
oldSiteURL := *th.App.Config().ServiceSettings.SiteURL
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = oldAllowUntrusted
*cfg.ServiceSettings.SiteURL = oldSiteURL
})
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = ""
*cfg.ServiceSettings.SiteURL = "http://team.example.com"
*cfg.ImageProxySettings.Enable = true
*cfg.ImageProxySettings.ImageProxyType = "local"
})
requestURL := server.URL + "/image?height=200&width=300&name=" + t.Name()
timestamp := int64(1547510400000)
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.Error(t, err)
assert.IsType(t, &url.Error{}, err)
assert.ErrorContains(t, err, httpservice.ErrAddressForbidden.Error())
requestURL = th.App.GetSiteURL() + "/v1/workspace/image?url=" + url.QueryEscape(requestURL)
// Note that this request still fails while testing because the request made by the image proxy is blocked
og, img, _, err = th.App.getLinkMetadata(th.Context, requestURL, timestamp, false, "")
assert.Nil(t, og)
assert.Nil(t, img)
assert.Error(t, err)
assert.IsType(t, imageproxy.Error{}, err)
})
t.Run("should prefer images for mixed content", func(t *testing.T) {
th := setup(t)
requestURL := server.URL + "/mixed?name=" + t.Name()
timestamp := int64(1547510400000)
og, img, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, true, "")
assert.Nil(t, og)
assert.NotNil(t, img)
assert.NoError(t, err)
})
t.Run("should throw error if post doesn't exist", func(t *testing.T) {
th := setup(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnablePermalinkPreviews = true
*cfg.ServiceSettings.SiteURL = server.URL
})
requestURL := server.URL + "/team/pl/5rpoy4o3nbgwjm7gs4cm71h6ho"
timestamp := int64(1547510400000)
_, _, _, err := th.App.getLinkMetadata(th.Context, requestURL, timestamp, true, "")
assert.Error(t, err)
})
t.Run("from cache", func(t *testing.T) {
testURL := "https://example.com/test"
testTimestamp := int64(1640995200000) // 2022-01-01 00:00:00 UTC
setup := func(t *testing.T) {
err := platform.PurgeLinkCache()
require.NoError(t, err)
}
assertCached := func(t *testing.T, url string, expectedOG *opengraph.OpenGraph, expectedImage *model.PostImage, expectedPermalink *model.Permalink) {
og, image, permalink, found := getLinkMetadataFromCache(url, testTimestamp)
assert.True(t, found)
assert.Equal(t, expectedOG, og)
assert.Equal(t, expectedImage, image)
assert.Equal(t, expectedPermalink, permalink)
}
assertNotCached := func(t *testing.T, url string) {
og, image, permalink, found := getLinkMetadataFromCache(url, testTimestamp)
assert.False(t, found)
assert.Nil(t, og)
assert.Nil(t, image)
assert.Nil(t, permalink)
}
t.Run("should return false when cache is empty", func(t *testing.T) {
setup(t)
assertNotCached(t, testURL)
})
t.Run("should return cached data when URL matches", func(t *testing.T) {
setup(t)
expectedOG := &opengraph.OpenGraph{
Title: "Test Title",
URL: testURL,
}
expectedImage := &model.PostImage{
Width: 100,
Height: 200,
}
expectedPermalink := &model.Permalink{
PreviewPost: &model.PreviewPost{
PostID: "test-post-id",
},
}
ctx := request.TestContext(t)
cacheLinkMetadata(ctx, testURL, testTimestamp, expectedOG, expectedImage, expectedPermalink)
assertCached(t, testURL, expectedOG, expectedImage, expectedPermalink)
})
t.Run("should return false when different url not cached", func(t *testing.T) {
setup(t)
cachedURL := "https://example.com/cached"
requestedURL := "https://example.com/different"
expectedOG := &opengraph.OpenGraph{
Title: "Cached Title",
URL: cachedURL,
}
ctx := request.TestContext(t)
cacheLinkMetadata(ctx, cachedURL, testTimestamp, expectedOG, nil, nil)
assertNotCached(t, requestedURL)
})
t.Run("should return false when different url not cached, even if hash collides with a cached url", func(t *testing.T) {
setup(t)
url1 := "http://test.com/w4xg6hpvomau9j5iz371"
url2 := "http://collision.comupio5zw28x1m36c"
hash1 := model.GenerateLinkMetadataHash(url1, testTimestamp)
hash2 := model.GenerateLinkMetadataHash(url2, testTimestamp)
assert.Equal(t, hash1, hash2, "URLs should have colliding hashes")
og1 := &opengraph.OpenGraph{
Title: "First URL Title",
URL: url1,
}
ctx := request.TestContext(t)
cacheLinkMetadata(ctx, url1, testTimestamp, og1, nil, nil)
assertCached(t, url1, og1, nil, nil)
assertNotCached(t, url2)
})
t.Run("should handle cached nil values correctly", func(t *testing.T) {
setup(t)
nilURL := "https://example.com/nil-test"
ctx := request.TestContext(t)
cacheLinkMetadata(ctx, nilURL, testTimestamp, nil, nil, nil)
assertCached(t, nilURL, nil, nil, nil)
})
})
}
func TestResolveMetadataURL(t *testing.T) {
mainHelper.Parallel(t)
for _, test := range []struct {
Name string
RequestURL string
SiteURL string
Expected string
}{
{
Name: "with HTTPS",
RequestURL: "https://example.com/file?param=1",
Expected: "https://example.com/file?param=1",
},
{
Name: "with HTTP",
RequestURL: "http://example.com/file?param=1",
Expected: "http://example.com/file?param=1",
},
{
Name: "with FTP",
RequestURL: "ftp://example.com/file?param=1",
Expected: "ftp://example.com/file?param=1",
},
{
Name: "relative to root",
RequestURL: "/file?param=1",
SiteURL: "https://team.example.com:123",
Expected: "https://team.example.com:123/file?param=1",
},
{
Name: "relative to root with subpath",
RequestURL: "/file?param=1",
SiteURL: "https://team.example.com:123/subpath",
Expected: "https://team.example.com:123/file?param=1",
},
} {
t.Run(test.Name, func(t *testing.T) {
assert.Equal(t, resolveMetadataURL(test.RequestURL, test.SiteURL), test.Expected)
})
}
}
func TestParseLinkMetadata(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
imageURL := "http://example.com/test.png"
file, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
ogURL := "https://example.com/hello"
html := `
<html>
<head>
<meta property="og:title" content="Hello, World!">
<meta property="og:type" content="object">
<meta property="og:url" content="` + ogURL + `">
</head>
</html>`
makeImageReader := func() io.Reader {
return bytes.NewReader(file)
}
makeOpenGraphReader := func() io.Reader {
return strings.NewReader(html)
}
t.Run("image", func(t *testing.T) {
og, dimensions, err := th.App.parseLinkMetadata(th.Context, imageURL, makeImageReader(), "image/png")
assert.NoError(t, err)
assert.Nil(t, og)
assert.Equal(t, &model.PostImage{
Format: "png",
Width: 408,
Height: 336,
}, dimensions)
})
t.Run("image with no content-type given", func(t *testing.T) {
og, dimensions, err := th.App.parseLinkMetadata(th.Context, imageURL, makeImageReader(), "")
assert.NoError(t, err)
assert.Nil(t, og)
assert.Equal(t, &model.PostImage{
Format: "png",
Width: 408,
Height: 336,
}, dimensions)
})
t.Run("malformed image", func(t *testing.T) {
og, dimensions, err := th.App.parseLinkMetadata(th.Context, imageURL, makeOpenGraphReader(), "image/png")
assert.Error(t, err)
assert.Nil(t, og)
assert.Nil(t, dimensions)
})
t.Run("opengraph", func(t *testing.T) {
og, dimensions, err := th.App.parseLinkMetadata(th.Context, ogURL, makeOpenGraphReader(), "text/html; charset=utf-8")
assert.NoError(t, err)
assert.NotNil(t, og)
assert.Equal(t, og.Title, "Hello, World!")
assert.Equal(t, og.Type, "object")
assert.Equal(t, og.URL, ogURL)
assert.Nil(t, dimensions)
})
t.Run("malformed opengraph", func(t *testing.T) {
og, dimensions, err := th.App.parseLinkMetadata(th.Context, ogURL, makeImageReader(), "text/html; charset=utf-8")
assert.NoError(t, err)
assert.Nil(t, og)
assert.Nil(t, dimensions)
})
t.Run("neither", func(t *testing.T) {
og, dimensions, err := th.App.parseLinkMetadata(th.Context, "http://example.com/test.wad", strings.NewReader("garbage"), "application/x-doom")
assert.NoError(t, err)
assert.Nil(t, og)
assert.Nil(t, dimensions)
})
t.Run("svg", func(t *testing.T) {
og, dimensions, err := th.App.parseLinkMetadata(th.Context, "http://example.com/image.svg", nil, "image/svg+xml")
assert.NoError(t, err)
assert.Nil(t, og)
assert.Nil(t, dimensions)
})
t.Run("svg with charset", func(t *testing.T) {
og, dimensions, err := th.App.parseLinkMetadata(th.Context, "http://example.com/image.svg", nil, "image/svg+xml; charset=utf-8")
assert.NoError(t, err)
assert.Nil(t, og)
assert.Nil(t, dimensions)
})
}
func TestParseImages(t *testing.T) {
mainHelper.Parallel(t)
for name, testCase := range map[string]struct {
FileName string
Expected *model.PostImage
ExpectError bool
}{
"png": {
FileName: "test.png",
Expected: &model.PostImage{
Width: 408,
Height: 336,
Format: "png",
},
},
"jpg-1": {
FileName: "orientation_test_1.jpeg",
Expected: &model.PostImage{
Width: 2860,
Height: 1578,
Format: "jpeg",
},
},
"jpg-2": {
FileName: "orientation_test_2.jpeg",
Expected: &model.PostImage{
Width: 2860,
Height: 1578,
Format: "jpeg",
},
},
"jpg-3": {
FileName: "orientation_test_3.jpeg",
Expected: &model.PostImage{
Width: 2860,
Height: 1578,
Format: "jpeg",
},
},
"jpg-4": {
FileName: "orientation_test_4.jpeg",
Expected: &model.PostImage{
Width: 2860,
Height: 1578,
Format: "jpeg",
},
},
"jpg-5": {
FileName: "orientation_test_5.jpeg",
Expected: &model.PostImage{
Width: 2860,
Height: 1578,
Format: "jpeg",
},
},
"jpg-6": {
FileName: "orientation_test_6.jpeg",
Expected: &model.PostImage{
Width: 2860,
Height: 1578,
Format: "jpeg",
},
},
"jpg-7": {
FileName: "orientation_test_7.jpeg",
Expected: &model.PostImage{
Width: 2860,
Height: 1578,
Format: "jpeg",
},
},
"jpg-8": {
FileName: "orientation_test_8.jpeg",
Expected: &model.PostImage{
Width: 2860,
Height: 1578,
Format: "jpeg",
},
},
"jpg-9": {
FileName: "orientation_test_9.jpeg",
Expected: &model.PostImage{
Width: 4000,
Height: 2667,
Format: "jpeg",
},
},
"animated gif": {
FileName: "testgif.gif",
Expected: &model.PostImage{
Width: 118,
Height: 118,
Format: "gif",
FrameCount: 4,
},
},
"tiff": {
FileName: "test.tiff",
Expected: (*model.PostImage)(nil),
},
"not an image": {
FileName: "README.md",
ExpectError: true,
},
} {
t.Run(name, func(t *testing.T) {
file, err := testutils.ReadTestFile(testCase.FileName)
require.NoError(t, err)
result, err := parseImages(request.EmptyContext(mlog.CreateConsoleTestLogger(t)), "", bytes.NewReader(file))
if testCase.ExpectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, testCase.Expected, result)
}
})
}
}
func TestLooksLikeAPermalink(t *testing.T) {
mainHelper.Parallel(t)
const siteURLWithSubpath = "http://localhost:8065/foo"
const siteURLWithTrailingSlash = "http://test.com/"
const siteURL = "http://test.com"
tests := map[string]struct {
input string
siteURL string
expect bool
}{
"happy path": {input: fmt.Sprintf("%s/private-core/pl/dppezk51jp8afbhwxf1jpag66r", siteURLWithSubpath), siteURL: siteURLWithSubpath, expect: true},
"happy path redirect": {input: fmt.Sprintf("%s/_redirect/pl/dppezk51jp8afbhwxf1jpag66r", siteURLWithSubpath), siteURL: siteURLWithSubpath, expect: true},
"looks nothing like a permalink": {input: "foobar", siteURL: siteURLWithSubpath, expect: false},
"link has no subpath": {input: fmt.Sprintf("%s/private-core/pl/dppezk51jp8afbhwxf1jpag66r", "http://localhost:8065"), siteURL: siteURLWithSubpath, expect: false},
"without port": {input: fmt.Sprintf("%s/private-core/pl/dppezk51jp8afbhwxf1jpag66r", "http://localhost/foo"), siteURL: siteURLWithSubpath, expect: false},
"wrong port": {input: fmt.Sprintf("%s/private-core/pl/dppezk51jp8afbhwxf1jpag66r", "http://localhost:8066"), siteURL: siteURLWithSubpath, expect: false},
"invalid post ID length": {input: fmt.Sprintf("%s/private-core/pl/dppezk51jp8afbhwxf1jpag66", siteURLWithSubpath), siteURL: siteURLWithSubpath, expect: false},
"invalid post ID character": {input: fmt.Sprintf("%s/private-core/pl/dppezk51jp8$fbhwxf1jpag66r", siteURLWithSubpath), siteURL: siteURLWithSubpath, expect: false},
"leading whitespace": {input: fmt.Sprintf(" %s/private-core/pl/dppezk51jp8afbhwxf1jpag66r", siteURLWithSubpath), siteURL: siteURLWithSubpath, expect: true},
"trailing whitespace": {input: fmt.Sprintf("%s/private-core/pl/dppezk51jp8afbhwxf1jpag66r ", siteURLWithSubpath), siteURL: siteURLWithSubpath, expect: true},
"siteURL without a subpath": {input: fmt.Sprintf("%sprivate-core/pl/dppezk51jp8afbhwxf1jpag66r", siteURLWithTrailingSlash), siteURL: siteURLWithTrailingSlash, expect: true},
"siteURL without a trailing slash": {input: fmt.Sprintf("%s/private-core/pl/dppezk51jp8afbhwxf1jpag66r", siteURL), siteURL: siteURL, expect: true},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
actual := looksLikeAPermalink(tc.input, tc.siteURL)
assert.Equal(t, tc.expect, actual)
})
}
}
func TestContainsPermalink(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
const siteURLWithSubpath = "http://localhost:8065/foo"
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = siteURLWithSubpath
})
testCases := []struct {
Description string
Post *model.Post
Expected bool
}{
{
Description: "contains a permalink",
Post: &model.Post{
Message: fmt.Sprintf("%s/private-core/pl/dppezk51jp8afbhwxf1jpag66r", siteURLWithSubpath),
},
Expected: true,
},
{
Description: "does not contain a permalink",
Post: &model.Post{
Message: "foobar",
},
Expected: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
actual := th.App.containsPermalink(th.Context, testCase.Post)
assert.Equal(t, testCase.Expected, actual)
})
}
}
func TestSanitizePostMetaDataForAudit(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(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)
// ensure the permalink metadata is sanitized for audit logging
m := clientPost.Auditable()
metaDataI, ok := m["metadata"]
require.True(t, ok)
metaData, ok := metaDataI.(map[string]any)
require.True(t, ok)
embedsI, ok := metaData["embeds"]
require.True(t, ok)
embeds, ok := embedsI.([]map[string]any)
require.True(t, ok)
for _, pe := range embeds {
// ensure all the PostEmbed maps only contain `type` and `url`
for k := range pe {
if k != "type" && k != "url" {
require.Fail(t, "PostEmbed should only contain 'type' and 'url fields'")
}
}
}
}
func TestSanitizePostMetadataForUser(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
enableLinkPreviews := *th.App.Config().ServiceSettings.EnableLinkPreviews
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.EnableLinkPreviews = &enableLinkPreviews
})
}()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableLinkPreviews = true
})
t.Run("should remove embeds for not accessible channels", func(t *testing.T) {
privateChannel, err := th.App.CreateChannel(th.Context, &model.Channel{
Name: model.NewId(),
Type: model.ChannelTypePrivate,
TeamId: th.BasicTeam.Id,
CreatorId: th.SystemAdminUser.Id,
}, true)
require.Nil(t, err)
require.NotEmpty(t, privateChannel.Id)
post := &model.Post{
Id: "post_id_1",
UserId: th.BasicUser.Id,
Metadata: &model.PostMetadata{
Embeds: []*model.PostEmbed{
{
Type: model.PostEmbedPermalink,
Data: &model.PreviewPost{
PostID: "permalink_post_id",
Post: &model.Post{
Id: "permalink_post_id",
Message: "permalink post message",
ChannelId: privateChannel.Id,
},
},
},
{
Type: model.PostEmbedPermalink,
Data: &model.PreviewPost{
PostID: "permalink_post_id_2",
Post: &model.Post{
Id: "permalink_post_id_2",
Message: "permalink post message 2",
ChannelId: privateChannel.Id,
},
},
},
{
Type: model.PostEmbedLink,
URL: "https://hanzo.ai",
},
},
},
}
sanitizedPost, isMemberForPreviews, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id)
require.Nil(t, err)
require.NotNil(t, sanitizedPost)
require.Equal(t, 1, len(sanitizedPost.Metadata.Embeds))
require.Equal(t, model.PostEmbedLink, sanitizedPost.Metadata.Embeds[0].Type)
require.True(t, isMemberForPreviews)
})
t.Run("should not remove embeds for archived channels", func(t *testing.T) {
publicChannel, err := th.App.CreateChannel(th.Context, &model.Channel{
Name: model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.SystemAdminUser.Id,
}, true)
require.Nil(t, err)
require.NotEmpty(t, publicChannel.Id)
err = th.App.DeleteChannel(th.Context, publicChannel, th.SystemAdminUser.Id)
require.Nil(t, err)
publicChannel, err = th.App.GetChannel(th.Context, publicChannel.Id)
require.Nil(t, err)
require.NotEmpty(t, publicChannel.Id)
require.NotEqual(t, 0, publicChannel.DeleteAt)
post := &model.Post{
Id: "post_id_1",
UserId: th.BasicUser.Id,
Metadata: &model.PostMetadata{
Embeds: []*model.PostEmbed{
{
Type: model.PostEmbedPermalink,
Data: &model.PreviewPost{
PostID: "permalink_post_id",
Post: &model.Post{
Id: "permalink_post_id",
Message: "permalink post message",
ChannelId: publicChannel.Id,
},
},
},
{
Type: model.PostEmbedLink,
URL: "https://hanzo.ai",
},
},
},
}
sanitizedPost, isMemberForPreviews, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id)
require.Nil(t, err)
require.NotNil(t, sanitizedPost)
require.Equal(t, 2, len(sanitizedPost.Metadata.Embeds))
require.Equal(t, model.PostEmbedPermalink, sanitizedPost.Metadata.Embeds[0].Type)
require.False(t, isMemberForPreviews)
})
t.Run("permalink embed with PreviewPost type does not panic", func(t *testing.T) {
// Verify that the code correctly type-asserts embed.Data as *model.PreviewPost
// (not *model.Permalink) and does not panic.
refChannelID := th.BasicChannel.Id
fileID := model.NewId()
post := &model.Post{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Metadata: &model.PostMetadata{
Embeds: []*model.PostEmbed{
{
Type: model.PostEmbedPermalink,
Data: &model.PreviewPost{
PostID: model.NewId(),
Post: &model.Post{
Id: model.NewId(),
ChannelId: refChannelID,
FileIds: model.StringArray{fileID},
Metadata: &model.PostMetadata{
Files: []*model.FileInfo{
{Id: fileID, Name: "photo.png", Extension: "png"},
},
},
},
},
},
},
},
}
// With AccessControl == nil (no enterprise), sanitizeFileAttachmentsForUser returns early.
// The key assertion is that the function does NOT panic on the *model.PreviewPost type assertion.
sanitizedPost, isMemberForPreviews, appErr := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id)
require.Nil(t, appErr)
require.NotNil(t, sanitizedPost)
require.True(t, isMemberForPreviews)
// Without enterprise ABAC, files should remain untouched.
require.Len(t, sanitizedPost.Metadata.Embeds, 1)
previewData, ok := sanitizedPost.Metadata.Embeds[0].Data.(*model.PreviewPost)
require.True(t, ok)
assert.Len(t, previewData.Post.Metadata.Files, 1, "files should be preserved when ABAC is not active")
assert.Len(t, previewData.Post.FileIds, 1)
})
t.Run("post with no top-level files still reaches embed sanitization without error", func(t *testing.T) {
// Verifies that the guard removal in SanitizePostMetadataForUser allows sanitization
// to proceed even when the post itself has no files, only permalink embeds with files.
post := &model.Post{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
FileIds: model.StringArray{},
Metadata: &model.PostMetadata{
Files: nil,
Embeds: []*model.PostEmbed{
{
Type: model.PostEmbedPermalink,
Data: &model.PreviewPost{
PostID: model.NewId(),
Post: &model.Post{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
FileIds: model.StringArray{model.NewId()},
Metadata: &model.PostMetadata{
Files: []*model.FileInfo{
{Id: model.NewId(), Name: "doc.pdf", Extension: "pdf"},
},
},
},
},
},
},
},
}
sanitizedPost, _, appErr := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id)
require.Nil(t, appErr)
require.NotNil(t, sanitizedPost)
// Without enterprise ABAC, the embed files should remain.
previewData, ok := sanitizedPost.Metadata.Embeds[0].Data.(*model.PreviewPost)
require.True(t, ok)
assert.NotNil(t, previewData.Post.Metadata.Files, "embed files should not be stripped without ABAC")
})
}
func TestPreparePostForClient_BurnOnReadSenderExpireAt(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable BurnOnRead feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.BurnOnRead = true })
// Enable Enterprise Advanced license and BoR config
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.EnableBurnOnRead = new(true)
})
// Create a burn-on-read post
borPost := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "test burn on read",
Type: model.PostTypeBurnOnRead,
}
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*60*1000))
post, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
// Simulate recipient revealing the post
user2 := th.CreateUser(t)
th.LinkUserToTeam(t, user2, th.BasicTeam)
th.AddUserToChannel(t, user2, th.BasicChannel)
_, appErr = th.App.RevealPost(th.Context, post, user2.Id, "")
require.Nil(t, appErr)
// Get the read receipt to get the expireAt
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, post.Id, user2.Id)
require.NoError(t, err)
// Update temporary post with sender's expireAt (simulating all recipients revealed)
tmpPost, err := th.App.Srv().Store().TemporaryPost().Get(th.Context, post.Id, true)
require.NoError(t, err)
tmpPost.ExpireAt = receipt.ExpireAt
_, err = th.App.Srv().Store().TemporaryPost().Save(th.Context, tmpPost)
require.NoError(t, err)
// Create a new context for the sender (post author) with proper session
senderContext := th.Context
senderContext.Session().UserId = th.BasicUser.Id
// Fetch the post without ExpireAt in metadata (simulating page reload)
postFromDB, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
require.Nil(t, appErr)
postFromDB.Metadata = &model.PostMetadata{} // Reset metadata to simulate fresh fetch
// PreparePostForClient should fill ExpireAt from TemporaryPost for the sender
preparedPost := th.App.PreparePostForClient(senderContext, postFromDB, &model.PreparePostForClientOpts{})
// Verify that ExpireAt is now set in metadata for the sender
require.NotNil(t, preparedPost.Metadata)
require.NotZero(t, preparedPost.Metadata.ExpireAt)
require.Equal(t, receipt.ExpireAt, preparedPost.Metadata.ExpireAt)
}
func TestSanitizeChannelMentionsForUser(t *testing.T) {
mainHelper.Parallel(t)
t.Run("post with no channel mentions", func(t *testing.T) {
th := Setup(t).InitBasic(t)
post := &model.Post{
Message: "Regular post with no mentions",
}
result, _, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id)
require.Nil(t, err)
require.NotNil(t, result)
require.Nil(t, result.GetProp(model.PostPropsChannelMentions))
})
t.Run("removes inaccessible channel mentions", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Create a channel on a different team user is not member of
team2, err := th.App.CreateTeam(th.Context, &model.Team{
DisplayName: "Team 2",
Name: "team-2",
Type: model.TeamOpen,
})
require.Nil(t, err)
channel, err := th.App.CreateChannel(th.Context, &model.Channel{
TeamId: team2.Id,
Name: "test-channel",
DisplayName: "Test Channel",
Type: model.ChannelTypeOpen,
}, false)
require.Nil(t, err)
post := &model.Post{
Message: "Check ~test-channel",
}
post.AddProp(model.PostPropsChannelMentions, map[string]any{
channel.Name: map[string]any{
"display_name": channel.DisplayName,
"team_name": team2.Name,
},
})
result, _, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id)
require.Nil(t, err)
require.NotNil(t, result)
// Should remove the mention since user can't access the team
require.Nil(t, result.GetProp(model.PostPropsChannelMentions))
})
t.Run("includes accessible public channel mentions on retrieval", func(t *testing.T) {
th := Setup(t).InitBasic(t)
post := &model.Post{
Message: "Check ~" + th.BasicChannel.Name,
}
post.AddProp(model.PostPropsChannelMentions, map[string]any{
th.BasicChannel.Name: map[string]any{
"display_name": th.BasicChannel.DisplayName,
"team_name": th.BasicTeam.Name,
},
})
result, _, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id)
require.Nil(t, err)
require.NotNil(t, result)
mentions := result.GetProp(model.PostPropsChannelMentions)
require.NotNil(t, mentions)
mentionsMap, ok := mentions.(map[string]any)
require.True(t, ok)
require.Contains(t, mentionsMap, th.BasicChannel.Name)
})
t.Run("uses fresh display name from database on retrieval", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Post has stale data
post := &model.Post{
Message: "Check ~" + th.BasicChannel.Name,
}
post.AddProp(model.PostPropsChannelMentions, map[string]any{
th.BasicChannel.Name: map[string]any{
"display_name": "Stale Display Name",
"team_name": th.BasicTeam.Name,
},
})
result, _, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id)
require.Nil(t, err)
require.NotNil(t, result)
mentions := result.GetProp(model.PostPropsChannelMentions)
require.NotNil(t, mentions)
mentionsMap, ok := mentions.(map[string]any)
require.True(t, ok)
channelData, ok := mentionsMap[th.BasicChannel.Name].(map[string]any)
require.True(t, ok)
// Should have current display name from database, not stale data
require.Equal(t, th.BasicChannel.DisplayName, channelData["display_name"])
})
t.Run("retains same-team public channel mention for non-channel-member under compliance", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ComplianceSettings.Enable = model.NewPointer(true)
})
// BasicUser2 is a member of BasicTeam (via InitBasic) but NOT of this fresh channel.
ch := th.CreateChannel(t, th.BasicTeam)
post := &model.Post{
Message: "Check ~" + ch.Name,
}
post.AddProp(model.PostPropsChannelMentions, map[string]any{
ch.Name: map[string]any{
"display_name": ch.DisplayName,
"team_name": th.BasicTeam.Name,
},
})
result, _, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser2.Id)
require.Nil(t, err)
require.NotNil(t, result)
mentions := result.GetProp(model.PostPropsChannelMentions)
require.NotNil(t, mentions)
mentionsMap, ok := mentions.(map[string]any)
require.True(t, ok)
require.Contains(t, mentionsMap, ch.Name)
})
t.Run("strips cross-team public channel mention for non-member regardless of compliance", func(t *testing.T) {
for _, compliance := range []bool{true, false} {
t.Run(fmt.Sprintf("compliance=%t", compliance), func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ComplianceSettings.Enable = model.NewPointer(compliance)
})
team2 := th.CreateTeam(t)
ch := th.CreateChannel(t, team2)
post := &model.Post{
Message: "Check ~" + ch.Name,
}
post.AddProp(model.PostPropsChannelMentions, map[string]any{
ch.Name: map[string]any{
"display_name": ch.DisplayName,
"team_name": team2.Name,
},
})
result, _, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser2.Id)
require.Nil(t, err)
require.NotNil(t, result)
require.Nil(t, result.GetProp(model.PostPropsChannelMentions))
})
}
})
t.Run("strips private channel mention for non-member regardless of compliance", func(t *testing.T) {
for _, compliance := range []bool{true, false} {
t.Run(fmt.Sprintf("compliance=%t", compliance), func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ComplianceSettings.Enable = model.NewPointer(compliance)
})
priv := th.CreatePrivateChannel(t, th.BasicTeam)
_ = th.App.RemoveUserFromChannel(th.Context, th.BasicUser2.Id, "", priv)
post := &model.Post{
Message: "Check ~" + priv.Name,
}
post.AddProp(model.PostPropsChannelMentions, map[string]any{
priv.Name: map[string]any{
"display_name": priv.DisplayName,
"team_name": th.BasicTeam.Name,
},
})
result, _, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser2.Id)
require.Nil(t, err)
require.NotNil(t, result)
require.Nil(t, result.GetProp(model.PostPropsChannelMentions))
})
}
})
}
func TestFillInPostPropsWithCurrentTeamId(t *testing.T) {
mainHelper.Parallel(t)
t.Run("uses current_team_id for DM channel mention resolution", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Create a second team with a channel named "engineering"
team2, err := th.App.CreateTeam(th.Context, &model.Team{
DisplayName: "Team 2",
Name: "team2",
Type: model.TeamOpen,
})
require.Nil(t, err)
th.LinkUserToTeam(t, th.BasicUser, team2)
channel2, err := th.App.CreateChannel(th.Context, &model.Channel{
TeamId: team2.Id,
Name: "engineering",
DisplayName: "Engineering Team 2",
Type: model.ChannelTypeOpen,
}, false)
require.Nil(t, err)
// Also create channel with same name on BasicTeam
channel1, err := th.App.CreateChannel(th.Context, &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "engineering",
DisplayName: "Engineering Team 1",
Type: model.ChannelTypeOpen,
}, false)
require.Nil(t, err)
// Create a DM channel
dmChannel, err := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
require.Nil(t, err)
// Create post in DM with channel mention and current_team_id
post := &model.Post{
ChannelId: dmChannel.Id,
UserId: th.BasicUser.Id,
Message: "Check ~engineering",
}
post.AddProp(model.PostPropsCurrentTeamId, team2.Id)
// Fill in post props
appErr := th.App.FillInPostProps(th.Context, post, dmChannel)
require.Nil(t, appErr)
// Verify current_team_id was removed
require.Nil(t, post.GetProp(model.PostPropsCurrentTeamId))
// Verify channel mentions were populated from team2, not BasicTeam
mentions := post.GetProp(model.PostPropsChannelMentions)
require.NotNil(t, mentions)
mentionsMap, ok := mentions.(map[string]any)
require.True(t, ok)
channelData, ok := mentionsMap["engineering"].(map[string]any)
require.True(t, ok)
require.Equal(t, channel2.DisplayName, channelData["display_name"])
require.Equal(t, team2.Name, channelData["team_name"])
require.NotEqual(t, channel1.DisplayName, channelData["display_name"]) // Should NOT be from team1
})
t.Run("omits channel mentions from interactive blocks when mmBlocksEnabled is false", func(t *testing.T) {
th := Setup(t).InitBasic(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 })
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "plain text only",
Props: model.StringInterface{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "text", "text": "see ~off-topic in blocks"},
},
},
}
appErr := th.App.FillInPostProps(th.Context, post, th.BasicChannel)
require.Nil(t, appErr)
assert.Nil(t, post.GetProp(model.PostPropsChannelMentions))
})
t.Run("uses channel team_id for regular channels, ignores current_team_id", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Create a post in a regular team channel
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "Check ~off-topic",
}
// Add current_team_id (should be ignored for team channels)
post.AddProp(model.PostPropsCurrentTeamId, "some-other-team")
// Fill in post props
appErr := th.App.FillInPostProps(th.Context, post, th.BasicChannel)
require.Nil(t, appErr)
// Verify current_team_id was removed
require.Nil(t, post.GetProp(model.PostPropsCurrentTeamId))
// Should use BasicChannel's team_id, not current_team_id
mentions := post.GetProp(model.PostPropsChannelMentions)
if mentions != nil {
mentionsMap, ok := mentions.(map[string]any)
require.True(t, ok)
// If off-topic was found, it should be from BasicTeam
if channelData, exists := mentionsMap["off-topic"].(map[string]any); exists {
require.Equal(t, th.BasicTeam.Name, channelData["team_name"])
}
}
})
}