Files
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

6760 lines
227 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/plugin/plugintest/mock"
"github.com/hanzoteam/server/server/public/shared/mlog"
"github.com/hanzoteam/server/server/v8/channels/store"
storemocks "github.com/hanzoteam/server/server/v8/channels/store/storetest/mocks"
"github.com/hanzoteam/server/server/v8/channels/testlib"
"github.com/hanzoteam/server/server/v8/platform/services/imageproxy"
"github.com/hanzoteam/server/server/v8/platform/services/searchengine/mocks"
)
func enableBoRFeature(th *TestHelper) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.EnableBurnOnRead = new(true)
})
}
func makePendingPostId(user *model.User) string {
return fmt.Sprintf("%s:%s", user.Id, strconv.FormatInt(model.GetMillis(), 10))
}
func TestCreatePostDeduplicate(t *testing.T) {
th := Setup(t).InitBasic(t)
t.Run("duplicate create post is idempotent", func(t *testing.T) {
session := &model.Session{
UserId: th.BasicUser.Id,
}
session, err := th.App.CreateSession(th.Context, session)
require.Nil(t, err)
pendingPostId := makePendingPostId(th.BasicUser)
post, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
PendingPostId: pendingPostId,
}, session.Id, true)
require.Nil(t, err)
require.Equal(t, "message", post.Message)
duplicatePost, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
PendingPostId: pendingPostId,
}, session.Id, true)
require.Nil(t, err)
require.Equal(t, post.Id, duplicatePost.Id, "should have returned previously created post id")
require.Equal(t, "message", duplicatePost.Message)
})
t.Run("post rejected by plugin leaves cache ready for non-deduplicated try", func(t *testing.T) {
setupPluginAPITest(t, `
package main
import (
"github.com/hanzoteam/server/server/public/plugin"
"github.com/hanzoteam/server/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
allow bool
}
func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
if !p.allow {
p.allow = true
return nil, "rejected"
}
return nil, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`, `{"id": "testrejectfirstpost", "server": {"executable": "backend.exe"}}`, "testrejectfirstpost", th.App, th.Context)
session := &model.Session{
UserId: th.BasicUser.Id,
}
session, err := th.App.CreateSession(th.Context, session)
require.Nil(t, err)
pendingPostId := makePendingPostId(th.BasicUser)
post, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
PendingPostId: pendingPostId,
}, session.Id, true)
require.NotNil(t, err)
require.Equal(t, "Post rejected by plugin. rejected", err.Id)
require.Nil(t, post)
duplicatePost, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
PendingPostId: pendingPostId,
}, session.Id, true)
require.Nil(t, err)
require.Equal(t, "message", duplicatePost.Message)
})
t.Run("slow posting after cache entry blocks duplicate request", func(t *testing.T) {
setupPluginAPITest(t, `
package main
import (
"github.com/hanzoteam/server/server/public/plugin"
"github.com/hanzoteam/server/server/public/model"
"time"
)
type MyPlugin struct {
plugin.MattermostPlugin
instant bool
}
func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
if !p.instant {
p.instant = true
time.Sleep(3 * time.Second)
}
return nil, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`, `{"id": "testdelayfirstpost", "server": {"executable": "backend.exe"}}`, "testdelayfirstpost", th.App, th.Context)
session := &model.Session{
UserId: th.BasicUser.Id,
}
session, err := th.App.CreateSession(th.Context, session)
require.Nil(t, err)
var post *model.Post
pendingPostId := makePendingPostId(th.BasicUser)
wg := sync.WaitGroup{}
// Launch a goroutine to make the first CreatePost call that will get delayed
// by the plugin above.
wg.Go(func() {
var appErr *model.AppError
post, _, appErr = th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "plugin delayed",
PendingPostId: pendingPostId,
}, session.Id, true)
require.Nil(t, appErr)
require.Equal(t, post.Message, "plugin delayed")
})
// Give the goroutine above a chance to start and get delayed by the plugin.
time.Sleep(2 * time.Second)
// Try creating a duplicate post
duplicatePost, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "plugin delayed",
PendingPostId: pendingPostId,
}, session.Id, true)
require.NotNil(t, err)
require.Equal(t, "api.post.deduplicate_create_post.pending", err.Id)
require.Nil(t, duplicatePost)
// Wait for the first CreatePost to finish to ensure assertions are made.
wg.Wait()
})
t.Run("duplicate create post after cache expires is not idempotent", func(t *testing.T) {
originalCacheTTL := pendingPostIDsCacheTTL
pendingPostIDsCacheTTL = time.Second
t.Cleanup(func() {
pendingPostIDsCacheTTL = originalCacheTTL
})
session := &model.Session{
UserId: th.BasicUser.Id,
}
session, err := th.App.CreateSession(th.Context, session)
require.Nil(t, err)
pendingPostId := makePendingPostId(th.BasicUser)
post, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
PendingPostId: pendingPostId,
}, session.Id, true)
require.Nil(t, err)
require.Equal(t, "message", post.Message)
time.Sleep(pendingPostIDsCacheTTL)
duplicatePost, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
PendingPostId: pendingPostId,
}, session.Id, true)
require.Nil(t, err)
require.NotEqual(t, post.Id, duplicatePost.Id, "should have created new post id")
require.Equal(t, "message", duplicatePost.Message)
})
t.Run("Permissison to post required to resolve from pending post cache", func(t *testing.T) {
sessionBasicUser := &model.Session{
UserId: th.BasicUser.Id,
}
sessionBasicUser, err := th.App.CreateSession(th.Context, sessionBasicUser)
require.Nil(t, err)
sessionBasicUser2 := &model.Session{
UserId: th.BasicUser2.Id,
}
sessionBasicUser2, err = th.App.CreateSession(th.Context, sessionBasicUser2)
require.Nil(t, err)
pendingPostId := makePendingPostId(th.BasicUser)
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, privateChannel)
post, _, err := th.App.CreatePostAsUser(th.Context.WithSession(sessionBasicUser), &model.Post{
UserId: th.BasicUser.Id,
ChannelId: privateChannel.Id,
Message: "message",
PendingPostId: pendingPostId,
}, sessionBasicUser.Id, true)
require.Nil(t, err)
require.Equal(t, "message", post.Message)
postAsDifferentUser, _, err := th.App.CreatePostAsUser(th.Context.WithSession(sessionBasicUser2), &model.Post{
UserId: th.BasicUser2.Id,
ChannelId: th.BasicChannel.Id,
Message: "message2",
PendingPostId: pendingPostId,
}, sessionBasicUser2.Id, true)
require.Nil(t, err)
require.NotEqual(t, post.Id, postAsDifferentUser.Id, "should have created new post id")
require.Equal(t, "message2", postAsDifferentUser.Message)
// Both posts should exist unchanged
actualPost, err := th.App.GetSinglePost(th.Context, post.Id, false)
require.Nil(t, err)
assert.Equal(t, "message", actualPost.Message)
assert.Equal(t, privateChannel.Id, actualPost.ChannelId)
actualPostAsDifferentUser, err := th.App.GetSinglePost(th.Context, postAsDifferentUser.Id, false)
require.Nil(t, err)
assert.Equal(t, "message2", actualPostAsDifferentUser.Message)
assert.Equal(t, th.BasicChannel.Id, actualPostAsDifferentUser.ChannelId)
})
}
func TestAttachFilesToPost(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should attach files", func(t *testing.T) {
th := Setup(t).InitBasic(t)
info1, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
})
require.NoError(t, err)
info2, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
})
require.NoError(t, err)
post := th.BasicPost
post.FileIds = []string{info1.Id, info2.Id}
attachedFiles, appErr := th.App.attachFilesToPost(th.Context, post, post.FileIds)
assert.Nil(t, appErr)
assert.Len(t, attachedFiles, 2)
assert.Contains(t, attachedFiles, info1.Id)
assert.Contains(t, attachedFiles, info2.Id)
infos, _, appErr := th.App.GetFileInfosForPost(th.Context, post, false, false)
assert.Nil(t, appErr)
assert.Len(t, infos, 2)
})
t.Run("should return only successfully attached files after failing to add files", func(t *testing.T) {
th := Setup(t).InitBasic(t)
info1, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
PostId: model.NewId(),
})
require.NoError(t, err)
info2, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
})
require.NoError(t, err)
post := th.BasicPost
post.FileIds = []string{info1.Id, info2.Id}
attachedFiles, appErr := th.App.attachFilesToPost(th.Context, post, post.FileIds)
assert.Nil(t, appErr)
assert.Len(t, attachedFiles, 1)
assert.Contains(t, attachedFiles, info2.Id)
infos, _, appErr := th.App.GetFileInfosForPost(th.Context, post, false, false)
assert.Nil(t, appErr)
assert.Len(t, infos, 1)
assert.Equal(t, info2.Id, infos[0].Id)
updated, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
require.Nil(t, appErr)
assert.Len(t, updated.FileIds, 1)
assert.Contains(t, updated.FileIds, info2.Id)
})
}
func TestUpdatePostEditAt(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
post := th.BasicPost.Clone()
post.IsPinned = true
saved, isMemberForPreviews, err := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true})
require.Nil(t, err)
assert.Equal(t, saved.EditAt, post.EditAt, "shouldn't have updated post.EditAt when pinning post")
assert.True(t, isMemberForPreviews)
post = saved.Clone()
time.Sleep(time.Millisecond * 100)
post.Message = model.NewId()
saved, isMemberForPreviews, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true})
require.Nil(t, err)
assert.NotEqual(t, saved.EditAt, post.EditAt, "should have updated post.EditAt when updating post message")
assert.True(t, isMemberForPreviews)
time.Sleep(time.Millisecond * 200)
}
func TestUpdatePostTimeLimit(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
post := th.BasicPost.Clone()
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
_, _, err := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true})
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1000000000
})
post.Message = model.NewId()
_, _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true})
require.Nil(t, err, "should allow you to edit the post")
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1
})
post.Message = model.NewId()
_, _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true})
require.Nil(t, err, "should allow you to edit an old post because the time check is applied above in the call hierarchy")
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
}
func TestUpdatePostInArchivedChannel(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
archivedChannel := th.CreateChannel(t, th.BasicTeam)
post := th.CreatePost(t, archivedChannel)
appErr := th.App.DeleteChannel(th.Context, archivedChannel, "")
require.Nil(t, appErr)
_, _, err := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true})
require.NotNil(t, err)
require.Equal(t, "api.post.update_post.can_not_update_post_in_deleted.error", err.Id)
}
func TestPostReplyToPostWhereRootPosterLeftChannel(t *testing.T) {
mainHelper.Parallel(t)
// This test ensures that when replying to a root post made by a user who has since left the channel, the reply
// post completes successfully. This is a regression test for PLT-6523.
th := Setup(t).InitBasic(t)
channel := th.BasicChannel
userInChannel := th.BasicUser2
userNotInChannel := th.BasicUser
rootPost := th.BasicPost
_, err := th.App.AddUserToChannel(th.Context, userInChannel, channel, false)
require.Nil(t, err)
err = th.App.RemoveUserFromChannel(th.Context, userNotInChannel.Id, "", channel)
require.Nil(t, err)
replyPost := model.Post{
Message: "asd",
ChannelId: channel.Id,
RootId: rootPost.Id,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: userInChannel.Id,
CreateAt: 0,
}
_, _, err = th.App.CreatePostAsUser(th.Context, &replyPost, "", true)
require.Nil(t, err)
}
func TestPostAttachPostToChildPost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channel := th.BasicChannel
user := th.BasicUser
rootPost := th.BasicPost
replyPost1 := model.Post{
Message: "reply one",
ChannelId: channel.Id,
RootId: rootPost.Id,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: user.Id,
CreateAt: 0,
}
res1, _, err := th.App.CreatePostAsUser(th.Context, &replyPost1, "", true)
require.Nil(t, err)
replyPost2 := model.Post{
Message: "reply two",
ChannelId: channel.Id,
RootId: res1.Id,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: user.Id,
CreateAt: 0,
}
_, _, err = th.App.CreatePostAsUser(th.Context, &replyPost2, "", true)
assert.Equalf(t, err.StatusCode, http.StatusBadRequest, "Expected BadRequest error, got %v", err)
replyPost3 := model.Post{
Message: "reply three",
ChannelId: channel.Id,
RootId: rootPost.Id,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: user.Id,
CreateAt: 0,
}
_, _, err = th.App.CreatePostAsUser(th.Context, &replyPost3, "", true)
assert.Nil(t, err)
}
func TestUpdatePostPluginHooks(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("Should stop processing at first reject", func(t *testing.T) {
setupMultiPluginAPITest(t, []string{
`
package main
import (
"github.com/hanzoteam/server/server/public/plugin"
"github.com/hanzoteam/server/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBeUpdated(c *plugin.Context, newPost, oldPost *model.Post) (*model.Post, string) {
return nil, "rejected"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
`
package main
import (
"github.com/hanzoteam/server/server/public/plugin"
"github.com/hanzoteam/server/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBeUpdated(c *plugin.Context, newPost, oldPost *model.Post) (*model.Post, string) {
if (newPost == nil) {
return nil, "nil post"
}
newPost.Message = newPost.Message + "fromplugin"
return newPost, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, []string{
`{"id": "testrejectfirstpost", "server": {"executable": "backend.exe"}}`,
`{"id": "testupdatepost", "server": {"executable": "backend.exe"}}`,
}, []string{
"testrejectfirstpost", "testupdatepost",
}, true, th.App, th.Context)
pendingPostId := makePendingPostId(th.BasicUser)
post, _, err := th.App.CreatePostAsUser(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
PendingPostId: pendingPostId,
}, "", true)
require.Nil(t, err)
post.Message = "new message"
updatedPost, _, err := th.App.UpdatePost(th.Context, post, nil)
require.Nil(t, updatedPost)
require.NotNil(t, err)
require.Equal(t, "Post rejected by plugin. rejected", err.Id)
})
t.Run("Should update", func(t *testing.T) {
setupMultiPluginAPITest(t, []string{
`
package main
import (
"github.com/hanzoteam/server/server/public/plugin"
"github.com/hanzoteam/server/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBeUpdated(c *plugin.Context, newPost, oldPost *model.Post) (*model.Post, string) {
newPost.Message = newPost.Message + " 1"
return newPost, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
`
package main
import (
"github.com/hanzoteam/server/server/public/plugin"
"github.com/hanzoteam/server/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBeUpdated(c *plugin.Context, newPost, oldPost *model.Post) (*model.Post, string) {
newPost.Message = "2 " + newPost.Message
return newPost, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, []string{
`{"id": "testaddone", "server": {"executable": "backend.exe"}}`,
`{"id": "testaddtwo", "server": {"executable": "backend.exe"}}`,
}, []string{
"testaddone", "testaddtwo",
}, true, th.App, th.Context)
pendingPostId := makePendingPostId(th.BasicUser)
post, _, err := th.App.CreatePostAsUser(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "message",
PendingPostId: pendingPostId,
}, "", true)
require.Nil(t, err)
post.Message = "new message"
updatedPost, isMemberForPreviews, err := th.App.UpdatePost(th.Context, post, nil)
require.True(t, isMemberForPreviews)
require.Nil(t, err)
require.NotNil(t, updatedPost)
require.Equal(t, "2 new message 1", updatedPost.Message)
})
}
func TestPostChannelMentions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channel := th.BasicChannel
user := th.BasicUser
// Create context with session for the user to properly test sanitization
ctx := th.Context.WithSession(&model.Session{UserId: user.Id})
channelToMention, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Mention Test",
Name: "mention-test",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}, false)
require.Nil(t, err)
defer func() {
appErr := th.App.PermanentDeleteChannel(th.Context, channelToMention)
require.Nil(t, appErr)
}()
channelToMention2, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Mention Test2",
Name: "mention-test2",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}, false)
require.Nil(t, err)
defer func() {
appErr := th.App.PermanentDeleteChannel(th.Context, channelToMention2)
require.Nil(t, appErr)
}()
_, err = th.App.AddUserToChannel(th.Context, user, channel, false)
require.Nil(t, err)
post := &model.Post{
Message: fmt.Sprintf("hello, ~%v!", channelToMention.Name),
ChannelId: channel.Id,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: user.Id,
CreateAt: 0,
}
post, _, err = th.App.CreatePostAsUser(ctx, post, "", true)
require.Nil(t, err)
assert.Equal(t, map[string]any{
"mention-test": map[string]any{
"display_name": "Mention Test",
"team_name": th.BasicTeam.Name,
},
}, post.GetProp(model.PostPropsChannelMentions))
post.Message = fmt.Sprintf("goodbye, ~%v!", channelToMention2.Name)
result, isMemberForPreviews, err := th.App.UpdatePost(ctx, post, nil)
require.True(t, isMemberForPreviews)
require.Nil(t, err)
assert.Equal(t, map[string]any{
"mention-test2": map[string]any{
"display_name": "Mention Test2",
"team_name": th.BasicTeam.Name,
},
}, result.GetProp(model.PostPropsChannelMentions))
result.Message = "no more mentions!"
result, isMemberForPreviews, err = th.App.UpdatePost(ctx, result, nil)
require.True(t, isMemberForPreviews)
require.Nil(t, err)
assert.Nil(t, result.GetProp(model.PostPropsChannelMentions))
}
// TestFillInPostPropsChannelMentionResolution locks in the author-side persistence of the
// channel_mentions prop after FillInPostProps switched from HasPermissionToReadChannel to
// HasPermissionToResolveChannelMention. The post author is the subject of every case.
func TestFillInPostPropsChannelMentionResolution(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Author is a member of BasicTeam (and BasicChannel) but NOT of the referenced channels below.
author := th.BasicUser
postChannel := th.BasicChannel
// Public channel on the author's team where the author is NOT a member.
// Created with membership=false so the creator is not added as a member.
publicNonMember, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Public Non-Member",
Name: "public-non-member-" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}, false)
require.Nil(t, err)
// Private channel on the author's team where the author is NOT a member.
privateNonMember, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Private Non-Member",
Name: "private-non-member-" + model.NewId(),
Type: model.ChannelTypePrivate,
TeamId: th.BasicTeam.Id,
}, false)
require.Nil(t, err)
// Public channel on the author's team where the author IS a member.
publicMember, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Public Member",
Name: "public-member-" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}, false)
require.Nil(t, err)
_, err = th.App.AddUserToChannel(th.Context, author, publicMember, false)
require.Nil(t, err)
// A second team the author does NOT belong to, with a public channel on it. The post used for
// the cross-team case lives on this team so that GetChannelsByNames (scoped to the post's team)
// actually resolves the channel and the permission check is what drops it.
otherTeam := th.CreateTeam(t)
otherTeamPublic, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Other Team Public",
Name: "other-team-public-" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: otherTeam.Id,
}, false)
require.Nil(t, err)
otherTeamPostChannel, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Other Team Post Channel",
Name: "other-team-post-" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: otherTeam.Id,
}, false)
require.Nil(t, err)
setCompliance := func(enabled bool) {
th.App.UpdateConfig(func(c *model.Config) {
c.ComplianceSettings.Enable = model.NewPointer(enabled)
})
}
// resolve runs FillInPostProps for a post authored by `author` mentioning the given channels in
// `inChannel`, and returns the persisted channel_mentions map (or nil).
resolve := func(t *testing.T, inChannel *model.Channel, mentioned ...*model.Channel) map[string]any {
t.Helper()
var builder strings.Builder
builder.WriteString("hello")
for _, m := range mentioned {
fmt.Fprintf(&builder, " ~%s", m.Name)
}
message := builder.String()
post := &model.Post{
Message: message,
ChannelId: inChannel.Id,
UserId: author.Id,
}
appErr := th.App.FillInPostProps(th.Context, post, inChannel)
require.Nil(t, appErr)
prop := post.GetProp(model.PostPropsChannelMentions)
if prop == nil {
return nil
}
mentions, ok := prop.(map[string]any)
require.True(t, ok)
return mentions
}
assertResolved := func(t *testing.T, mentions map[string]any, channel *model.Channel, teamName string) {
t.Helper()
require.Contains(t, mentions, channel.Name)
entry, ok := mentions[channel.Name].(map[string]any)
require.True(t, ok)
assert.Equal(t, channel.DisplayName, entry["display_name"])
assert.Equal(t, teamName, entry["team_name"])
}
t.Run("public channel, author is a team member but not a channel member, compliance ON => persisted", func(t *testing.T) {
setCompliance(true)
defer setCompliance(false)
mentions := resolve(t, postChannel, publicNonMember)
require.NotNil(t, mentions, "channel_mentions prop must be persisted under compliance")
assertResolved(t, mentions, publicNonMember, th.BasicTeam.Name)
})
t.Run("public channel, author is a team member but not a channel member, compliance OFF => persisted", func(t *testing.T) {
setCompliance(false)
mentions := resolve(t, postChannel, publicNonMember)
require.NotNil(t, mentions)
assertResolved(t, mentions, publicNonMember, th.BasicTeam.Name)
})
t.Run("public channel on a team the author does not belong to => not persisted", func(t *testing.T) {
setCompliance(false)
mentions := resolve(t, otherTeamPostChannel, otherTeamPublic)
assert.Nil(t, mentions)
})
t.Run("private channel the author is not a member of => not persisted", func(t *testing.T) {
setCompliance(false)
mentions := resolve(t, postChannel, privateNonMember)
assert.Nil(t, mentions)
})
t.Run("channel the author is a member of => persisted", func(t *testing.T) {
setCompliance(true)
defer setCompliance(false)
mentions := resolve(t, postChannel, publicMember)
require.NotNil(t, mentions)
assertResolved(t, mentions, publicMember, th.BasicTeam.Name)
})
t.Run("mixed mentions resolve per-channel boundary", func(t *testing.T) {
setCompliance(true)
defer setCompliance(false)
mentions := resolve(t, postChannel, publicNonMember, privateNonMember, publicMember)
require.NotNil(t, mentions)
assertResolved(t, mentions, publicNonMember, th.BasicTeam.Name)
assertResolved(t, mentions, publicMember, th.BasicTeam.Name)
assert.NotContains(t, mentions, privateNonMember.Name)
})
}
func TestImageProxy(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockPostStore := storemocks.PostStore{}
mockPostStore.On("GetMaxPostSize").Return(65535, nil)
mockSystemStore := storemocks.SystemStore{}
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
mockStore.On("GetDBSchemaVersion").Return(1, nil)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://mymattermost.com"
})
th.App.ch.imageProxy = imageproxy.MakeImageProxy(th.Server.platform, th.Server.HTTPService(), th.Server.Log())
testHMACKey := model.NewTestPassword()
for name, tc := range map[string]struct {
ProxyType string
ProxyURL string
ProxyOptions string
ImageURL string
ProxiedImageURL string
ProxiedRemovedImageURL string
}{
"atmos/camo": {
ProxyType: model.ImageProxyTypeAtmosCamo,
ProxyURL: "https://127.0.0.1",
ProxyOptions: testHMACKey,
ImageURL: "http://mydomain.com/myimage",
ProxiedRemovedImageURL: "http://mydomain.com/myimage",
ProxiedImageURL: "http://mymattermost.com/v1/workspace/image?url=http%3A%2F%2Fmydomain.com%2Fmyimage",
},
"atmos/camo_SameSite": {
ProxyType: model.ImageProxyTypeAtmosCamo,
ProxyURL: "https://127.0.0.1",
ProxyOptions: testHMACKey,
ImageURL: "http://mymattermost.com/myimage",
ProxiedRemovedImageURL: "http://mymattermost.com/myimage",
ProxiedImageURL: "http://mymattermost.com/myimage",
},
"atmos/camo_PathOnly": {
ProxyType: model.ImageProxyTypeAtmosCamo,
ProxyURL: "https://127.0.0.1",
ProxyOptions: testHMACKey,
ImageURL: "/myimage",
ProxiedRemovedImageURL: "http://mymattermost.com/myimage",
ProxiedImageURL: "http://mymattermost.com/myimage",
},
"atmos/camo_EmptyImageURL": {
ProxyType: model.ImageProxyTypeAtmosCamo,
ProxyURL: "https://127.0.0.1",
ProxyOptions: testHMACKey,
ImageURL: "",
ProxiedRemovedImageURL: "",
ProxiedImageURL: "",
},
"local": {
ProxyType: model.ImageProxyTypeLocal,
ImageURL: "http://mydomain.com/myimage",
ProxiedRemovedImageURL: "http://mydomain.com/myimage",
ProxiedImageURL: "http://mymattermost.com/v1/workspace/image?url=http%3A%2F%2Fmydomain.com%2Fmyimage",
},
"local_SameSite": {
ProxyType: model.ImageProxyTypeLocal,
ImageURL: "http://mymattermost.com/myimage",
ProxiedRemovedImageURL: "http://mymattermost.com/myimage",
ProxiedImageURL: "http://mymattermost.com/myimage",
},
"local_PathOnly": {
ProxyType: model.ImageProxyTypeLocal,
ImageURL: "/myimage",
ProxiedRemovedImageURL: "http://mymattermost.com/myimage",
ProxiedImageURL: "http://mymattermost.com/myimage",
},
"local_EmptyImageURL": {
ProxyType: model.ImageProxyTypeLocal,
ImageURL: "",
ProxiedRemovedImageURL: "",
ProxiedImageURL: "",
},
} {
t.Run(name, func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ImageProxySettings.Enable = new(true)
cfg.ImageProxySettings.ImageProxyType = new(tc.ProxyType)
cfg.ImageProxySettings.RemoteImageProxyOptions = new(tc.ProxyOptions)
cfg.ImageProxySettings.RemoteImageProxyURL = new(tc.ProxyURL)
})
post := &model.Post{
Id: model.NewId(),
Message: "![foo](" + tc.ImageURL + ")",
}
list := model.NewPostList()
list.Posts[post.Id] = post
assert.Equal(t, "![foo]("+tc.ProxiedImageURL+")", th.App.PostWithProxyAddedToImageURLs(post).Message)
assert.Equal(t, "![foo]("+tc.ImageURL+")", th.App.PostWithProxyRemovedFromImageURLs(post).Message)
post.Message = "![foo](" + tc.ProxiedImageURL + ")"
assert.Equal(t, "![foo]("+tc.ProxiedRemovedImageURL+")", th.App.PostWithProxyRemovedFromImageURLs(post).Message)
if tc.ImageURL != "" {
post.Message = "![foo](" + tc.ImageURL + " =500x200)"
assert.Equal(t, "![foo]("+tc.ProxiedImageURL+" =500x200)", th.App.PostWithProxyAddedToImageURLs(post).Message)
assert.Equal(t, "![foo]("+tc.ImageURL+" =500x200)", th.App.PostWithProxyRemovedFromImageURLs(post).Message)
post.Message = "![foo](" + tc.ProxiedImageURL + " =500x200)"
assert.Equal(t, "![foo]("+tc.ProxiedRemovedImageURL+" =500x200)", th.App.PostWithProxyRemovedFromImageURLs(post).Message)
}
})
}
}
func TestDeletePostWithFileAttachments(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Create a post with a file attachment.
teamID := th.BasicTeam.Id
channelID := th.BasicChannel.Id
userID := th.BasicUser.Id
filename := "test"
data := []byte("abcd")
info1, err := th.App.DoUploadFile(th.Context, time.Date(2007, 2, 4, 1, 2, 3, 4, time.Local), teamID, channelID, userID, filename, data, true)
require.Nil(t, err)
defer func() {
err := th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, info1.Id)
require.NoError(t, err)
appErr := th.App.RemoveFile(info1.Path)
require.Nil(t, appErr)
}()
post := &model.Post{
Message: "asd",
ChannelId: channelID,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: userID,
CreateAt: 0,
FileIds: []string{info1.Id},
}
post, _, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
assert.Nil(t, err)
// Delete the post.
_, err = th.App.DeletePost(th.Context, post.Id, userID)
assert.Nil(t, err)
// Wait for the cleanup routine to finish.
time.Sleep(time.Millisecond * 100)
// Check that the file can no longer be reached.
_, err = th.App.GetFileInfo(th.Context, info1.Id)
assert.NotNil(t, err)
}
func TestDeletePostWithRestrictedDM(t *testing.T) {
mainHelper.Parallel(t)
t.Run("cannot delete post in restricted DM", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageTeam
})
// Create a DM channel between two users who don't share a team
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
// Ensure the two users do not share a team
teams, err := th.App.GetTeamsForUser(th.BasicUser.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
teams, err = th.App.GetTeamsForUser(th.BasicUser2.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser2.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
// Create separate teams for each user
team1 := th.CreateTeam(t)
team2 := th.CreateTeam(t)
th.LinkUserToTeam(t, th.BasicUser, team1)
th.LinkUserToTeam(t, th.BasicUser2, team2)
// Create a post in the DM channel
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: dmChannel.Id,
Message: "test post",
}
post, _, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{})
require.Nil(t, err)
// Try to delete the post
_, appErr := th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id)
require.NotNil(t, appErr)
require.Equal(t, "api.post.delete_post.can_not_delete_from_restricted_dm.error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
// Reset config
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageAny
})
})
}
func TestDeletePostInArchivedChannel(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
archivedChannel := th.CreateChannel(t, th.BasicTeam)
post := th.CreatePost(t, archivedChannel)
appErr := th.App.DeleteChannel(th.Context, archivedChannel, "")
require.Nil(t, appErr)
_, err := th.App.DeletePost(th.Context, post.Id, "")
require.NotNil(t, err)
require.Equal(t, "api.post.delete_post.can_not_delete_post_in_deleted.error", err.Id)
}
func TestDeletePostDeletesPersistentNotification(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional))
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostPriority = true
*cfg.ServiceSettings.AllowPersistentNotifications = true
})
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false)
require.Nil(t, appErr)
t.Run("should delete persistent notification for root post", func(t *testing.T) {
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "urgent " + "@" + th.BasicUser2.Username,
Metadata: &model.PostMetadata{
Priority: &model.PostPriority{
Priority: model.NewPointer(model.PostPriorityUrgent),
PersistentNotifications: new(true),
},
},
}
post, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.Empty(t, post.RootId, "test post must be a root post")
// Verify persistent notification exists
pn, err := th.App.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
require.NoError(t, err)
require.NotNil(t, pn)
// Delete the post (soft delete)
_, appErr = th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id)
require.Nil(t, appErr)
// Verify persistent notification was deleted
_, err = th.App.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
var nfErr *store.ErrNotFound
require.Error(t, err)
require.ErrorAs(t, err, &nfErr)
})
}
func TestCreatePost(t *testing.T) {
// This test is intentionally not parallel: two subtests below call t.Setenv
// to pin MM_FEATUREFLAGS_EnableSharedChannelsDMs, which Go disallows under a
// parallel ancestor.
t.Run("call PreparePostForClient before returning", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://mymattermost.com"
*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())
imageURL := "http://mydomain.com/myimage"
proxiedImageURL := "http://mymattermost.com/v1/workspace/image?url=http%3A%2F%2Fmydomain.com%2Fmyimage"
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "![image](" + imageURL + ")",
UserId: th.BasicUser.Id,
}
rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, "![image]("+proxiedImageURL+")", rpost.Message)
})
t.Run("Sets prop MENTION_HIGHLIGHT_DISABLED when it should", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
t.Run("Does not set prop when user has USE_CHANNEL_MENTIONS", func(t *testing.T) {
postWithNoMention := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This post does not have mentions",
UserId: th.BasicUser.Id,
}
rpost, _, err := th.App.CreatePost(th.Context, postWithNoMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, rpost.GetProps(), model.StringInterface{})
postWithMention := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This post has @here mention @all",
UserId: th.BasicUser.Id,
}
rpost, _, err = th.App.CreatePost(th.Context, postWithMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, rpost.GetProps(), model.StringInterface{})
})
t.Run("Sets prop when post has mentions and user does not have USE_CHANNEL_MENTIONS", func(t *testing.T) {
th.RemovePermissionFromRole(t, model.PermissionUseChannelMentions.Id, model.ChannelUserRoleId)
th.RemovePermissionFromRole(t, model.PermissionUseChannelMentions.Id, model.ChannelAdminRoleId)
postWithNoMention := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This post does not have mentions",
UserId: th.BasicUser.Id,
}
rpost, _, err := th.App.CreatePost(th.Context, postWithNoMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, rpost.GetProps(), model.StringInterface{})
postWithMention := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This post has @here mention @all",
UserId: th.BasicUser.Id,
}
rpost, _, err = th.App.CreatePost(th.Context, postWithMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.Equal(t, rpost.GetProp(model.PostPropsMentionHighlightDisabled), true)
th.AddPermissionToRole(t, model.PermissionUseChannelMentions.Id, model.ChannelUserRoleId)
th.AddPermissionToRole(t, model.PermissionUseChannelMentions.Id, model.ChannelAdminRoleId)
})
})
t.Run("Sets PostPropsPreviewedPost when a permalink is the first link", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
referencedPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
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, referencedPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id)
channelForPreview := th.CreateChannel(t, th.BasicTeam)
previewPost := &model.Post{
ChannelId: channelForPreview.Id,
Message: permalink,
UserId: th.BasicUser.Id,
}
previewPost, _, err = th.App.CreatePost(th.Context, previewPost, channelForPreview, model.CreatePostFlags{})
require.Nil(t, err)
assert.Equal(t, previewPost.GetProps(), model.StringInterface{"previewed_post": referencedPost.Id})
})
t.Run("creates a single record for a permalink preview post", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channelForPreview := th.CreateChannel(t, th.BasicTeam)
referencedPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
referencedPost, _, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://foobar.com"
*cfg.ServiceSettings.EnablePermalinkPreviews = true
})
permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id)
previewPost := &model.Post{
ChannelId: channelForPreview.Id,
Message: permalink,
UserId: th.BasicUser.Id,
}
previewPost, _, err = th.App.CreatePost(th.Context, previewPost, channelForPreview, model.CreatePostFlags{})
require.Nil(t, err)
sqlStore := th.GetSqlStore()
sql := fmt.Sprintf("select count(*) from Posts where Id = '%[1]s' or OriginalId = '%[1]s';", previewPost.Id)
var val int64
err2 := sqlStore.GetMaster().Get(&val, sql)
require.NoError(t, err2)
require.EqualValues(t, int64(1), val)
})
t.Run("sanitizes post metadata appropriately", func(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.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
user1 := th.CreateUser(t)
user2 := th.CreateUser(t)
directChannel, err := th.App.createDirectChannel(th.Context, user1.Id, user2.Id)
require.Nil(t, err)
th.Context.Session().UserId = th.BasicUser.Id
testCases := []struct {
Description string
Channel *model.Channel
Author string
Length int
}{
{
Description: "removes metadata from post for members who cannot read channel",
Channel: directChannel,
Author: user1.Id,
Length: 0,
},
{
Description: "does not remove metadata from post for members who can read channel",
Channel: th.BasicChannel,
Author: th.BasicUser.Id,
Length: 1,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
referencedPost := &model.Post{
ChannelId: testCase.Channel.Id,
Message: "hello world",
UserId: testCase.Author,
}
referencedPost, _, err = th.App.CreatePost(th.Context, referencedPost, testCase.Channel, model.CreatePostFlags{})
require.Nil(t, err)
permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id)
previewPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: permalink,
UserId: th.BasicUser.Id,
}
previewPost, _, err = th.App.CreatePost(th.Context, previewPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
require.Len(t, previewPost.Metadata.Embeds, testCase.Length)
})
}
})
t.Run("Should not allow to create posts on shared DMs", func(t *testing.T) {
// The env override is reapplied on every config Set, so UpdateConfig cannot
// pin the flag; t.Setenv is the only safe way (and requires no parallel ancestor).
t.Setenv("MM_FEATUREFLAGS_EnableSharedChannelsDMs", "false")
th := setupSharedChannels(t).InitBasic(t)
user1 := th.CreateUser(t)
user2 := th.CreateUser(t)
dm, appErr := th.App.createDirectChannel(th.Context, user1.Id, user2.Id)
require.Nil(t, appErr)
require.NotNil(t, dm)
// we can't create direct channels with remote users, so we
// have to force the channel to be shared through the store to
// simulate preexisting shared DMs
sc := &model.SharedChannel{
ChannelId: dm.Id,
Type: dm.Type,
Home: true,
ShareName: "shareddm",
CreatorId: user1.Id,
RemoteId: model.NewId(),
}
_, scErr := th.Server.Store().SharedChannel().Save(sc)
require.NoError(t, scErr)
// and we update the channel to mark it as shared
dm.Shared = new(true)
_, err := th.Server.Store().Channel().Update(th.Context, dm)
require.NoError(t, err)
newPost := &model.Post{
ChannelId: dm.Id,
Message: "hello world",
UserId: user1.Id,
}
createdPost, _, appErr := th.App.CreatePost(th.Context, newPost, dm, model.CreatePostFlags{})
require.NotNil(t, appErr)
require.Nil(t, createdPost)
})
t.Run("Should not allow to create posts on shared GMs", func(t *testing.T) {
// The env override is reapplied on every config Set, so UpdateConfig cannot
// pin the flag; t.Setenv is the only safe way (and requires no parallel ancestor).
t.Setenv("MM_FEATUREFLAGS_EnableSharedChannelsDMs", "false")
th := setupSharedChannels(t).InitBasic(t)
user1 := th.CreateUser(t)
user2 := th.CreateUser(t)
user3 := th.CreateUser(t)
gm, appErr := th.App.createGroupChannel(th.Context, []string{user1.Id, user2.Id, user3.Id}, user1.Id)
require.Nil(t, appErr)
require.NotNil(t, gm)
// we can't create group channels with remote users, so we
// have to force the channel to be shared through the store to
// simulate preexisting shared GMs
sc := &model.SharedChannel{
ChannelId: gm.Id,
Type: gm.Type,
Home: true,
ShareName: "sharedgm",
CreatorId: user1.Id,
RemoteId: model.NewId(),
}
_, err := th.Server.Store().SharedChannel().Save(sc)
require.NoError(t, err)
// and we update the channel to mark it as shared
gm.Shared = new(true)
_, err = th.Server.Store().Channel().Update(th.Context, gm)
require.NoError(t, err)
newPost := &model.Post{
ChannelId: gm.Id,
Message: "hello world",
UserId: user1.Id,
}
createdPost, _, appErr := th.App.CreatePost(th.Context, newPost, gm, model.CreatePostFlags{})
require.NotNil(t, appErr)
require.Nil(t, createdPost)
})
t.Run("MM-40016 should not panic with `concurrent map read and map write`", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channelForPreview := th.CreateChannel(t, th.BasicTeam)
for range 20 {
user := th.CreateUser(t)
th.LinkUserToTeam(t, user, th.BasicTeam)
th.AddUserToChannel(t, user, channelForPreview)
}
referencedPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
referencedPost, _, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://example.com"
*cfg.ServiceSettings.EnablePermalinkPreviews = true
})
permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id)
previewPost := &model.Post{
ChannelId: channelForPreview.Id,
Message: permalink,
UserId: th.BasicUser.Id,
}
previewPost, _, err = th.App.CreatePost(th.Context, previewPost, channelForPreview, model.CreatePostFlags{})
require.Nil(t, err)
n := 1000
var wg sync.WaitGroup
wg.Add(n)
for range n {
go func() {
defer wg.Done()
post := previewPost.Clone()
_, _, appErr := th.App.UpdatePost(th.Context, post, nil)
require.Nil(t, appErr)
}()
}
wg.Wait()
})
t.Run("should sanitize the force notifications prop if the flag is not set", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
postToCreate := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
postToCreate.AddProp(model.PostPropsForceNotification, model.NewId())
createdPost, _, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
require.Empty(t, createdPost.GetProp(model.PostPropsForceNotification))
})
t.Run("should add the force notifications prop if the flag is set", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
postToCreate := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
createdPost, _, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{ForceNotification: true})
require.Nil(t, err)
require.NotEmpty(t, createdPost.GetProp(model.PostPropsForceNotification))
})
t.Run("should reject silent notification from a human user", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
postToCreate := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
_, _, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{SilentNotification: true})
require.NotNil(t, err)
require.Equal(t, "api.post.create_post.silent_notification.app_error", err.Id)
require.Equal(t, http.StatusForbidden, err.StatusCode)
})
t.Run("should preserve silent notification on non-SafeUpdate edit", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
th.LinkUserToTeam(t, botUser, th.BasicTeam)
_, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false)
require.Nil(t, appErr)
createdPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "silent before edit",
UserId: bot.UserId,
}, th.BasicChannel, model.CreatePostFlags{SilentNotification: true})
require.Nil(t, err)
require.True(t, createdPost.HasSilentNotification())
createdPost.Message = "silent after edit"
createdPost.DelProp(model.PostPropsSilentNotification)
updatedPost, _, err := th.App.UpdatePost(th.Context, createdPost, nil)
require.Nil(t, err)
require.True(t, updatedPost.HasSilentNotification())
})
t.Run("should preserve from_webhook on non-SafeUpdate edit", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
createdPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "webhook before edit",
UserId: th.BasicUser.Id,
}, th.BasicChannel, model.CreatePostFlags{SilentNotification: true, FromIncomingWebhook: true})
require.Nil(t, err)
require.Equal(t, "true", createdPost.GetProp(model.PostPropsFromWebhook))
createdPost.Message = "webhook after edit"
createdPost.DelProp(model.PostPropsFromWebhook)
updatedPost, _, err := th.App.UpdatePost(th.Context, createdPost, nil)
require.Nil(t, err)
require.Equal(t, "true", updatedPost.GetProp(model.PostPropsFromWebhook))
})
t.Run("should preserve from_bot on non-SafeUpdate edit", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
th.LinkUserToTeam(t, botUser, th.BasicTeam)
_, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false)
require.Nil(t, appErr)
createdPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "bot before edit",
UserId: bot.UserId,
}, th.BasicChannel, model.CreatePostFlags{SilentNotification: true})
require.Nil(t, err)
require.Equal(t, "true", createdPost.GetProp(model.PostPropsFromBot))
createdPost.Message = "bot after edit"
createdPost.DelProp(model.PostPropsFromBot)
updatedPost, _, err := th.App.UpdatePost(th.Context, createdPost, nil)
require.Nil(t, err)
require.Equal(t, "true", updatedPost.GetProp(model.PostPropsFromBot))
})
t.Run("should not promote post to silent via edit payload", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
createdPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "normal post",
UserId: th.BasicUser.Id,
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
require.False(t, createdPost.HasSilentNotification())
createdPost.Message = "edited post"
createdPost.AddProp(model.PostPropsSilentNotification, true)
updatedPost, _, err := th.App.UpdatePost(th.Context, createdPost, nil)
require.Nil(t, err)
require.False(t, updatedPost.HasSilentNotification())
})
t.Run("should not preserve force notification on non-SafeUpdate edit", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
createdPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "forced before edit",
UserId: th.BasicUser.Id,
}, th.BasicChannel, model.CreatePostFlags{ForceNotification: true})
require.Nil(t, err)
require.True(t, createdPost.HasForceNotification())
createdPost.Message = "forced after edit"
createdPost.AddProp(model.PostPropsForceNotification, model.NewId())
updatedPost, _, err := th.App.UpdatePost(th.Context, createdPost, nil)
require.Nil(t, err)
require.False(t, updatedPost.HasForceNotification())
})
t.Run("should add silent notification prop for OAuth app posts", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
session := *th.Context.Session()
session.IsOAuth = true
oauthCtx := th.Context.WithSession(&session)
createdPost, _, err := th.App.CreatePost(oauthCtx, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "oauth silent",
UserId: th.BasicUser.Id,
}, th.BasicChannel, model.CreatePostFlags{SilentNotification: true})
require.Nil(t, err)
require.True(t, createdPost.HasSilentNotification())
})
t.Run("should add silent notification prop for incoming webhook flag", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
createdPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "webhook silent",
UserId: th.BasicUser.Id,
}, th.BasicChannel, model.CreatePostFlags{SilentNotification: true, FromIncomingWebhook: true})
require.Nil(t, err)
require.True(t, createdPost.HasSilentNotification())
require.Equal(t, "true", createdPost.GetProp(model.PostPropsFromWebhook))
})
t.Run("should add silent notification prop for plugin posts", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
createdPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "plugin silent",
UserId: th.BasicUser.Id,
}, th.BasicChannel, model.CreatePostFlags{SilentNotification: true, FromPlugin: true})
require.Nil(t, err)
require.True(t, createdPost.HasSilentNotification())
require.Equal(t, "true", createdPost.GetProp(model.PostPropsFromPlugin))
})
t.Run("should strip forged notification-policy props from human payload", func(t *testing.T) {
// silent_notification and force_notification are authorization-load-bearing
// (they change notification delivery for everyone in the channel) and must
// be stripped from non-integration callers regardless of hardened-mode
// setting. The from_* identity markers are not stripped by default in v11
// — they remain user-settable for backward compatibility. Hardened mode
// rejects from_webhook and from_plugin via ContainsIntegrationsReservedProps;
// from_bot and from_oauth_app are not currently in that reserved set.
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
createdPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "forged integration props",
UserId: th.BasicUser.Id,
Props: model.StringInterface{
model.PostPropsSilentNotification: true,
model.PostPropsForceNotification: "forged-id",
},
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
require.Empty(t, createdPost.GetProp(model.PostPropsSilentNotification))
require.Empty(t, createdPost.GetProp(model.PostPropsForceNotification))
})
t.Run("force notification wins over silent", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
th.LinkUserToTeam(t, botUser, th.BasicTeam)
_, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false)
require.Nil(t, appErr)
createdPost, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "forced notify",
UserId: bot.UserId,
}, th.BasicChannel, model.CreatePostFlags{SilentNotification: true, ForceNotification: true})
require.Nil(t, err)
require.True(t, createdPost.HasSilentNotification())
require.False(t, createdPost.IsNotificationSuppressed())
})
t.Run("should add silent notification prop for bot posts", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
th.LinkUserToTeam(t, botUser, th.BasicTeam)
_, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false)
require.Nil(t, appErr)
postToCreate := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "silent status",
UserId: bot.UserId,
}
createdPost, _, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{SilentNotification: true})
require.Nil(t, err)
require.True(t, createdPost.HasSilentNotification())
require.True(t, createdPost.IsNotificationSuppressed())
})
t.Run("should reject silent notification with persistent notifications", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostPriority = true
*cfg.ServiceSettings.AllowPersistentNotifications = true
})
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
th.LinkUserToTeam(t, botUser, th.BasicTeam)
_, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false)
require.Nil(t, appErr)
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false)
require.Nil(t, appErr)
_, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "silent persistent @" + th.BasicUser2.Username,
UserId: bot.UserId,
Metadata: &model.PostMetadata{
Priority: &model.PostPriority{
Priority: model.NewPointer(model.PostPriorityUrgent),
PersistentNotifications: model.NewPointer(true),
},
},
}, th.BasicChannel, model.CreatePostFlags{SilentNotification: true})
require.NotNil(t, err)
require.Equal(t, "api.post.create_post.silent_persistent_notification.app_error", err.Id)
})
t.Run("silent notification does not increase DM unread", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
dm, appErr := th.App.createDirectChannel(th.Context, botUser.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
_, appErr = th.App.MarkChannelsAsViewed(th.Context, []string{dm.Id}, th.BasicUser2.Id, "", false, false)
require.Nil(t, appErr)
unreadBefore, appErr := th.App.GetChannelUnread(th.Context, dm.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{
ChannelId: dm.Id,
Message: "silent dm",
UserId: botUser.Id,
}, dm, model.CreatePostFlags{SilentNotification: true})
require.Nil(t, appErr)
unreadAfter, appErr := th.App.GetChannelUnread(th.Context, dm.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
require.Equal(t, unreadBefore.MsgCount, unreadAfter.MsgCount)
})
t.Run("silent notification does not increase GM unread", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, botUser.Id, "")
require.Nil(t, appErr)
gm, appErr := th.App.createGroupChannel(th.Context, []string{th.BasicUser.Id, th.BasicUser2.Id, botUser.Id}, th.BasicUser.Id)
require.Nil(t, appErr)
_, appErr = th.App.MarkChannelsAsViewed(th.Context, []string{gm.Id}, th.BasicUser2.Id, "", false, false)
require.Nil(t, appErr)
unreadBefore, appErr := th.App.GetChannelUnread(th.Context, gm.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{
ChannelId: gm.Id,
Message: "silent gm",
UserId: botUser.Id,
}, gm, model.CreatePostFlags{SilentNotification: true})
require.Nil(t, appErr)
unreadAfter, appErr := th.App.GetChannelUnread(th.Context, gm.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
require.Equal(t, unreadBefore.MsgCount, unreadAfter.MsgCount)
})
t.Run("silent notification does not increase channel unread", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
th.LinkUserToTeam(t, botUser, th.BasicTeam)
_, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false)
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "baseline",
UserId: th.BasicUser.Id,
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, appErr = th.App.MarkChannelsAsViewed(th.Context, []string{th.BasicChannel.Id}, th.BasicUser2.Id, "", false, false)
require.Nil(t, appErr)
unreadBefore, appErr := th.App.GetChannelUnread(th.Context, th.BasicChannel.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "silent update",
UserId: bot.UserId,
}, th.BasicChannel, model.CreatePostFlags{SilentNotification: true})
require.Nil(t, appErr)
unreadAfter, appErr := th.App.GetChannelUnread(th.Context, th.BasicChannel.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
require.Equal(t, unreadBefore.MsgCount, unreadAfter.MsgCount)
})
t.Run("creates post with type card", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "card post",
Type: model.PostTypeCard,
}
rpost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.NotNil(t, rpost)
assert.Equal(t, model.PostTypeCard, rpost.Type)
assert.Equal(t, "card post", rpost.Message)
})
t.Run("Should remove post file IDs for burn on read posts", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable BurnOnRead feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.BurnOnRead = true })
enableBoRFeature(th)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "hello world",
Type: model.PostTypeBurnOnRead,
FileIds: []string{model.NewId()},
}
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.Empty(t, createdPost.FileIds)
})
t.Run("should reject burn-on-read posts in shared channels", func(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
t.Cleanup(func() {
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
})
th := setupSharedChannels(t).InitBasic(t)
enableBoRFeature(th)
channel := th.CreateChannel(t, th.BasicTeam)
sc := &model.SharedChannel{
ChannelId: channel.Id,
TeamId: th.BasicTeam.Id,
Type: channel.Type,
Home: true,
ShareName: "shared-bor-test",
CreatorId: th.BasicUser.Id,
RemoteId: model.NewId(),
}
_, scErr := th.Server.Store().SharedChannel().Save(sc)
require.NoError(t, scErr)
channel.Shared = new(true)
_, err := th.Server.Store().Channel().Update(th.Context, channel)
require.NoError(t, err)
post := &model.Post{
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
Message: "burn-on-read in shared channel",
Type: model.PostTypeBurnOnRead,
}
createdPost, _, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{SetOnline: true})
require.NotNil(t, appErr)
require.Nil(t, createdPost)
require.Equal(t, "api.post.fill_in_post_props.burn_on_read.shared_channel.app_error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("should allow burn-on-read posts in non-shared channels", func(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
t.Cleanup(func() {
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
})
th := Setup(t).InitBasic(t)
enableBoRFeature(th)
channel := th.CreateChannel(t, th.BasicTeam)
require.False(t, channel.IsShared())
post := &model.Post{
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
Message: "burn-on-read in non-shared channel",
Type: model.PostTypeBurnOnRead,
}
createdPost, _, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})
}
func TestPatchPost(t *testing.T) {
mainHelper.Parallel(t)
t.Run("call PreparePostForClient before returning", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://mymattermost.com"
*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())
imageURL := "http://mydomain.com/myimage"
proxiedImageURL := "http://mymattermost.com/v1/workspace/image?url=http%3A%2F%2Fmydomain.com%2Fmyimage"
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "![image](http://mydomain/anotherimage)",
UserId: th.BasicUser.Id,
}
rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.NotEqual(t, "![image]("+proxiedImageURL+")", rpost.Message)
patch := &model.PostPatch{
Message: new("![image](" + imageURL + ")"),
}
rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patch, nil)
require.Nil(t, err)
assert.Equal(t, "![image]("+proxiedImageURL+")", rpost.Message)
})
t.Run("Sets Prop MENTION_HIGHLIGHT_DISABLED when it should", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This post does not have mentions",
UserId: th.BasicUser.Id,
}
rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
t.Run("Does not set prop when user has USE_CHANNEL_MENTIONS", func(t *testing.T) {
patchWithNoMention := &model.PostPatch{Message: new("This patch has no channel mention")}
rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patchWithNoMention, nil)
require.Nil(t, err)
assert.Equal(t, rpost.GetProps(), model.StringInterface{})
patchWithMention := &model.PostPatch{Message: new("This patch has a mention now @here")}
rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patchWithMention, nil)
require.Nil(t, err)
assert.Equal(t, rpost.GetProps(), model.StringInterface{})
})
t.Run("Sets prop when user does not have USE_CHANNEL_MENTIONS", func(t *testing.T) {
th.RemovePermissionFromRole(t, model.PermissionUseChannelMentions.Id, model.ChannelUserRoleId)
th.RemovePermissionFromRole(t, model.PermissionUseChannelMentions.Id, model.ChannelAdminRoleId)
patchWithNoMention := &model.PostPatch{Message: new("This patch still does not have a mention")}
rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patchWithNoMention, nil)
require.Nil(t, err)
assert.Equal(t, rpost.GetProps(), model.StringInterface{})
patchWithMention := &model.PostPatch{Message: new("This patch has a mention now @here")}
rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patchWithMention, nil)
require.Nil(t, err)
assert.Equal(t, rpost.GetProp(model.PostPropsMentionHighlightDisabled), true)
th.AddPermissionToRole(t, model.PermissionUseChannelMentions.Id, model.ChannelUserRoleId)
th.AddPermissionToRole(t, model.PermissionUseChannelMentions.Id, model.ChannelAdminRoleId)
})
})
t.Run("cannot patch post in restricted DM", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageTeam
})
// Create a DM channel between two users who don't share a team
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
// Ensure the two users do not share a team
teams, err := th.App.GetTeamsForUser(th.BasicUser.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
teams, err = th.App.GetTeamsForUser(th.BasicUser2.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser2.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
// Create separate teams for each user
team1 := th.CreateTeam(t)
team2 := th.CreateTeam(t)
th.LinkUserToTeam(t, th.BasicUser, team1)
th.LinkUserToTeam(t, th.BasicUser2, team2)
// Create a post in the DM channel
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: dmChannel.Id,
Message: "test post",
}
post, _, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{})
require.Nil(t, err)
// Try to patch the post
patch := &model.PostPatch{
Message: new("updated message"),
}
_, _, appErr := th.App.PatchPost(th.Context, post.Id, patch, model.DefaultUpdatePostOptions())
require.NotNil(t, appErr)
require.Equal(t, "api.post.patch_post.can_not_update_post_in_restricted_dm.error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
// Reset config
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageAny
})
})
}
func TestCreatePostAsUser(t *testing.T) {
mainHelper.Parallel(t)
t.Run("marks channel as viewed for regular user", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test",
UserId: th.BasicUser.Id,
}
channelMemberBefore, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
time.Sleep(1 * time.Millisecond)
_, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true)
require.Nil(t, appErr)
channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
require.Greater(t, channelMemberAfter.LastViewedAt, channelMemberBefore.LastViewedAt)
})
t.Run("does not mark channel as viewed for webhook from user", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test",
UserId: th.BasicUser.Id,
}
channelMemberBefore, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
time.Sleep(1 * time.Millisecond)
// from_webhook is now server-set via FromIncomingWebhook flag, not client-supplied Props.
// Route through CreatePostAsUserWithFlags so the "skip view-marking for webhook" branch
// (post.go) is actually exercised — CreatePost alone never touches LastViewedAt.
createdPost, _, appErr := th.App.CreatePostAsUserWithFlags(th.Context, post, "", model.CreatePostFlags{
SetOnline: true,
FromIncomingWebhook: true,
})
require.Nil(t, appErr)
require.Equal(t, "true", createdPost.GetProp(model.PostPropsFromWebhook))
channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
require.Equal(t, channelMemberAfter.LastViewedAt, channelMemberBefore.LastViewedAt)
})
t.Run("does not mark channel as viewed for bot user in channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
th.LinkUserToTeam(t, botUser, th.BasicTeam)
th.AddUserToChannel(t, botUser, th.BasicChannel)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test",
UserId: bot.UserId,
}
channelMemberBefore, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
time.Sleep(1 * time.Millisecond)
_, _, appErr = th.App.CreatePostAsUser(th.Context, post, "", true)
require.Nil(t, appErr)
channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
require.Equal(t, channelMemberAfter.LastViewedAt, channelMemberBefore.LastViewedAt)
})
t.Run("does not log warning for bot user not in channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
bot := th.CreateBot(t)
botUser, appErr := th.App.GetUser(bot.UserId)
require.Nil(t, appErr)
th.LinkUserToTeam(t, botUser, th.BasicTeam)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test",
UserId: bot.UserId,
}
_, _, appErr = th.App.CreatePostAsUser(th.Context, post, "", true)
require.Nil(t, appErr)
require.NoError(t, th.TestLogger.Flush())
testlib.AssertNoLog(t, th.LogBuffer, mlog.LvlWarn.Name, "Failed to get membership")
})
t.Run("marks channel as viewed for reply post when CRT is off", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOff
})
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test",
UserId: th.BasicUser2.Id,
}
rootPost, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true)
require.Nil(t, appErr)
channelMemberBefore, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
time.Sleep(1 * time.Millisecond)
replyPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test reply",
UserId: th.BasicUser.Id,
RootId: rootPost.Id,
}
_, _, appErr = th.App.CreatePostAsUser(th.Context, replyPost, "", true)
require.Nil(t, appErr)
channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
require.NotEqual(t, channelMemberAfter.LastViewedAt, channelMemberBefore.LastViewedAt)
})
t.Run("does not mark channel as viewed for reply post when CRT is on", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test",
UserId: th.BasicUser2.Id,
}
rootPost, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true)
require.Nil(t, appErr)
channelMemberBefore, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
time.Sleep(1 * time.Millisecond)
replyPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test reply",
UserId: th.BasicUser.Id,
RootId: rootPost.Id,
}
_, _, appErr = th.App.CreatePostAsUser(th.Context, replyPost, "", true)
require.Nil(t, appErr)
channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
require.Equal(t, channelMemberAfter.LastViewedAt, channelMemberBefore.LastViewedAt)
})
t.Run("cannot create post as user in restricted DM", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageTeam
})
// Create a DM channel between two users who don't share a team
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
// Ensure the two users do not share a team
teams, err := th.App.GetTeamsForUser(th.BasicUser.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
teams, err = th.App.GetTeamsForUser(th.BasicUser2.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser2.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
// Create separate teams for each user
team1 := th.CreateTeam(t)
team2 := th.CreateTeam(t)
th.LinkUserToTeam(t, th.BasicUser, team1)
th.LinkUserToTeam(t, th.BasicUser2, team2)
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: dmChannel.Id,
Message: "test post",
}
_, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true)
require.NotNil(t, appErr)
require.Equal(t, "api.post.create_post.can_not_post_in_restricted_dm.error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
// Reset config
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageAny
})
})
}
func TestPatchPostInArchivedChannel(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
archivedChannel := th.CreateChannel(t, th.BasicTeam)
post := th.CreatePost(t, archivedChannel)
appErr := th.App.DeleteChannel(th.Context, archivedChannel, "")
require.Nil(t, appErr)
_, _, err := th.App.PatchPost(th.Context, post.Id, &model.PostPatch{IsPinned: new(true)}, nil)
require.NotNil(t, err)
require.Equal(t, "api.post.patch_post.can_not_update_post_in_deleted.error", err.Id)
}
func TestUpdateEphemeralPost(t *testing.T) {
mainHelper.Parallel(t)
t.Run("Post contains preview if the user has permissions", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
referencedPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
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, referencedPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id)
testPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: permalink,
UserId: th.BasicUser.Id,
}
testPost, _ = th.App.UpdateEphemeralPost(th.Context, th.BasicUser.Id, testPost)
require.NotNil(t, testPost.Metadata)
require.Len(t, testPost.Metadata.Embeds, 1)
require.Equal(t, model.PostEmbedPermalink, testPost.Metadata.Embeds[0].Type)
})
t.Run("Post does not contain preview if the user has no permissions", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, th.BasicUser, privateChannel)
th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel)
referencedPost := &model.Post{
ChannelId: privateChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
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, referencedPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id)
testPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: permalink,
UserId: th.BasicUser2.Id,
}
testPost, _ = th.App.UpdateEphemeralPost(th.Context, th.BasicUser2.Id, testPost)
require.Nil(t, testPost.Metadata.Embeds)
})
}
func TestUpdatePost(t *testing.T) {
mainHelper.Parallel(t)
t.Run("call PreparePostForClient before returning", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://mymattermost.com"
*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())
imageURL := "http://mydomain.com/myimage"
proxiedImageURL := "http://mymattermost.com/v1/workspace/image?url=http%3A%2F%2Fmydomain.com%2Fmyimage"
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "![image](http://mydomain/anotherimage)",
UserId: th.BasicUser.Id,
}
rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
assert.NotEqual(t, "![image]("+proxiedImageURL+")", rpost.Message)
post.Id = rpost.Id
post.Message = "![image](" + imageURL + ")"
rpost, isMemberForPreviews, err := th.App.UpdatePost(th.Context, post, nil)
require.True(t, isMemberForPreviews)
require.Nil(t, err)
assert.Equal(t, "![image]("+proxiedImageURL+")", rpost.Message)
})
t.Run("Sets PostPropsPreviewedPost when a post is updated to have a permalink as the first link", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
referencedPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
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, referencedPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id)
channelForTestPost := th.CreateChannel(t, th.BasicTeam)
testPost := &model.Post{
ChannelId: channelForTestPost.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
testPost, _, err = th.App.CreatePost(th.Context, testPost, channelForTestPost, model.CreatePostFlags{})
require.Nil(t, err)
assert.Equal(t, model.StringInterface{}, testPost.GetProps())
testPost.Message = permalink
testPost, isMemberForPreviews, err := th.App.UpdatePost(th.Context, testPost, nil)
require.True(t, isMemberForPreviews)
require.Nil(t, err)
assert.Equal(t, model.StringInterface{model.PostPropsPreviewedPost: referencedPost.Id}, testPost.GetProps())
})
t.Run("sanitizes post metadata appropriately", func(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.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
user1 := th.CreateUser(t)
user2 := th.CreateUser(t)
directChannel, err := th.App.createDirectChannel(th.Context, user1.Id, user2.Id)
require.Nil(t, err)
th.Context.Session().UserId = th.BasicUser.Id
testCases := []struct {
Description string
Channel *model.Channel
Author string
Length int
}{
{
Description: "removes metadata from post for members who cannot read channel",
Channel: directChannel,
Author: user1.Id,
Length: 0,
},
{
Description: "does not remove metadata from post for members who can read channel",
Channel: th.BasicChannel,
Author: th.BasicUser.Id,
Length: 1,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
referencedPost := &model.Post{
ChannelId: testCase.Channel.Id,
Message: "hello world",
UserId: testCase.Author,
}
_, _, err = th.App.CreatePost(th.Context, referencedPost, testCase.Channel, model.CreatePostFlags{})
require.Nil(t, err)
previewPost := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
}
previewPost, _, err = th.App.CreatePost(th.Context, previewPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id)
previewPost.Message = permalink
previewPost, isMemberForPreviews, err := th.App.UpdatePost(th.Context, previewPost, nil)
require.True(t, isMemberForPreviews)
require.Nil(t, err)
require.Len(t, previewPost.Metadata.Embeds, testCase.Length)
})
}
})
t.Run("should strip client-supplied embeds", func(t *testing.T) {
// MM-67055: Verify that client-supplied metadata.embeds are stripped.
// This prevents WebSocket message spoofing via permalink embeds.
//
// Note: Priority and Acknowledgements are stored in separate database tables,
// not in post metadata. Shared Channels handles them separately via
// syncRemotePriorityMetadata and syncRemoteAcknowledgementsMetadata after
// calling UpdatePost. See sync_recv.go::upsertSyncPost
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
th.Context.Session().UserId = th.BasicUser.Id
// Create a basic post
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "original message",
UserId: th.BasicUser.Id,
}
createdPost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
// Try to update with spoofed embeds (the attack vector)
updatePost := &model.Post{
Id: createdPost.Id,
ChannelId: th.BasicChannel.Id,
Message: "updated message",
UserId: th.BasicUser.Id,
Metadata: &model.PostMetadata{
Embeds: []*model.PostEmbed{
{
Type: model.PostEmbedPermalink,
Data: &model.PreviewPost{
PostID: "spoofed-post-id",
Post: &model.Post{
Id: "spoofed-post-id",
UserId: th.BasicUser2.Id,
Message: "Spoofed message from another user!",
},
},
},
},
},
}
updatedPost, _, err := th.App.UpdatePost(th.Context, updatePost, nil)
require.Nil(t, err)
require.NotNil(t, updatedPost.Metadata)
// Verify embeds were stripped
assert.Empty(t, updatedPost.Metadata.Embeds, "spoofed embeds should be stripped")
})
t.Run("cannot update post in restricted DM", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageTeam
})
// Create a DM channel between two users who don't share a team
dmChannel := th.CreateDmChannel(t, th.BasicUser2)
// Ensure the two users do not share a team
teams, err := th.App.GetTeamsForUser(th.BasicUser.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
teams, err = th.App.GetTeamsForUser(th.BasicUser2.Id)
require.Nil(t, err)
for _, team := range teams {
teamErr := th.App.RemoveUserFromTeam(th.Context, team.Id, th.BasicUser2.Id, th.SystemAdminUser.Id)
require.Nil(t, teamErr)
}
// Create separate teams for each user
team1 := th.CreateTeam(t)
team2 := th.CreateTeam(t)
th.LinkUserToTeam(t, th.BasicUser, team1)
th.LinkUserToTeam(t, th.BasicUser2, team2)
// Create a post in the DM channel
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: dmChannel.Id,
Message: "test post",
}
post, _, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{})
require.Nil(t, err)
// Try to update the post
post.Message = "updated message"
_, _, appErr := th.App.UpdatePost(th.Context, post, model.DefaultUpdatePostOptions())
require.NotNil(t, appErr)
require.Equal(t, "api.post.update_post.can_not_update_post_in_restricted_dm.error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
// Reset config
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.RestrictDirectMessage = model.DirectMessageAny
})
})
}
func TestSearchPostsForUser(t *testing.T) {
mainHelper.Parallel(t)
perPage := 5
searchTerm := "searchTerm"
setup := func(t *testing.T, enableElasticsearch bool) (*TestHelper, []*model.Post) {
th := Setup(t).InitBasic(t)
posts := make([]*model.Post, 7)
for i := 0; i < cap(posts); i++ {
post, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: searchTerm,
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
posts[i] = post
}
if enableElasticsearch {
th.App.Srv().SetLicense(model.NewTestLicense("elastic_search"))
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableIndexing = true
*cfg.ElasticsearchSettings.EnableSearching = true
})
} else {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ElasticsearchSettings.EnableSearching = false
})
}
return th, posts
}
t.Run("should return everything as first page of posts from database", func(t *testing.T) {
mainHelper.Parallel(t)
th, posts := setup(t, false)
page := 0
results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Equal(t, []string{
posts[6].Id,
posts[5].Id,
posts[4].Id,
posts[3].Id,
posts[2].Id,
posts[1].Id,
posts[0].Id,
}, results.Order)
assert.True(t, allPostHaveMembership)
})
t.Run("should not return later pages of posts from database", func(t *testing.T) {
mainHelper.Parallel(t)
th, _ := setup(t, false)
page := 1
results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Equal(t, []string{}, results.Order)
assert.True(t, allPostHaveMembership)
})
t.Run("should return first page of posts from ElasticSearch", func(t *testing.T) {
mainHelper.Parallel(t)
th, posts := setup(t, true)
page := 0
resultsPage := []string{
posts[6].Id,
posts[5].Id,
posts[4].Id,
posts[3].Id,
posts[2].Id,
}
es := &mocks.SearchEngineInterface{}
es.On("SearchPosts", mock.Anything, mock.Anything, page, perPage).Return(resultsPage, nil, nil)
es.On("Start").Return(nil).Maybe()
es.On("IsActive").Return(true)
es.On("IsHealthy").Return(true)
es.On("IsSearchEnabled").Return(true)
th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = es
defer func() {
th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil
}()
results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Equal(t, resultsPage, results.Order)
assert.True(t, allPostHaveMembership)
es.AssertExpectations(t)
})
t.Run("should return later pages of posts from ElasticSearch", func(t *testing.T) {
mainHelper.Parallel(t)
th, posts := setup(t, true)
page := 1
resultsPage := []string{
posts[1].Id,
posts[0].Id,
}
es := &mocks.SearchEngineInterface{}
es.On("SearchPosts", mock.Anything, mock.Anything, page, perPage).Return(resultsPage, nil, nil)
es.On("Start").Return(nil).Maybe()
es.On("IsActive").Return(true)
es.On("IsHealthy").Return(true)
es.On("IsSearchEnabled").Return(true)
th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = es
defer func() {
th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil
}()
results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Equal(t, resultsPage, results.Order)
assert.True(t, allPostHaveMembership)
es.AssertExpectations(t)
})
t.Run("should fall back to database if ElasticSearch fails on first page", func(t *testing.T) {
mainHelper.Parallel(t)
th, posts := setup(t, true)
page := 0
es := &mocks.SearchEngineInterface{}
es.On("SearchPosts", mock.Anything, mock.Anything, page, perPage).Return(nil, nil, &model.AppError{})
es.On("GetName").Return("mock")
es.On("Start").Return(nil).Maybe()
es.On("IsActive").Return(true)
es.On("IsHealthy").Return(true)
es.On("IsSearchEnabled").Return(true)
th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = es
defer func() {
th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil
}()
results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Equal(t, []string{
posts[6].Id,
posts[5].Id,
posts[4].Id,
posts[3].Id,
posts[2].Id,
posts[1].Id,
posts[0].Id,
}, results.Order)
assert.True(t, allPostHaveMembership)
es.AssertExpectations(t)
})
t.Run("should return nothing if ElasticSearch fails on later pages", func(t *testing.T) {
mainHelper.Parallel(t)
th, _ := setup(t, true)
page := 1
es := &mocks.SearchEngineInterface{}
es.On("SearchPosts", mock.Anything, mock.Anything, page, perPage).Return(nil, nil, &model.AppError{})
es.On("GetName").Return("mock")
es.On("Start").Return(nil).Maybe()
es.On("IsActive").Return(true)
es.On("IsHealthy").Return(true)
es.On("IsSearchEnabled").Return(true)
th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = es
defer func() {
th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil
}()
results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Equal(t, []string{}, results.Order)
assert.True(t, allPostHaveMembership)
es.AssertExpectations(t)
})
t.Run("should return the same results if there is a tilde in the channel name", func(t *testing.T) {
mainHelper.Parallel(t)
th, _ := setup(t, false)
page := 0
searchQueryWithPrefix := fmt.Sprintf("in:~%s %s", th.BasicChannel.Name, searchTerm)
resultsWithPrefix, _, err := th.App.SearchPostsForUser(th.Context, searchQueryWithPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Greater(t, len(resultsWithPrefix.PostList.Posts), 0, "searching using a tilde in front of a channel should return results")
searchQueryWithoutPrefix := fmt.Sprintf("in:%s %s", th.BasicChannel.Name, searchTerm)
resultsWithoutPrefix, _, err := th.App.SearchPostsForUser(th.Context, searchQueryWithoutPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Equal(t, len(resultsWithPrefix.Posts), len(resultsWithoutPrefix.Posts), "searching using a tilde in front of a channel should return the same number of results")
for k, v := range resultsWithPrefix.Posts {
assert.Equal(t, v, resultsWithoutPrefix.Posts[k], "post at %s was different", k)
}
})
t.Run("should return the same results if there is an 'at' in the user", func(t *testing.T) {
mainHelper.Parallel(t)
th, _ := setup(t, false)
page := 0
searchQueryWithPrefix := fmt.Sprintf("from:@%s %s", th.BasicUser.Username, searchTerm)
resultsWithPrefix, _, err := th.App.SearchPostsForUser(th.Context, searchQueryWithPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Greater(t, len(resultsWithPrefix.PostList.Posts), 0, "searching using a 'at' symbol in front of a channel should return results")
searchQueryWithoutPrefix := fmt.Sprintf("from:@%s %s", th.BasicUser.Username, searchTerm)
resultsWithoutPrefix, _, err := th.App.SearchPostsForUser(th.Context, searchQueryWithoutPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage)
assert.Nil(t, err)
assert.Equal(t, len(resultsWithPrefix.Posts), len(resultsWithoutPrefix.Posts), "searching using an 'at' symbol in front of a channel should return the same number of results")
for k, v := range resultsWithPrefix.Posts {
assert.Equal(t, v, resultsWithoutPrefix.Posts[k], "post at %s was different", k)
}
})
}
func TestCountMentionsFromPost(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should not count posts without mentions", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test2",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test3",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 0, count)
})
t.Run("should count keyword mentions", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
user2.NotifyProps[model.MentionKeysNotifyProp] = "apple"
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test2",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "apple",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// post1 and post3 should mention the user
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 2, count)
})
t.Run("should count channel-wide mentions when enabled", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
user2.NotifyProps[model.ChannelMentionsNotifyProp] = "true"
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "@channel",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "@all",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// post2 and post3 should mention the user
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 2, count)
})
t.Run("should not count channel-wide mentions when disabled for user", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
user2.NotifyProps[model.ChannelMentionsNotifyProp] = "false"
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "@channel",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "@all",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 0, count)
})
t.Run("should not count channel-wide mentions when disabled for channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
user2.NotifyProps[model.ChannelMentionsNotifyProp] = "true"
_, err := th.App.UpdateChannelMemberNotifyProps(th.Context, map[string]string{
model.IgnoreChannelMentionsNotifyProp: model.IgnoreChannelMentionsOn,
}, channel.Id, user2.Id)
require.Nil(t, err)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "@channel",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "@all",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 0, count)
})
t.Run("should count comment mentions when using COMMENTS_NOTIFY_ROOT", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
user2.NotifyProps[model.CommentsNotifyProp] = model.CommentsNotifyRoot
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
Message: "test",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
RootId: post1.Id,
Message: "test2",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
post3, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test3",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
RootId: post3.Id,
Message: "test4",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
RootId: post3.Id,
Message: "test5",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// post2 should mention the user
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 1, count)
})
t.Run("should count comment mentions when using COMMENTS_NOTIFY_ANY", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
user2.NotifyProps[model.CommentsNotifyProp] = model.CommentsNotifyAny
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
Message: "test",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
RootId: post1.Id,
Message: "test2",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
post3, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test3",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
RootId: post3.Id,
Message: "test4",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
RootId: post3.Id,
Message: "test5",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// post2 and post5 should mention the user
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 2, count)
})
t.Run("should count mentions caused by being added to the channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test",
Type: model.PostTypeAddToChannel,
Props: map[string]any{
model.PostPropsAddedUserId: model.NewId(),
},
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test2",
Type: model.PostTypeAddToChannel,
Props: map[string]any{
model.PostPropsAddedUserId: user2.Id,
},
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test3",
Type: model.PostTypeAddToChannel,
Props: map[string]any{
model.PostPropsAddedUserId: user2.Id,
},
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// should be mentioned by post2 and post3
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 2, count)
})
t.Run("should return the number of posts made by the other user for a direct channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel, err := th.App.createDirectChannel(th.Context, user1.Id, user2.Id)
require.Nil(t, err)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test2",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 2, count)
count, _, _, err = th.App.countMentionsFromPost(th.Context, user1, post1)
assert.Nil(t, err)
assert.Equal(t, 0, count)
})
t.Run("should return the number of posts made by the other user for a group message", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
user3 := th.SystemAdminUser
channel, err := th.App.createGroupChannel(th.Context, []string{user1.Id, user2.Id, user3.Id}, user1.Id)
require.Nil(t, err)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test2",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user3.Id,
ChannelId: channel.Id,
Message: "test3",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 3, count)
count, _, _, err = th.App.countMentionsFromPost(th.Context, user1, post1)
assert.Nil(t, err)
assert.Equal(t, 1, count)
})
t.Run("should not count mentions from the before the given post", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
_, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
post2, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test2",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// post1 and post3 should mention the user, but we only count post3
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post2)
assert.Nil(t, err)
assert.Equal(t, 1, count)
})
t.Run("should not count mentions from the user's own posts", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// post2 should mention the user
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 1, count)
})
t.Run("should include comments made before the given post when counting comment mentions", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
user2.NotifyProps[model.CommentsNotifyProp] = model.CommentsNotifyAny
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test1",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
RootId: post1.Id,
Message: "test2",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
post3, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test3",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
RootId: post1.Id,
Message: "test4",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// post4 should mention the user
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post3)
assert.Nil(t, err)
assert.Equal(t, 1, count)
})
t.Run("should not include comments made before the given post when rootPost is inaccessible", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Create an Entry license with post history limits
license := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
license.Limits = &model.LicenseLimits{
PostHistory: 10000, // Set some post history limit to enable filtering
}
th.App.Srv().SetLicense(license)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
user2.NotifyProps[model.CommentsNotifyProp] = model.CommentsNotifyAny
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test1",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
RootId: post1.Id,
Message: "test2",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
time.Sleep(time.Millisecond * 2)
post3, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test3",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
RootId: post1.Id,
Message: "test4",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// Make posts created before post3 inaccessible
e := th.App.Srv().Store().System().SaveOrUpdate(&model.System{
Name: model.SystemLastAccessiblePostTime,
Value: strconv.FormatInt(post3.CreateAt, 10),
})
require.NoError(t, e)
// post4 should mention the user, but since post2 is inaccessible due to the cloud plan's limit,
// post4 does not notify the user.
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post3)
assert.Nil(t, err)
assert.Zero(t, count)
})
t.Run("should count mentions from the user's webhook posts", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test1",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// from_webhook is now server-set via FromIncomingWebhook flag, not client-supplied Props.
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true, FromIncomingWebhook: true})
require.Nil(t, err)
// post3 should mention the user
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 1, count)
})
t.Run("should count multiple pages of mentions", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
numPosts := 215
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
for i := 0; i < numPosts-1; i++ {
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
}
// Every post should mention the user
count, _, _, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, numPosts, count)
})
t.Run("should count urgent mentions", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostPriority = true
})
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
user2.NotifyProps[model.MentionKeysNotifyProp] = "apple"
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
Metadata: &model.PostMetadata{
Priority: &model.PostPriority{
Priority: model.NewPointer(model.PostPriorityUrgent),
},
},
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "apple",
Metadata: &model.PostMetadata{
Priority: &model.PostPriority{
Priority: model.NewPointer(model.PostPriorityUrgent),
},
},
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// all posts mention the user but only post1, post3 are urgent
_, _, count, err := th.App.countMentionsFromPost(th.Context, user2, post1)
assert.Nil(t, err)
assert.Equal(t, 2, count)
})
}
func TestFillInPostProps(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should not add disable group highlight to post props for user with group mention permissions", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
user1 := th.BasicUser
channel := th.CreateChannel(t, th.BasicTeam)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test123123 @group1 @group2 blah blah blah",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
err = th.App.FillInPostProps(th.Context, post1, channel)
assert.Nil(t, err)
assert.Equal(t, post1.Props, model.StringInterface{})
})
t.Run("should not add disable group highlight to post props for app without license", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
id := model.NewId()
guest := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
}
guest, err := th.App.CreateGuest(th.Context, guest)
require.Nil(t, err)
th.LinkUserToTeam(t, guest, th.BasicTeam)
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, guest, channel)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: guest.Id,
ChannelId: channel.Id,
Message: "test123123 @group1 @group2 blah blah blah",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
err = th.App.FillInPostProps(th.Context, post1, channel)
assert.Nil(t, err)
assert.Equal(t, post1.Props, model.StringInterface{})
})
t.Run("should add disable group highlight to post props for guest user", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
id := model.NewId()
guest := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
}
guest, err := th.App.CreateGuest(th.Context, guest)
require.Nil(t, err)
th.LinkUserToTeam(t, guest, th.BasicTeam)
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, guest, channel)
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: guest.Id,
ChannelId: channel.Id,
Message: "test123123 @group1 @group2 blah blah blah",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
err = th.App.FillInPostProps(th.Context, post1, channel)
assert.Nil(t, err)
assert.Equal(t, post1.Props, model.StringInterface{"disable_group_highlight": true})
})
t.Run("should set AI-generated username when user ID is post creator", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
channel := th.CreateChannel(t, th.BasicTeam)
post1 := &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test post",
}
post1.AddProp(model.PostPropsAIGeneratedByUserID, user1.Id)
err := th.App.FillInPostProps(th.Context, post1, channel)
assert.Nil(t, err)
assert.Equal(t, user1.Id, post1.GetProp(model.PostPropsAIGeneratedByUserID))
assert.Equal(t, user1.Username, post1.GetProp(model.PostPropsAIGeneratedByUsername))
})
t.Run("should set AI-generated username when user ID is a bot", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
channel := th.CreateChannel(t, th.BasicTeam)
// Create a bot
bot, appErr := th.App.CreateBot(th.Context, &model.Bot{
Username: "testbot",
Description: "test bot",
OwnerId: user1.Id,
})
require.Nil(t, appErr)
post1 := &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test post generated by bot",
}
post1.AddProp(model.PostPropsAIGeneratedByUserID, bot.UserId)
err := th.App.FillInPostProps(th.Context, post1, channel)
assert.Nil(t, err)
assert.Equal(t, bot.UserId, post1.GetProp(model.PostPropsAIGeneratedByUserID))
assert.Equal(t, bot.Username, post1.GetProp(model.PostPropsAIGeneratedByUsername))
})
t.Run("should return error when user ID is a different non-bot user", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
post1 := &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test post",
}
// Try to set AI-generated user ID to a different user (not post creator, not bot)
post1.AddProp(model.PostPropsAIGeneratedByUserID, user2.Id)
err := th.App.FillInPostProps(th.Context, post1, channel)
// Should return an error since user2 is neither the post creator nor a bot
assert.NotNil(t, err)
assert.Equal(t, "FillInPostProps", err.Where)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("should remove AI-generated prop when user ID does not exist", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
channel := th.CreateChannel(t, th.BasicTeam)
post1 := &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "test post",
}
// Set AI-generated user ID to a non-existent user
post1.AddProp(model.PostPropsAIGeneratedByUserID, model.NewId())
err := th.App.FillInPostProps(th.Context, post1, channel)
assert.Nil(t, err)
// The property should be removed since the user doesn't exist
assert.Nil(t, post1.GetProp(model.PostPropsAIGeneratedByUserID))
assert.Nil(t, post1.GetProp(model.PostPropsAIGeneratedByUsername))
})
t.Run("should not populate channel mentions for channels in teams where the user is not a member", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
team2 := th.CreateTeam(t)
th.LinkUserToTeam(t, user2, team2)
// Create a channel in team2 which user1 is not a member of
channel2, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Channel in Team 2",
Name: "channel-in-team-2",
Type: model.ChannelTypeOpen,
TeamId: team2.Id,
CreatorId: user2.Id,
}, false)
require.Nil(t, err)
dmChannelBetweenUser1AndUser2 := th.CreateDmChannel(t, user2)
th.Context.Session().UserId = user1.Id
post, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: dmChannelBetweenUser1AndUser2.Id,
Message: "Testing out i should not be able to mention channel2 from team2? ~" + channel2.Name,
}, dmChannelBetweenUser1AndUser2, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
err = th.App.FillInPostProps(th.Context, post, dmChannelBetweenUser1AndUser2)
require.Nil(t, err)
mentions := post.GetProp(model.PostPropsChannelMentions)
require.Nil(t, mentions)
})
t.Run("should populate channel mentions for channels in teams where the user is a member", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
dmChannel := th.CreateDmChannel(t, user2)
th.Context.Session().UserId = user1.Id
post, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: dmChannel.Id,
Message: "Check out ~" + channel.Name,
}, dmChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
mentions := post.GetProp(model.PostPropsChannelMentions)
require.NotNil(t, mentions)
mentionsMap, ok := mentions.(map[string]any)
require.True(t, ok)
require.Contains(t, mentionsMap, channel.Name)
})
}
func TestThreadMembership(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should update memberships for conversation participants", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
user1 := th.BasicUser
user2 := th.BasicUser2
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
postRoot, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "root post",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
RootId: postRoot.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// first user should now be part of the thread since they replied to a post
memberships, err2 := th.App.GetThreadMembershipsForUser(user1.Id, th.BasicTeam.Id)
require.NoError(t, err2)
require.Len(t, memberships, 1)
// second user should also be part of a thread since they were mentioned
memberships, err2 = th.App.GetThreadMembershipsForUser(user2.Id, th.BasicTeam.Id)
require.NoError(t, err2)
require.Len(t, memberships, 1)
post2, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
Message: "second post",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
RootId: post2.Id,
Message: fmt.Sprintf("@%s", user1.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// first user should now be part of two threads
memberships, err2 = th.App.GetThreadMembershipsForUser(user1.Id, th.BasicTeam.Id)
require.NoError(t, err2)
require.Len(t, memberships, 2)
})
}
func TestFollowThreadSkipsParticipants(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
channel := th.BasicChannel
user := th.BasicUser
user2 := th.BasicUser2
sysadmin := th.SystemAdminUser
appErr := th.App.JoinChannel(th.Context, channel, user.Id)
require.Nil(t, appErr)
appErr = th.App.JoinChannel(th.Context, channel, user2.Id)
require.Nil(t, appErr)
_, appErr = th.App.JoinUserToTeam(th.Context, th.BasicTeam, sysadmin, sysadmin.Id)
require.Nil(t, appErr)
appErr = th.App.JoinChannel(th.Context, channel, sysadmin.Id)
require.Nil(t, appErr)
p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + sysadmin.Username}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
threadMembership, appErr := th.App.GetThreadMembershipForUser(user.Id, p1.Id)
require.Nil(t, appErr)
thread, appErr := th.App.GetThreadForUser(th.Context, threadMembership, false)
require.Nil(t, appErr)
require.Len(t, thread.Participants, 1) // length should be 1, the original poster, since sysadmin was just mentioned but didn't post
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: sysadmin.Id, ChannelId: channel.Id, Message: "sysadmin reply"}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
threadMembership, appErr = th.App.GetThreadMembershipForUser(user.Id, p1.Id)
require.Nil(t, appErr)
thread, appErr = th.App.GetThreadForUser(th.Context, threadMembership, false)
require.Nil(t, appErr)
require.Len(t, thread.Participants, 2) // length should be 2, the original poster and sysadmin, since sysadmin participated now
// another user follows the thread
appErr = th.App.UpdateThreadFollowForUser(user2.Id, th.BasicTeam.Id, p1.Id, true)
require.Nil(t, appErr)
threadMembership, appErr = th.App.GetThreadMembershipForUser(user2.Id, p1.Id)
require.Nil(t, appErr)
thread, appErr = th.App.GetThreadForUser(th.Context, threadMembership, false)
require.Nil(t, appErr)
require.Len(t, thread.Participants, 2) // length should be 2, since follow shouldn't update participant list, only user1 and sysadmin are participants
for _, p := range thread.Participants {
require.True(t, p.Id == sysadmin.Id || p.Id == user.Id)
}
oldID := threadMembership.PostId
threadMembership.PostId = "notfound"
_, appErr = th.App.GetThreadForUser(th.Context, threadMembership, false)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
threadMembership.Following = false
threadMembership.PostId = oldID
_, appErr = th.App.GetThreadForUser(th.Context, threadMembership, false)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
}
func TestAutofollowBasedOnRootPost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
channel := th.BasicChannel
user := th.BasicUser
user2 := th.BasicUser2
appErr := th.App.JoinChannel(th.Context, channel, user.Id)
require.Nil(t, appErr)
appErr = th.App.JoinChannel(th.Context, channel, user2.Id)
require.Nil(t, appErr)
p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
m, err := th.App.GetThreadMembershipsForUser(user2.Id, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, m, 0)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
m, err = th.App.GetThreadMembershipsForUser(user2.Id, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, m, 1)
}
func TestViewChannelShouldNotUpdateThreads(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
channel := th.BasicChannel
user := th.BasicUser
user2 := th.BasicUser2
appErr := th.App.JoinChannel(th.Context, channel, user.Id)
require.Nil(t, appErr)
appErr = th.App.JoinChannel(th.Context, channel, user2.Id)
require.Nil(t, appErr)
p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
m, err := th.App.GetThreadMembershipsForUser(user2.Id, th.BasicTeam.Id)
require.NoError(t, err)
_, appErr = th.App.ViewChannel(th.Context, &model.ChannelView{
ChannelId: channel.Id,
PrevChannelId: "",
}, user2.Id, "", true)
require.Nil(t, appErr)
m1, err := th.App.GetThreadMembershipsForUser(user2.Id, th.BasicTeam.Id)
require.NoError(t, err)
require.Equal(t, m[0].LastViewed, m1[0].LastViewed) // opening the channel shouldn't update threads
}
func TestCollapsedThreadFetch(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
user1 := th.BasicUser
user2 := th.BasicUser2
t.Run("should only return root posts, enriched", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
defer func() {
appErr := th.App.DeleteChannel(th.Context, channel, user1.Id)
require.Nil(t, appErr)
}()
postRoot, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "root post",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
RootId: postRoot.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
thread, err := th.App.Srv().Store().Thread().Get(postRoot.Id)
require.NoError(t, err)
require.Len(t, thread.Participants, 1)
_, appErr = th.App.MarkChannelAsUnreadFromPost(th.Context, postRoot.Id, user1.Id, true)
require.Nil(t, appErr)
l, appErr := th.App.GetPostsForChannelAroundLastUnread(th.Context, channel.Id, user1.Id, 10, 10, true, true, false)
require.Nil(t, appErr)
require.Len(t, l.Order, 1)
require.EqualValues(t, 1, l.Posts[postRoot.Id].ReplyCount)
require.EqualValues(t, []string{user1.Id}, []string{l.Posts[postRoot.Id].Participants[0].Id})
require.Empty(t, l.Posts[postRoot.Id].Participants[0].Email)
require.NotZero(t, l.Posts[postRoot.Id].LastReplyAt)
require.True(t, *l.Posts[postRoot.Id].IsFollowing)
// try extended fetch
l, appErr = th.App.GetPostsForChannelAroundLastUnread(th.Context, channel.Id, user1.Id, 10, 10, true, true, true)
require.Nil(t, appErr)
require.Len(t, l.Order, 1)
require.NotEmpty(t, l.Posts[postRoot.Id].Participants[0].Email)
})
t.Run("Should not panic on unexpected db error", func(t *testing.T) {
channel := th.CreateChannel(t, th.BasicTeam)
th.AddUserToChannel(t, user2, channel)
defer func() {
appErr := th.App.DeleteChannel(th.Context, channel, user1.Id)
require.Nil(t, appErr)
}()
postRoot, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "root post",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// we introduce a race to trigger an unexpected error from the db side.
var wg sync.WaitGroup
wg.Go(func() {
err := th.Server.Store().Post().PermanentDeleteByUser(th.Context, user1.Id)
require.NoError(t, err)
})
require.NotPanics(t, func() {
// We're only testing that this doesn't panic, not checking the error
// #nosec G104 - purposely not checking error as we're in a NotPanics block
_, _, _ = th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
RootId: postRoot.Id,
Message: fmt.Sprintf("@%s", user2.Username),
}, channel, model.CreatePostFlags{SetOnline: true})
})
wg.Wait()
})
t.Run("should sanitize participant data", func(t *testing.T) {
id := model.NewId()
user3, appErr := th.App.CreateUser(th.Context, &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
AuthData: new("bobbytables"),
AuthService: "saml",
EmailVerified: true,
})
require.Nil(t, appErr)
defer func() {
appErr = th.App.PermanentDeleteUser(th.Context, user3)
require.Nil(t, appErr)
}()
channel := th.CreateChannel(t, th.BasicTeam)
defer func() {
appErr = th.App.DeleteChannel(th.Context, channel, user1.Id)
require.Nil(t, appErr)
}()
th.LinkUserToTeam(t, user3, th.BasicTeam)
th.AddUserToChannel(t, user3, channel)
postRoot, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: user1.Id,
ChannelId: channel.Id,
Message: "root post",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{
UserId: user3.Id,
ChannelId: channel.Id,
RootId: postRoot.Id,
Message: "reply",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
thread, err := th.App.Srv().Store().Thread().Get(postRoot.Id)
require.NoError(t, err)
require.Len(t, thread.Participants, 1)
// extended fetch posts page
l, appErr := th.App.GetPostsPage(th.Context, model.GetPostsOptions{
UserId: user1.Id,
ChannelId: channel.Id,
PerPage: int(10),
SkipFetchThreads: false,
CollapsedThreads: true,
CollapsedThreadsExtended: true,
})
require.Nil(t, appErr)
require.Len(t, l.Order, 1)
require.NotEmpty(t, l.Posts[postRoot.Id].Participants[0].Email)
require.Empty(t, l.Posts[postRoot.Id].Participants[0].AuthData)
_, appErr = th.App.MarkChannelAsUnreadFromPost(th.Context, postRoot.Id, user1.Id, true)
require.Nil(t, appErr)
// extended fetch posts around
l, appErr = th.App.GetPostsForChannelAroundLastUnread(th.Context, channel.Id, user1.Id, 10, 10, true, true, true)
require.Nil(t, appErr)
require.Len(t, l.Order, 1)
require.NotEmpty(t, l.Posts[postRoot.Id].Participants[0].Email)
require.Empty(t, l.Posts[postRoot.Id].Participants[0].AuthData)
// extended fetch post thread
opts := model.GetPostsOptions{
SkipFetchThreads: false,
CollapsedThreads: true,
CollapsedThreadsExtended: true,
}
l, appErr = th.App.GetPostThread(th.Context, postRoot.Id, opts, user1.Id)
require.Nil(t, appErr)
require.Len(t, l.Order, 2)
require.NotEmpty(t, l.Posts[postRoot.Id].Participants[0].Email)
require.Empty(t, l.Posts[postRoot.Id].Participants[0].AuthData)
})
}
func TestSharedChannelSyncForPostActions(t *testing.T) {
mainHelper.Parallel(t)
t.Run("creating a post in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) {
th := setupSharedChannels(t).InitBasic(t)
sharedChannelService := NewMockSharedChannelService(th.Server.GetSharedChannelSyncService())
th.Server.SetSharedChannelSyncService(sharedChannelService)
testCluster := &testlib.FakeClusterInterface{}
th.Server.Platform().SetCluster(testCluster)
user := th.BasicUser
channel := th.CreateChannel(t, th.BasicTeam, WithShared(true))
_, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user.Id,
ChannelId: channel.Id,
Message: "Hello folks",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err, "Creating a post should not error")
require.Len(t, sharedChannelService.channelNotifications, 1)
assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[0])
})
t.Run("updating a post in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) {
th := setupSharedChannels(t).InitBasic(t)
sharedChannelService := NewMockSharedChannelService(th.Server.GetSharedChannelSyncService())
th.Server.SetSharedChannelSyncService(sharedChannelService)
testCluster := &testlib.FakeClusterInterface{}
th.Server.Platform().SetCluster(testCluster)
user := th.BasicUser
channel := th.CreateChannel(t, th.BasicTeam, WithShared(true))
post, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user.Id,
ChannelId: channel.Id,
Message: "Hello folks",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err, "Creating a post should not error")
_, _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true})
require.Nil(t, err, "Updating a post should not error")
require.Len(t, sharedChannelService.channelNotifications, 2)
assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[0])
assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[1])
})
t.Run("deleting a post in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) {
th := setupSharedChannels(t).InitBasic(t)
sharedChannelService := NewMockSharedChannelService(th.Server.GetSharedChannelSyncService())
th.Server.SetSharedChannelSyncService(sharedChannelService)
testCluster := &testlib.FakeClusterInterface{}
th.Server.Platform().SetCluster(testCluster)
user := th.BasicUser
channel := th.CreateChannel(t, th.BasicTeam, WithShared(true))
post, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: user.Id,
ChannelId: channel.Id,
Message: "Hello folks",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err, "Creating a post should not error")
_, err = th.App.DeletePost(th.Context, post.Id, user.Id)
require.Nil(t, err, "Deleting a post should not error")
// one creation and two deletes
require.Len(t, sharedChannelService.channelNotifications, 3)
assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[0])
assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[1])
assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[2])
})
}
func TestAutofollowOnPostingAfterUnfollow(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
channel := th.BasicChannel
user := th.BasicUser
user2 := th.BasicUser2
appErr := th.App.JoinChannel(th.Context, channel, user.Id)
require.Nil(t, appErr)
appErr = th.App.JoinChannel(th.Context, channel, user2.Id)
require.Nil(t, appErr)
p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user2.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "reply"}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
// unfollow thread
m, err := th.App.Srv().Store().Thread().MaintainMembership(user.Id, p1.Id, store.ThreadMembershipOpts{
Following: false,
UpdateFollowing: true,
})
require.NoError(t, err)
require.False(t, m.Following)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "another reply"}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
// User should be following thread after posting in it, even after previously
// unfollowing it, if ThreadAutoFollow is true
m, appErr = th.App.GetThreadMembershipForUser(user.Id, p1.Id)
require.Nil(t, appErr)
require.True(t, m.Following)
}
func TestGetPostIfAuthorized(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("Private channel", func(t *testing.T) {
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
post, _, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: privateChannel.Id, Message: "Hello"}, privateChannel, model.CreatePostFlags{})
require.Nil(t, err)
require.NotNil(t, post)
session1, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, err)
require.NotNil(t, session1)
session2, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser2.Id, Props: model.StringMap{}})
require.Nil(t, err)
require.NotNil(t, session2)
// User is not authorized to get post
_, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session2, false)
require.NotNil(t, err)
// User is authorized to get post
_, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session1, false)
require.Nil(t, err)
})
t.Run("Public channel", func(t *testing.T) {
publicChannel := th.CreateChannel(t, th.BasicTeam)
post, _, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: publicChannel.Id, Message: "Hello"}, publicChannel, model.CreatePostFlags{})
require.Nil(t, err)
require.NotNil(t, post)
session1, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, err)
require.NotNil(t, session1)
session2, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser2.Id, Props: model.StringMap{}})
require.Nil(t, err)
require.NotNil(t, session2)
// User is authorized to get post
_, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session2, false)
require.Nil(t, err)
// User is authorized to get post
_, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session1, false)
require.Nil(t, err)
th.App.UpdateConfig(func(c *model.Config) {
b := true
c.ComplianceSettings.Enable = &b
})
// User is not authorized to get post
_, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session2, false)
require.NotNil(t, err)
// User is authorized to get post
_, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session1, false)
require.Nil(t, err)
})
}
// MM-68140: thread context for rewrite must not be built from posts in channels the session cannot read.
func TestBuildThreadContextForRewriteRequiresChannelReadAccess(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("direct message between other users", func(t *testing.T) {
secretToken := "MM68140_SECRET_DM_THREAD_" + model.NewId()
dm := th.CreateDmChannel(t, th.BasicUser2)
root, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: dm.Id,
Message: secretToken,
}, dm, model.CreatePostFlags{})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
RootId: root.Id,
UserId: th.BasicUser2.Id,
ChannelId: dm.Id,
Message: "reply only visible to DM participants",
}, dm, model.CreatePostFlags{})
require.Nil(t, err)
attacker := th.CreateUser(t)
session, err := th.App.CreateSession(th.Context, &model.Session{UserId: attacker.Id, Props: model.StringMap{}})
require.Nil(t, err)
ctx := th.Context.WithSession(session)
contextStr, appErr := th.App.buildThreadContextForRewrite(ctx, root.Id)
require.NotNil(t, appErr, "expected permission error when root_id is in a channel the user cannot read, got nil")
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.NotContains(t, contextStr, secretToken)
})
t.Run("private channel the user is not a member of", func(t *testing.T) {
secretToken := "MM68140_SECRET_PRIVATE_THREAD_" + model.NewId()
privateCh := th.CreatePrivateChannel(t, th.BasicTeam)
root, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: privateCh.Id,
Message: secretToken,
}, privateCh, model.CreatePostFlags{})
require.Nil(t, err)
session, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser2.Id, Props: model.StringMap{}})
require.Nil(t, err)
ctx := th.Context.WithSession(session)
contextStr, appErr := th.App.buildThreadContextForRewrite(ctx, root.Id)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.NotContains(t, contextStr, secretToken)
})
}
// MM-68140: additional edge cases for thread context authorization and anchor resolution.
func TestBuildThreadContextForRewriteEdgeCasesMM68140(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("reply post id as root_id resolves thread and includes root message", func(t *testing.T) {
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false)
require.Nil(t, appErr)
rootSecret := "MM68140_ROOT_VIA_REPLY_ANCHOR_" + model.NewId()
root, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: rootSecret,
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
reply, _, err := th.App.CreatePost(th.Context, &model.Post{
RootId: root.Id,
UserId: th.BasicUser2.Id,
ChannelId: th.BasicChannel.Id,
Message: "reply anchor",
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
session, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser2.Id, Props: model.StringMap{}})
require.Nil(t, err)
ctx := th.Context.WithSession(session)
contextStr, appErr := th.App.buildThreadContextForRewrite(ctx, reply.Id)
require.Nil(t, appErr)
assert.Contains(t, contextStr, rootSecret)
assert.Contains(t, contextStr, "reply anchor")
})
t.Run("nonexistent post id returns not found", func(t *testing.T) {
session, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, err)
ctx := th.Context.WithSession(session)
_, appErr := th.App.buildThreadContextForRewrite(ctx, model.NewId())
require.NotNil(t, appErr)
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
})
t.Run("soft-deleted anchor post returns not found", func(t *testing.T) {
root, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "to be deleted",
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
_, err = th.App.DeletePost(th.Context, root.Id, th.BasicUser.Id)
require.Nil(t, err)
session, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, err)
ctx := th.Context.WithSession(session)
_, appErr := th.App.buildThreadContextForRewrite(ctx, root.Id)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
})
t.Run("guest on team cannot use root_id for private channel they are not in", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.GuestAccountsSettings.Enable = true
})
guest := th.CreateGuest(t)
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, guest.Id, "")
require.Nil(t, appErr)
privateCh := th.CreatePrivateChannel(t, th.BasicTeam)
secretToken := "MM68140_GUEST_PRIVATE_" + model.NewId()
root, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: privateCh.Id,
Message: secretToken,
}, privateCh, model.CreatePostFlags{})
require.Nil(t, err)
session, err := th.App.CreateSession(th.Context, &model.Session{UserId: guest.Id, Props: model.StringMap{}})
require.Nil(t, err)
ctx := th.Context.WithSession(session)
contextStr, appErr := th.App.buildThreadContextForRewrite(ctx, root.Id)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.NotContains(t, contextStr, secretToken)
})
t.Run("system admin may read thread context for DM they do not participate in", func(t *testing.T) {
dm := th.CreateDmChannel(t, th.BasicUser2)
secretToken := "MM68140_ADMIN_DM_THREAD_" + model.NewId()
root, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: dm.Id,
Message: secretToken,
}, dm, model.CreatePostFlags{})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
RootId: root.Id,
UserId: th.BasicUser2.Id,
ChannelId: dm.Id,
Message: "dm reply",
}, dm, model.CreatePostFlags{})
require.Nil(t, err)
session, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.SystemAdminUser.Id, Props: model.StringMap{}})
require.Nil(t, err)
ctx := th.Context.WithSession(session)
contextStr, appErr := th.App.buildThreadContextForRewrite(ctx, root.Id)
require.Nil(t, appErr)
assert.Contains(t, contextStr, secretToken)
})
t.Run("member can build context after channel is archived", func(t *testing.T) {
ch := th.CreateChannel(t, th.BasicTeam)
root, _, err := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: ch.Id,
Message: "MM68140_ARCHIVED_ROOT",
}, ch, model.CreatePostFlags{})
require.Nil(t, err)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
RootId: root.Id,
UserId: th.BasicUser.Id,
ChannelId: ch.Id,
Message: "reply in archived",
}, ch, model.CreatePostFlags{})
require.Nil(t, err)
appErr := th.App.DeleteChannel(th.Context, ch, th.SystemAdminUser.Id)
require.Nil(t, appErr)
session, err := th.App.CreateSession(th.Context, &model.Session{UserId: th.BasicUser.Id, Props: model.StringMap{}})
require.Nil(t, err)
ctx := th.Context.WithSession(session)
contextStr, appErr := th.App.buildThreadContextForRewrite(ctx, root.Id)
require.Nil(t, appErr)
assert.Contains(t, contextStr, "MM68140_ARCHIVED_ROOT")
})
}
func TestShouldNotRefollowOnOthersReply(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ThreadAutoFollow = true
*cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn
})
channel := th.BasicChannel
user := th.BasicUser
user2 := th.BasicUser2
appErr := th.App.JoinChannel(th.Context, channel, user.Id)
require.Nil(t, appErr)
appErr = th.App.JoinChannel(th.Context, channel, user2.Id)
require.Nil(t, appErr)
p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user2.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
// User2 unfollows thread
m, err := th.App.Srv().Store().Thread().MaintainMembership(user2.Id, p1.Id, store.ThreadMembershipOpts{
Following: false,
UpdateFollowing: true,
})
require.NoError(t, err)
require.False(t, m.Following)
// user posts in the thread
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "another reply"}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
// User2 should still not be following the thread because they manually
// unfollowed the thread
m, appErr = th.App.GetThreadMembershipForUser(user2.Id, p1.Id)
require.Nil(t, appErr)
require.False(t, m.Following)
// user posts in the thread mentioning user2
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "reply with mention @" + user2.Username}, channel, model.CreatePostFlags{})
require.Nil(t, appErr)
// User2 should now be following the thread because they were explicitly mentioned
m, appErr = th.App.GetThreadMembershipForUser(user2.Id, p1.Id)
require.Nil(t, appErr)
require.True(t, m.Following)
}
func TestGetLastAccessiblePostTime(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
// Setup store mocks needed for GetServerLimits
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
mockStore.On("User").Return(&mockUserStore)
// Test with no license - should return 0
r, err := th.App.GetLastAccessiblePostTime()
assert.Nil(t, err)
assert.Equal(t, int64(0), r)
// Test with Entry license but no limits configured - should return 0
entryLicenseNoLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicenseNoLimits.Limits = nil // No limits configured
th.App.Srv().SetLicense(entryLicenseNoLimits)
r, err = th.App.GetLastAccessiblePostTime()
assert.Nil(t, err)
assert.Equal(t, int64(0), r, "Entry license with no limits should return 0")
// Test with Entry license with zero post history limit - should return 0
entryLicenseZeroLimit := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicenseZeroLimit.Limits = &model.LicenseLimits{PostHistory: 0} // Zero limit
th.App.Srv().SetLicense(entryLicenseZeroLimit)
r, err = th.App.GetLastAccessiblePostTime()
assert.Nil(t, err)
assert.Equal(t, int64(0), r, "Entry license with zero post history limit should return 0")
// Test with Entry license that has post history limits
entryLicenseWithLimits := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicenseWithLimits.Limits = &model.LicenseLimits{PostHistory: 1000} // Actual limit
th.App.Srv().SetLicense(entryLicenseWithLimits)
// Test case 1: No system value found (ErrNotFound) - should return 0
mockSystemStore := storemocks.SystemStore{}
mockStore.On("System").Return(&mockSystemStore)
mockSystemStore.On("GetByName", mock.Anything).Return(nil, store.NewErrNotFound("", ""))
r, err = th.App.GetLastAccessiblePostTime()
assert.Nil(t, err)
assert.Equal(t, int64(0), r)
// Test case 2: Database error - should return error
mockSystemStore = storemocks.SystemStore{}
mockStore.On("System").Return(&mockSystemStore)
mockSystemStore.On("GetByName", mock.Anything).Return(nil, errors.New("database error"))
_, err = th.App.GetLastAccessiblePostTime()
assert.NotNil(t, err)
// Test case 3: Valid system value found - should return parsed timestamp
mockSystemStore = storemocks.SystemStore{}
mockStore.On("System").Return(&mockSystemStore)
mockSystemStore.On("GetByName", mock.Anything).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "1234567890"}, nil)
r, err = th.App.GetLastAccessiblePostTime()
assert.Nil(t, err)
assert.Equal(t, int64(1234567890), r)
}
func TestGetEditHistoryForPost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "new message",
UserId: th.BasicUser.Id,
}
rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// update the post message
patch := &model.PostPatch{
Message: new("new message edited"),
}
_, _, err1 := th.App.PatchPost(th.Context, rpost.Id, patch, nil)
require.Nil(t, err1)
// update the post message again
patch = &model.PostPatch{
Message: new("new message edited again"),
}
_, _, err2 := th.App.PatchPost(th.Context, rpost.Id, patch, nil)
require.Nil(t, err2)
t.Run("should return the edit history", func(t *testing.T) {
edits, err := th.App.GetEditHistoryForPost(post.Id)
require.Nil(t, err)
require.Len(t, edits, 2)
require.Equal(t, "new message edited", edits[0].Message)
require.Equal(t, "new message", edits[1].Message)
})
t.Run("should return an error if the post is not found", func(t *testing.T) {
edits, err := th.App.GetEditHistoryForPost("invalid-post-id")
require.NotNil(t, err)
require.Empty(t, edits)
})
t.Run("edit history should contain file metadata", func(t *testing.T) {
fileBytes := []byte("file contents")
fileInfo, err := th.App.UploadFile(th.Context, fileBytes, th.BasicChannel.Id, "file.txt")
require.Nil(t, err)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "new message",
UserId: th.BasicUser.Id,
FileIds: model.StringArray{fileInfo.Id},
}
_, _, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
patch := &model.PostPatch{
Message: new("new message edited"),
}
_, _, appErr := th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
patch = &model.PostPatch{
Message: new("new message edited 2"),
}
_, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
patch = &model.PostPatch{
Message: new("new message edited 3"),
}
_, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
edits, err := th.App.GetEditHistoryForPost(post.Id)
require.Nil(t, err)
require.Len(t, edits, 3)
for _, edit := range edits {
require.Len(t, edit.FileIds, 1)
require.Equal(t, fileInfo.Id, edit.FileIds[0])
require.Len(t, edit.Metadata.Files, 1)
require.Equal(t, fileInfo.Id, edit.Metadata.Files[0].Id)
}
})
t.Run("edit history should contain file metadata even if the file info is deleted", func(t *testing.T) {
fileBytes := []byte("file contents")
fileInfo, appErr := th.App.UploadFile(th.Context, fileBytes, th.BasicChannel.Id, "file.txt")
require.Nil(t, appErr)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "new message",
UserId: th.BasicUser.Id,
FileIds: model.StringArray{fileInfo.Id},
}
_, _, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
patch := &model.PostPatch{
Message: new("new message edited"),
}
_, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
patch = &model.PostPatch{
Message: new("new message edited 2"),
}
_, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
patch = &model.PostPatch{
Message: new("new message edited 3"),
}
_, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
// now delete the file info, and it should still be include in edit history metadata
_, err := th.App.Srv().Store().FileInfo().DeleteForPost(th.Context, post.Id)
require.NoError(t, err)
edits, appErr := th.App.GetEditHistoryForPost(post.Id)
require.Nil(t, appErr)
require.Len(t, edits, 3)
for _, edit := range edits {
require.Len(t, edit.FileIds, 1)
require.Equal(t, fileInfo.Id, edit.FileIds[0])
require.Len(t, edit.Metadata.Files, 1)
require.Equal(t, fileInfo.Id, edit.Metadata.Files[0].Id)
require.Greater(t, edit.Metadata.Files[0].DeleteAt, int64(0))
}
})
}
func TestCopyWranglerPostlist(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Create a post with a file attachment
fileBytes := []byte("file contents")
fileInfo, err := th.App.UploadFile(th.Context, fileBytes, th.BasicChannel.Id, "file.txt")
require.Nil(t, err)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test message",
UserId: th.BasicUser.Id,
FileIds: []string{fileInfo.Id},
}
rootPost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
// Add a reaction to the post
reaction := &model.Reaction{
UserId: th.BasicUser.Id,
PostId: rootPost.Id,
EmojiName: "smile",
}
_, err = th.App.SaveReactionForPost(th.Context, reaction)
require.Nil(t, err)
// Copy the post to a new channel
targetChannel := &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "test-channel",
Type: model.ChannelTypeOpen,
}
targetChannel, err = th.App.CreateChannel(th.Context, targetChannel, false)
require.Nil(t, err)
wpl := &model.WranglerPostList{
Posts: []*model.Post{rootPost},
FileAttachmentCount: 1,
}
newRootPost, _, err := th.App.CopyWranglerPostlist(th.Context, wpl, targetChannel)
require.Nil(t, err)
// Check that the new post has the same message and file attachment
require.Equal(t, rootPost.Message, newRootPost.Message)
require.Len(t, newRootPost.FileIds, 1)
// Check that the new post has the same reaction
reactions, err := th.App.GetReactionsForPost(newRootPost.Id)
require.Nil(t, err)
require.Len(t, reactions, 1)
require.Equal(t, reaction.EmojiName, reactions[0].EmojiName)
}
func TestValidateMoveOrCopy(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable = new(true)
cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable = new(true)
cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable = new(true)
cfg.WranglerSettings.MoveThreadToAnotherTeamEnable = new(true)
cfg.WranglerSettings.MoveThreadMaxCount = new(int64(100))
})
t.Run("empty post list", func(t *testing.T) {
err := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{}, th.BasicChannel, th.BasicChannel, th.BasicUser)
require.Error(t, err)
require.Equal(t, "The wrangler post list contains no posts", err.Error())
})
t.Run("moving from private channel with MoveThreadFromPrivateChannelEnable disabled", func(t *testing.T) {
privateChannel := &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "private-channel",
Type: model.ChannelTypePrivate,
}
privateChannel, err := th.App.CreateChannel(th.Context, privateChannel, false)
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable = new(false)
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: privateChannel.Id}}}, privateChannel, th.BasicChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "Wrangler is currently configured to not allow moving posts from private channels", e.Error())
})
t.Run("moving from direct channel with MoveThreadFromDirectMessageChannelEnable disabled", func(t *testing.T) {
directChannel, err := th.App.createDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
require.Nil(t, err)
require.NotNil(t, directChannel)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable = new(false)
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: directChannel.Id}}}, directChannel, th.BasicChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "Wrangler is currently configured to not allow moving posts from direct message channels", e.Error())
})
t.Run("moving from group channel with MoveThreadFromGroupMessageChannelEnable disabled", func(t *testing.T) {
groupChannel := &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "group-channel",
Type: model.ChannelTypeGroup,
}
groupChannel, err := th.App.CreateChannel(th.Context, groupChannel, false)
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable = new(false)
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: groupChannel.Id}}}, groupChannel, th.BasicChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "Wrangler is currently configured to not allow moving posts from group message channels", e.Error())
})
t.Run("moving to different team with MoveThreadToAnotherTeamEnable disabled", func(t *testing.T) {
team := &model.Team{
Name: "testteam",
DisplayName: "testteam",
Type: model.TeamOpen,
}
targetTeam, err := th.App.CreateTeam(th.Context, team)
require.Nil(t, err)
require.NotNil(t, targetTeam)
targetChannel := &model.Channel{
TeamId: targetTeam.Id,
Name: "test-channel",
Type: model.ChannelTypeOpen,
}
targetChannel, err = th.App.CreateChannel(th.Context, targetChannel, false)
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadToAnotherTeamEnable = new(false)
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, targetChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "Wrangler is currently configured to not allow moving messages to different teams", e.Error())
})
t.Run("moving to channel user is not a member of", func(t *testing.T) {
targetChannel := &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "test-channel",
Type: model.ChannelTypePrivate,
}
targetChannel, err := th.App.CreateChannel(th.Context, targetChannel, false)
require.Nil(t, err)
err = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, th.SystemAdminUser.Id, th.BasicChannel)
require.Nil(t, err)
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, targetChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, fmt.Sprintf("channel with ID %s doesn't exist or you are not a member", targetChannel.Id), e.Error())
})
t.Run("moving thread longer than MoveThreadMaxCount", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.WranglerSettings.MoveThreadMaxCount = 1
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}, {ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, th.BasicChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "the thread is 2 posts long, but this command is configured to only move threads of up to 1 posts", e.Error())
})
}
func TestPermanentDeletePost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Enable BurnOnRead feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.BurnOnRead = true })
t.Run("should permanently delete a post and its file attachment", func(t *testing.T) {
// Create a post with a file attachment.
teamID := th.BasicTeam.Id
channelID := th.BasicChannel.Id
userID := th.BasicUser.Id
filename := "test"
data := []byte("abcd")
info1, err := th.App.DoUploadFile(th.Context, time.Date(2007, 2, 4, 1, 2, 3, 4, time.Local), teamID, channelID, userID, filename, data, true)
assert.Nil(t, err)
post := &model.Post{
Message: "asd",
ChannelId: channelID,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: userID,
CreateAt: 0,
FileIds: []string{info1.Id},
}
post, _, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
assert.Nil(t, err)
// Delete the post.
err = th.App.PermanentDeletePost(th.Context, post.Id, userID)
assert.Nil(t, err)
// Wait for the cleanup routine to finish.
time.Sleep(time.Millisecond * 100)
// Check that the post can no longer be reached.
_, err = th.App.GetSinglePost(th.Context, post.Id, true)
assert.NotNil(t, err)
// Check that the file can no longer be reached.
_, err = th.App.GetFileInfo(th.Context, info1.Id)
assert.NotNil(t, err)
})
t.Run("should permanently delete a post that is soft deleted", func(t *testing.T) {
// Create a post with a file attachment.
teamID := th.BasicTeam.Id
channelID := th.BasicChannel.Id
userID := th.BasicUser.Id
filename := "test"
data := []byte("abcd")
info1, appErr := th.App.DoUploadFile(th.Context, time.Date(2007, 2, 4, 1, 2, 3, 4, time.Local), teamID, channelID, userID, filename, data, true)
require.Nil(t, appErr)
post := &model.Post{
Message: "asd",
ChannelId: channelID,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: userID,
CreateAt: 0,
FileIds: []string{info1.Id},
}
post, _, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
assert.Nil(t, appErr)
infos, err := th.App.Srv().Store().FileInfo().GetForPost(post.Id, true, true, false)
require.NoError(t, err)
assert.Len(t, infos, 1)
// Soft delete the post.
_, appErr = th.App.DeletePost(th.Context, post.Id, userID)
assert.Nil(t, appErr)
// Wait for the cleanup routine to finish.
time.Sleep(time.Millisecond * 100)
// Delete the post.
appErr = th.App.PermanentDeletePost(th.Context, post.Id, userID)
assert.Nil(t, appErr)
// Check that the post can no longer be reached.
_, appErr = th.App.GetSinglePost(th.Context, post.Id, true)
assert.NotNil(t, appErr)
infos, err = th.App.Srv().Store().FileInfo().GetForPost(post.Id, true, true, false)
require.NoError(t, err)
assert.Len(t, infos, 0)
})
t.Run("should permanently delete a burn-on-read post and its file attachments", func(t *testing.T) {
// Enable feature with license
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 with a file attachment
teamID := th.BasicTeam.Id
channelID := th.BasicChannel.Id
userID := th.BasicUser.Id
filename := "burn_on_read_file"
data := []byte("burn on read file content")
info1, err := th.App.DoUploadFile(th.Context, time.Date(2007, 2, 4, 1, 2, 3, 4, time.Local), teamID, channelID, userID, filename, data, true)
require.Nil(t, err)
post := &model.Post{
Message: "burn on read message with file",
ChannelId: channelID,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: userID,
CreateAt: 0,
FileIds: []string{info1.Id},
Type: model.PostTypeBurnOnRead,
}
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
post, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
require.Equal(t, model.PostTypeBurnOnRead, post.Type)
// Verify that the post has empty message and file IDs (stored in TemporaryPosts)
assert.Empty(t, post.Message)
assert.Empty(t, post.FileIds)
// Verify that TemporaryPost exists with original content
tmpPost, tmpErr := th.App.Srv().Store().TemporaryPost().Get(th.Context, post.Id, true)
require.NoError(t, tmpErr)
require.NotNil(t, tmpPost)
assert.Equal(t, "burn on read message with file", tmpPost.Message)
assert.Equal(t, model.StringArray{info1.Id}, tmpPost.FileIDs)
// Verify file info exists before deletion
_, err = th.App.GetFileInfo(th.Context, info1.Id)
require.Nil(t, err)
// Permanently delete the post
appErr = th.App.PermanentDeletePost(th.Context, post.Id, userID)
require.Nil(t, appErr)
// Check that the post can no longer be reached
_, err = th.App.GetSinglePost(th.Context, post.Id, true)
assert.NotNil(t, err)
// Check that the file also deleted
_, err = th.App.GetFileInfo(th.Context, info1.Id)
assert.NotNil(t, err)
// Verify TemporaryPost is also deleted
_, tmpErr = th.App.Srv().Store().TemporaryPost().Get(th.Context, post.Id, true)
assert.Error(t, tmpErr)
assert.True(t, store.IsErrNotFound(tmpErr))
})
t.Run("should delete persistent notification for root post", func(t *testing.T) {
th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional))
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostPriority = true
*cfg.ServiceSettings.AllowPersistentNotifications = true
})
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false)
require.Nil(t, appErr)
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "urgent " + "@" + th.BasicUser2.Username,
Metadata: &model.PostMetadata{
Priority: &model.PostPriority{
Priority: model.NewPointer(model.PostPriorityUrgent),
PersistentNotifications: new(true),
},
},
}
post, _, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.Empty(t, post.RootId, "test post must be a root post")
// Verify persistent notification exists
pn, err := th.App.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
require.NoError(t, err)
require.NotNil(t, pn)
// Permanently delete the post
appErr = th.App.PermanentDeletePost(th.Context, post.Id, th.BasicUser.Id)
require.Nil(t, appErr)
// Verify persistent notification was deleted
_, err = th.App.Srv().Store().PostPersistentNotification().GetSingle(post.Id)
var nfErr *store.ErrNotFound
require.Error(t, err)
require.ErrorAs(t, err, &nfErr)
})
t.Run("should send unrevealed post in websocket broadcast", func(t *testing.T) {
// Enable feature with license
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
//teamID := th.BasicTeam.Id
channelID := th.BasicChannel.Id
userID := th.BasicUser.Id
wsMessages, closeWS := connectFakeWebSocket(t, th, userID, "", []model.WebsocketEventType{model.WebsocketEventPostDeleted})
defer closeWS()
post := &model.Post{
Message: "burn on read message with file",
ChannelId: channelID,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: userID,
CreateAt: 0,
Type: model.PostTypeBurnOnRead,
}
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
post, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
require.Equal(t, model.PostTypeBurnOnRead, post.Type)
appErr = th.App.PermanentDeletePost(th.Context, post.Id, userID)
require.Nil(t, appErr)
var received *model.WebSocketEvent
select {
case received = <-wsMessages:
// the post sent in websocket payload shouldn't contain message or file IDs
data := received.GetData()
postJSON, ok := data["post"].(string)
require.True(t, ok)
var receivedPost model.Post
err := json.Unmarshal([]byte(postJSON), &receivedPost)
require.NoError(t, err)
require.Equal(t, post.Id, receivedPost.Id)
require.Equal(t, "", receivedPost.Message)
require.Equal(t, 0, len(receivedPost.FileIds))
case <-time.After(10 * time.Second):
require.Fail(t, "Did not receive websocket message in time")
}
})
}
func TestCleanUpAfterPostDeletion(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
appErr := th.App.JoinChannel(th.Context, th.BasicChannel, th.BasicUser.Id)
require.Nil(t, appErr)
appErr = th.App.JoinChannel(th.Context, th.BasicChannel, th.BasicUser2.Id)
require.Nil(t, appErr)
waitForPostDeleted := func(t *testing.T, messages chan *model.WebSocketEvent) *model.WebSocketEvent {
t.Helper()
select {
case received := <-messages:
return received
case <-time.After(10 * time.Second):
require.Fail(t, "Did not receive websocket message in time")
return nil
}
}
t.Run("post_deleted broadcast to channel members strips action integrations", func(t *testing.T) {
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "interactive message",
Props: model.StringInterface{
model.PostPropsAttachments: []*model.MessageAttachment{
{
Text: "hello",
Actions: []*model.PostAction{
{
Type: model.PostActionTypeButton,
Name: "action",
Integration: &model.PostActionIntegration{
URL: "http://localhost:8065/secret-endpoint",
Context: map[string]any{"secret_marker": "s3cr3t"},
},
},
},
},
},
},
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
memberMessages, closeMemberWS := connectFakeWebSocket(t, th, th.BasicUser2.Id, "", []model.WebsocketEventType{model.WebsocketEventPostDeleted})
defer closeMemberWS()
_, appErr = th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id)
require.Nil(t, appErr)
memberEvent := waitForPostDeleted(t, memberMessages)
memberPostJSON, ok := memberEvent.GetData()["post"].(string)
require.True(t, ok)
assert.NotContains(t, memberPostJSON, "secret-endpoint")
assert.NotContains(t, memberPostJSON, "secret_marker")
assert.Nil(t, memberEvent.GetData()["delete_by"])
var memberPost model.Post
require.NoError(t, json.Unmarshal([]byte(memberPostJSON), &memberPost))
require.Equal(t, post.Id, memberPost.Id)
memberAttachments := memberPost.Attachments()
require.Len(t, memberAttachments, 1)
require.Len(t, memberAttachments[0].Actions, 1)
assert.Equal(t, "action", memberAttachments[0].Actions[0].Name, "non-secret attachment data must be preserved")
assert.Nil(t, memberAttachments[0].Actions[0].Integration)
})
t.Run("post_deleted broadcast to channel members strips mm_blocks_actions secrets", func(t *testing.T) {
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "blocks message",
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
post.AddProp(model.PostPropsMmBlocksActions, map[string]any{
"mm_blocks_act": map[string]any{
"type": model.MmBlocksActionTypeExternal,
"url": "http://localhost:8065/secret-endpoint",
"context": map[string]any{"secret_marker": "s3cr3t"},
},
})
memberMessages, closeMemberWS := connectFakeWebSocket(t, th, th.BasicUser2.Id, "", []model.WebsocketEventType{model.WebsocketEventPostDeleted})
defer closeMemberWS()
appErr = th.App.CleanUpAfterPostDeletion(th.Context, post, th.BasicUser.Id)
require.Nil(t, appErr)
memberEvent := waitForPostDeleted(t, memberMessages)
memberPostJSON, ok := memberEvent.GetData()["post"].(string)
require.True(t, ok)
assert.NotContains(t, memberPostJSON, "secret-endpoint")
assert.NotContains(t, memberPostJSON, "secret_marker")
var memberPost model.Post
require.NoError(t, json.Unmarshal([]byte(memberPostJSON), &memberPost))
assert.Equal(t, post.Id, memberPost.Id)
assert.Nil(t, memberPost.GetProp(model.PostPropsMmBlocksActions))
})
}
func TestSendTestMessage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("Should create the post with the correct prop", func(t *testing.T) {
post, result := th.App.SendTestMessage(th.Context, th.BasicUser.Id)
assert.Nil(t, result)
assert.NotEmpty(t, post.GetProp(model.PostPropsForceNotification))
})
}
func TestPopulateEditHistoryFileMetadata(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("should populate file metadata for all posts", func(t *testing.T) {
fileInfo1, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
})
require.NoError(t, err)
fileInfo2, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
})
require.NoError(t, err)
post1 := th.CreatePost(t, th.BasicChannel, func(post *model.Post) {
post.FileIds = model.StringArray{fileInfo1.Id}
})
post2 := th.CreatePost(t, th.BasicChannel, func(post *model.Post) {
post.FileIds = model.StringArray{fileInfo2.Id}
})
appErr := th.App.populateEditHistoryFileMetadata([]*model.Post{post1, post2})
require.Nil(t, appErr)
require.Len(t, post1.Metadata.Files, 1)
require.Equal(t, fileInfo1.Id, post1.Metadata.Files[0].Id)
require.Len(t, post2.Metadata.Files, 1)
require.Equal(t, fileInfo2.Id, post2.Metadata.Files[0].Id)
})
t.Run("should populate file metadata even for deleted posts", func(t *testing.T) {
fileInfo1, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
})
require.NoError(t, err)
fileInfo2, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
})
require.NoError(t, err)
post1 := th.CreatePost(t, th.BasicChannel, func(post *model.Post) {
post.FileIds = model.StringArray{fileInfo1.Id}
})
post2 := th.CreatePost(t, th.BasicChannel, func(post *model.Post) {
post.FileIds = model.StringArray{fileInfo2.Id}
})
_, appErr := th.App.DeletePost(th.Context, post1.Id, th.BasicUser.Id)
require.Nil(t, appErr)
_, appErr = th.App.DeletePost(th.Context, post2.Id, th.BasicUser.Id)
require.Nil(t, appErr)
appErr = th.App.populateEditHistoryFileMetadata([]*model.Post{post1, post2})
require.Nil(t, appErr)
require.Len(t, post1.Metadata.Files, 1)
require.Equal(t, fileInfo1.Id, post1.Metadata.Files[0].Id)
require.Len(t, post2.Metadata.Files, 1)
require.Equal(t, fileInfo2.Id, post2.Metadata.Files[0].Id)
})
t.Run("should populate file metadata even for deleted fileInfos", func(t *testing.T) {
fileInfo1, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
})
require.NoError(t, err)
fileInfo2, err := th.App.Srv().Store().FileInfo().Save(th.Context,
&model.FileInfo{
CreatorId: th.BasicUser.Id,
Path: "path.txt",
})
require.NoError(t, err)
post1 := th.CreatePost(t, th.BasicChannel, func(post *model.Post) {
post.FileIds = model.StringArray{fileInfo1.Id}
})
post2 := th.CreatePost(t, th.BasicChannel, func(post *model.Post) {
post.FileIds = model.StringArray{fileInfo2.Id}
})
_, err = th.App.Srv().Store().FileInfo().DeleteForPost(th.Context, post1.Id)
require.NoError(t, err)
_, err = th.App.Srv().Store().FileInfo().DeleteForPost(th.Context, post2.Id)
require.NoError(t, err)
appErr := th.App.populateEditHistoryFileMetadata([]*model.Post{post1, post2})
require.Nil(t, appErr)
require.Len(t, post1.Metadata.Files, 1)
require.Equal(t, fileInfo1.Id, post1.Metadata.Files[0].Id)
require.Greater(t, post1.Metadata.Files[0].DeleteAt, int64(0))
require.Len(t, post2.Metadata.Files, 1)
require.Equal(t, fileInfo2.Id, post2.Metadata.Files[0].Id)
require.Greater(t, post2.Metadata.Files[0].DeleteAt, int64(0))
})
}
func TestFilterPostsByChannelPermissions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.GuestAccountsSettings.Enable = true
})
guestUser := th.CreateGuest(t)
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, guestUser.Id, "")
require.Nil(t, appErr)
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
_, appErr = th.App.AddUserToChannel(th.Context, guestUser, privateChannel, false)
require.Nil(t, appErr)
_, appErr = th.App.AddUserToChannel(th.Context, guestUser, th.BasicChannel, false)
require.Nil(t, appErr)
post1 := th.CreatePost(t, th.BasicChannel)
post2 := th.CreatePost(t, privateChannel)
post3 := th.CreatePost(t, th.BasicChannel)
t.Run("should filter posts when user has read_channel_content permission", func(t *testing.T) {
postList := model.NewPostList()
postList.Posts[post1.Id] = post1
postList.Posts[post2.Id] = post2
postList.Posts[post3.Id] = post3
postList.Order = []string{post1.Id, post2.Id, post3.Id}
allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id)
require.Nil(t, appErr)
require.Len(t, postList.Posts, 3)
require.Len(t, postList.Order, 3)
require.True(t, allPostHaveMembership)
})
t.Run("should filter posts when guest has read_channel_content permission", func(t *testing.T) {
postList := model.NewPostList()
postList.Posts[post1.Id] = post1
postList.Posts[post2.Id] = post2
postList.Posts[post3.Id] = post3
postList.Order = []string{post1.Id, post2.Id, post3.Id}
allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, guestUser.Id)
require.Nil(t, appErr)
require.Len(t, postList.Posts, 3)
require.Len(t, postList.Order, 3)
require.True(t, allPostHaveMembership)
})
t.Run("should filter posts when guest does not have read_channel_content permission", func(t *testing.T) {
channelGuestRole, appErr := th.App.GetRoleByName(th.Context, model.ChannelGuestRoleId)
require.Nil(t, appErr)
originalPermissions := make([]string, len(channelGuestRole.Permissions))
copy(originalPermissions, channelGuestRole.Permissions)
newPermissions := []string{}
for _, perm := range channelGuestRole.Permissions {
if perm != model.PermissionReadChannelContent.Id && perm != model.PermissionReadChannel.Id {
newPermissions = append(newPermissions, perm)
}
}
_, appErr = th.App.PatchRole(channelGuestRole, &model.RolePatch{
Permissions: &newPermissions,
})
require.Nil(t, appErr)
defer func() {
_, err := th.App.PatchRole(channelGuestRole, &model.RolePatch{
Permissions: &originalPermissions,
})
require.Nil(t, err)
}()
postList := model.NewPostList()
postList.Posts[post1.Id] = post1
postList.Posts[post2.Id] = post2
postList.Posts[post3.Id] = post3
postList.Order = []string{post1.Id, post2.Id, post3.Id}
allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, guestUser.Id)
require.Nil(t, appErr)
require.Len(t, postList.Posts, 0)
require.Len(t, postList.Order, 0)
require.True(t, allPostHaveMembership)
})
t.Run("should handle empty post list", func(t *testing.T) {
postList := model.NewPostList()
allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id)
require.Nil(t, appErr)
require.Len(t, postList.Posts, 0)
require.Len(t, postList.Order, 0)
require.True(t, allPostHaveMembership)
})
t.Run("should handle nil post list", func(t *testing.T) {
_, appErr := th.App.FilterPostsByChannelPermissions(th.Context, nil, th.BasicUser.Id)
require.Nil(t, appErr)
})
t.Run("should handle posts with empty channel IDs", func(t *testing.T) {
postList := model.NewPostList()
postWithoutChannel := &model.Post{
Id: model.NewId(),
ChannelId: "",
Message: "test",
}
postList.Posts[postWithoutChannel.Id] = postWithoutChannel
postList.Order = []string{postWithoutChannel.Id}
allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id)
require.Nil(t, appErr)
require.Len(t, postList.Posts, 0)
require.Len(t, postList.Order, 0)
require.True(t, allPostHaveMembership)
})
t.Run("should handle posts from non-existent channels", func(t *testing.T) {
postList := model.NewPostList()
postWithInvalidChannel := &model.Post{
Id: model.NewId(),
ChannelId: model.NewId(),
Message: "test",
}
postList.Posts[postWithInvalidChannel.Id] = postWithInvalidChannel
postList.Order = []string{postWithInvalidChannel.Id}
allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id)
require.Nil(t, appErr)
require.Len(t, postList.Posts, 0)
require.Len(t, postList.Order, 0)
require.True(t, allPostHaveMembership)
})
}
func TestRevealPost(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable BurnOnRead feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.BurnOnRead = true })
// Helper to create a burn-on-read post
createBurnOnReadPost := func() *model.Post {
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "burn on read message",
Type: model.PostTypeBurnOnRead,
}
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.NotNil(t, createdPost)
return createdPost
}
// Helper to create a regular post
createRegularPost := func() *model.Post {
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "regular message",
}
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.NotNil(t, createdPost)
return createdPost
}
// Create a second user for testing
user2 := th.CreateUser(t)
th.LinkUserToTeam(t, user2, th.BasicTeam)
th.AddUserToChannel(t, user2, th.BasicChannel)
t.Run("post type non burn on read", func(t *testing.T) {
regularPost := createRegularPost()
revealedPost, appErr := th.App.RevealPost(th.Context, regularPost, user2.Id, "")
require.NotNil(t, appErr)
require.Nil(t, revealedPost)
require.Equal(t, "app.reveal_post.not_burn_on_read.app_error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("post doesn't have required prop", func(t *testing.T) {
enableBoRFeature(th)
// Create a burn-on-read post without expire_at prop
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "burn on read message",
Type: model.PostTypeBurnOnRead,
}
// First save the post normally (which will add expire_at automatically)
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
// Now manually remove the expire_at prop to test missing prop scenario
createdPost.SetProps(make(model.StringInterface))
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
require.NotNil(t, appErr)
require.Nil(t, revealedPost)
require.Equal(t, "app.reveal_post.missing_expire_at.app_error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("post with invalid expire_at prop type", func(t *testing.T) {
enableBoRFeature(th)
post := createBurnOnReadPost()
// Manually set invalid expire_at type
post.SetProps(make(model.StringInterface))
post.AddProp(model.PostPropsExpireAt, "invalid_string")
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
require.NotNil(t, appErr)
require.Nil(t, revealedPost)
require.Equal(t, "app.reveal_post.missing_expire_at.app_error", appErr.Id)
})
t.Run("post with zero expire_at", func(t *testing.T) {
enableBoRFeature(th)
post := createBurnOnReadPost()
// Manually set zero expire_at
post.SetProps(make(model.StringInterface))
post.AddProp(model.PostPropsExpireAt, float64(0))
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
require.NotNil(t, appErr)
require.Nil(t, revealedPost)
require.Equal(t, "app.reveal_post.missing_expire_at.app_error", appErr.Id)
})
t.Run("read receipt does not exist", func(t *testing.T) {
enableBoRFeature(th)
post := createBurnOnReadPost()
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
require.Nil(t, appErr)
require.NotNil(t, revealedPost)
require.Equal(t, "burn on read message", revealedPost.Message)
require.NotNil(t, revealedPost.Metadata)
require.NotZero(t, revealedPost.Metadata.ExpireAt)
// Verify read receipt was created
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, post.Id, user2.Id)
require.NoError(t, err)
require.NotNil(t, receipt)
require.Equal(t, post.Id, receipt.PostID)
require.Equal(t, user2.Id, receipt.UserID)
require.Equal(t, revealedPost.Metadata.ExpireAt, receipt.ExpireAt)
})
t.Run("read receipt exists and not expired", func(t *testing.T) {
enableBoRFeature(th)
post := createBurnOnReadPost()
// First reveal to create receipt
revealedPost1, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
require.Nil(t, appErr)
require.NotNil(t, revealedPost1)
require.NotZero(t, revealedPost1.Metadata.ExpireAt)
// Reveal again - should succeed and return the same post
revealedPost2, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
require.Nil(t, appErr)
require.NotNil(t, revealedPost2)
require.Equal(t, "burn on read message", revealedPost2.Message)
require.NotNil(t, revealedPost2.Metadata)
require.Equal(t, revealedPost1.Metadata.ExpireAt, revealedPost2.Metadata.ExpireAt)
})
t.Run("read receipt exists but expired", func(t *testing.T) {
enableBoRFeature(th)
post := createBurnOnReadPost()
// Create an expired read receipt
expiredReceipt := &model.ReadReceipt{
UserID: user2.Id,
PostID: post.Id,
ExpireAt: model.GetMillis() - 1000, // Expired 1 second ago
}
_, err := th.App.Srv().Store().ReadReceipt().Save(th.Context, expiredReceipt)
require.NoError(t, err)
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
require.NotNil(t, appErr)
require.Nil(t, revealedPost)
require.Equal(t, "app.reveal_post.read_receipt_expired.error", appErr.Id)
require.Equal(t, http.StatusForbidden, appErr.StatusCode)
})
t.Run("post expired", func(t *testing.T) {
post := createBurnOnReadPost()
// Manually set expired expire_at
post.SetProps(make(model.StringInterface))
post.AddProp(model.PostPropsExpireAt, model.GetMillis()-1000)
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
require.NotNil(t, appErr)
require.Nil(t, revealedPost)
require.Equal(t, "app.reveal_post.post_expired.app_error", appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
t.Run("revealed post preserves existing metadata", func(t *testing.T) {
enableBoRFeature(th)
fileBytes := []byte("test")
fileInfo, err := th.App.UploadFile(th.Context, fileBytes, th.BasicChannel.Id, "file.txt")
require.Nil(t, err)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "burn on read message",
Type: model.PostTypeBurnOnRead,
FileIds: model.StringArray{fileInfo.Id},
}
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.NotNil(t, createdPost)
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
require.Nil(t, appErr)
require.NotNil(t, revealedPost)
require.NotNil(t, revealedPost.Metadata)
require.NotZero(t, revealedPost.Metadata.ExpireAt)
require.Len(t, revealedPost.Metadata.Files, 1)
})
t.Run("updateTemporaryPostIfAllRead bypasses cache to get fresh data", func(t *testing.T) {
enableBoRFeature(th)
// Create a third user to have multiple recipients
user3 := th.CreateUser(t)
th.LinkUserToTeam(t, user3, th.BasicTeam)
th.AddUserToChannel(t, user3, th.BasicChannel)
post := createBurnOnReadPost()
// Get initial TemporaryPost ExpireAt (should be max TTL - 24 hours)
tmpPostInitial, err := th.App.Srv().Store().TemporaryPost().Get(th.Context, post.Id, true)
require.NoError(t, err)
initialExpireAt := tmpPostInitial.ExpireAt
// First reveal by user2 creates a read receipt
_, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
require.Nil(t, appErr)
// Get the read receipt's ExpireAt (should be 5 minutes)
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, post.Id, user2.Id)
require.NoError(t, err)
// Verify receipt ExpireAt is less than initial tmpPost ExpireAt (5 minutes vs 24 hours)
require.Less(t, receipt.ExpireAt, initialExpireAt)
// At this point, user3 hasn't revealed yet, so TemporaryPost shouldn't be updated
tmpPostAfterFirstReveal, err := th.App.Srv().Store().TemporaryPost().Get(th.Context, post.Id, false)
require.NoError(t, err)
require.Equal(t, initialExpireAt, tmpPostAfterFirstReveal.ExpireAt)
// Now user3 reveals - this should trigger updateTemporaryPostIfAllRead
_, appErr = th.App.RevealPost(th.Context, post, user3.Id, "")
require.Nil(t, appErr)
// Fetch the TemporaryPost with cache bypass to verify it was updated
tmpPostAfterAllReveal, err := th.App.Srv().Store().TemporaryPost().Get(th.Context, post.Id, false)
require.NoError(t, err)
// Verify the ExpireAt was updated to match the shortest receipt ExpireAt
require.Less(t, tmpPostAfterAllReveal.ExpireAt, initialExpireAt)
// Should be close to the first recipient's ExpireAt (within a few ms due to timing)
require.InDelta(t, receipt.ExpireAt, tmpPostAfterAllReveal.ExpireAt, 10000) // 10 second tolerance
})
}
func TestBurnPost(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable BurnOnRead feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.BurnOnRead = true })
// feature flag, configuration and license is not checked for this feature
// so we set these to enable the feature to create a burn on read post
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.EnableBurnOnRead = new(true)
})
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel) // author of the post
th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel) // recipient of the post
// Helper to create a burn-on-read post
createBurnOnReadPost := func() *model.Post {
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "burn on read message",
Type: model.PostTypeBurnOnRead,
}
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.NotNil(t, createdPost)
return createdPost
}
// Helper to create a regular post
createRegularPost := func() *model.Post {
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "regular message",
}
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.NotNil(t, createdPost)
return createdPost
}
t.Run("burn on read post", func(t *testing.T) {
post := createBurnOnReadPost()
appErr := th.App.BurnPost(th.Context, post, th.BasicUser.Id, "")
require.Nil(t, appErr)
// Verify post is deleted
post, err := th.App.Srv().Store().Post().GetSingle(th.Context, post.Id, false)
require.Error(t, err)
require.Nil(t, post)
require.True(t, store.IsErrNotFound(err))
})
t.Run("regular post", func(t *testing.T) {
post := createRegularPost()
appErr := th.App.BurnPost(th.Context, post, th.BasicUser.Id, "")
require.NotNil(t, appErr)
require.Equal(t, "app.burn_post.not_burn_on_read.app_error", appErr.Id)
})
t.Run("read receipt does not exist", func(t *testing.T) {
post := createBurnOnReadPost()
appErr := th.App.BurnPost(th.Context, post, th.BasicUser2.Id, "")
require.NotNil(t, appErr)
require.Equal(t, "app.burn_post.not_revealed.app_error", appErr.Id)
})
t.Run("read receipt exists but expired", func(t *testing.T) {
post := createBurnOnReadPost()
// Create an expired read receipt
expiredTime := model.GetMillis() - 1000 // Expired 1 second ago
expiredReceipt := &model.ReadReceipt{
UserID: th.BasicUser2.Id,
PostID: post.Id,
ExpireAt: expiredTime,
}
_, err := th.App.Srv().Store().ReadReceipt().Save(th.Context, expiredReceipt)
require.NoError(t, err)
appErr := th.App.BurnPost(th.Context, post, th.BasicUser2.Id, "")
require.Nil(t, appErr) // this is a no-op
// Verify receipt ExpireAt is unchanged (no-op)
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, post.Id, th.BasicUser2.Id)
require.NoError(t, err)
require.Equal(t, expiredTime, receipt.ExpireAt)
})
t.Run("read receipt exists and not expired", func(t *testing.T) {
post := createBurnOnReadPost()
// Create a read receipt that is not expired
notExpiredReceipt := &model.ReadReceipt{
UserID: th.BasicUser2.Id,
PostID: post.Id,
ExpireAt: model.GetMillis() + 10000, // Not expired 10 seconds from now
}
_, err := th.App.Srv().Store().ReadReceipt().Save(th.Context, notExpiredReceipt)
require.NoError(t, err)
appErr := th.App.BurnPost(th.Context, post, th.BasicUser2.Id, "")
require.Nil(t, appErr)
// Verify receipt ExpireAt is updated to current time
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, post.Id, th.BasicUser2.Id)
require.NoError(t, err)
require.LessOrEqual(t, receipt.ExpireAt, model.GetMillis())
})
}
func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable BurnOnRead feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.BurnOnRead = true })
// Create a second user for testing
user2 := th.CreateUser(t)
th.LinkUserToTeam(t, user2, th.BasicTeam)
th.AddUserToChannel(t, user2, th.BasicChannel)
t.Run("expired burn-on-read post should not be returned in flagged posts", func(t *testing.T) {
enableBoRFeature(th)
// Create a burn-on-read post
borPost := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "burn on read message",
Type: model.PostTypeBurnOnRead,
}
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*1000)) // 10 seconds
createdPost, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
require.NotNil(t, createdPost)
// User2 reveals the post
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
require.Nil(t, appErr)
require.NotNil(t, revealedPost)
// User2 saves/flags the post
preference := model.Preference{
UserId: user2.Id,
Category: model.PreferenceCategoryFlaggedPost,
Name: createdPost.Id,
Value: "true",
}
err := th.App.Srv().Store().Preference().Save(model.Preferences{preference})
require.NoError(t, err)
// Verify post appears in flagged posts before expiration
flaggedPosts, appErr := th.App.GetFlaggedPosts(th.Context, user2.Id, 0, 10)
require.Nil(t, appErr)
require.NotNil(t, flaggedPosts)
require.Contains(t, flaggedPosts.Order, createdPost.Id)
require.NotNil(t, flaggedPosts.Posts[createdPost.Id])
// Simulate expiration by updating the receipt's ExpireAt to the past
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, createdPost.Id, user2.Id)
require.NoError(t, err)
require.NotNil(t, receipt)
receipt.ExpireAt = model.GetMillis() - 1000 // 1 second in the past
_, err = th.App.Srv().Store().ReadReceipt().Update(th.Context, receipt)
require.NoError(t, err)
// Get flagged posts again - expired post should be filtered out
flaggedPosts, appErr = th.App.GetFlaggedPosts(th.Context, user2.Id, 0, 10)
require.Nil(t, appErr)
require.NotNil(t, flaggedPosts)
require.NotContains(t, flaggedPosts.Order, createdPost.Id, "Expired burn-on-read post should not be in flagged posts")
require.Nil(t, flaggedPosts.Posts[createdPost.Id], "Expired burn-on-read post should not be in posts map")
})
t.Run("expired burn-on-read post should not be returned in flagged posts for team", func(t *testing.T) {
enableBoRFeature(th)
// Create a burn-on-read post
borPost := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "burn on read team message",
Type: model.PostTypeBurnOnRead,
}
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*1000))
createdPost, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
// User2 reveals and flags the post
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
require.Nil(t, appErr)
require.NotNil(t, revealedPost)
preference := model.Preference{
UserId: user2.Id,
Category: model.PreferenceCategoryFlaggedPost,
Name: createdPost.Id,
Value: "true",
}
err := th.App.Srv().Store().Preference().Save(model.Preferences{preference})
require.NoError(t, err)
// Expire the receipt
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, createdPost.Id, user2.Id)
require.NoError(t, err)
receipt.ExpireAt = model.GetMillis() - 1000
_, err = th.App.Srv().Store().ReadReceipt().Update(th.Context, receipt)
require.NoError(t, err)
// Get flagged posts for team
flaggedPosts, appErr := th.App.GetFlaggedPostsForTeam(th.Context, user2.Id, th.BasicTeam.Id, 0, 10)
require.Nil(t, appErr)
require.NotContains(t, flaggedPosts.Order, createdPost.Id)
})
t.Run("expired burn-on-read post should not be returned in flagged posts for channel", func(t *testing.T) {
enableBoRFeature(th)
// Create a burn-on-read post
borPost := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "burn on read channel message",
Type: model.PostTypeBurnOnRead,
}
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*1000))
createdPost, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
// User2 reveals and flags the post
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
require.Nil(t, appErr)
require.NotNil(t, revealedPost)
preference := model.Preference{
UserId: user2.Id,
Category: model.PreferenceCategoryFlaggedPost,
Name: createdPost.Id,
Value: "true",
}
err := th.App.Srv().Store().Preference().Save(model.Preferences{preference})
require.NoError(t, err)
// Expire the receipt
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, createdPost.Id, user2.Id)
require.NoError(t, err)
receipt.ExpireAt = model.GetMillis() - 1000
_, err = th.App.Srv().Store().ReadReceipt().Update(th.Context, receipt)
require.NoError(t, err)
// Get flagged posts for channel
flaggedPosts, appErr := th.App.GetFlaggedPostsForChannel(th.Context, user2.Id, th.BasicChannel.Id, 0, 10)
require.Nil(t, appErr)
require.NotContains(t, flaggedPosts.Order, createdPost.Id)
})
t.Run("non-expired burn-on-read post should appear in flagged posts", func(t *testing.T) {
enableBoRFeature(th)
// Create a burn-on-read post with long expiration
borPost := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "burn on read message still valid",
Type: model.PostTypeBurnOnRead,
}
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(3600*1000)) // 1 hour
createdPost, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
// User2 reveals and flags the post
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
require.Nil(t, appErr)
require.NotNil(t, revealedPost)
preference := model.Preference{
UserId: user2.Id,
Category: model.PreferenceCategoryFlaggedPost,
Name: createdPost.Id,
Value: "true",
}
err := th.App.Srv().Store().Preference().Save(model.Preferences{preference})
require.NoError(t, err)
// Get flagged posts - post should be present
flaggedPosts, appErr := th.App.GetFlaggedPosts(th.Context, user2.Id, 0, 10)
require.Nil(t, appErr)
require.Contains(t, flaggedPosts.Order, createdPost.Id, "Non-expired burn-on-read post should be in flagged posts")
require.NotNil(t, flaggedPosts.Posts[createdPost.Id])
// Verify metadata is populated correctly
post := flaggedPosts.Posts[createdPost.Id]
require.NotNil(t, post.Metadata)
require.NotZero(t, post.Metadata.ExpireAt)
require.Greater(t, post.Metadata.ExpireAt, model.GetMillis())
})
}
func TestBurnOnReadRestrictionsForDMsAndBots(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable BurnOnRead feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.BurnOnRead = true })
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.EnableBurnOnRead = new(true)
cfg.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds = new(600)
cfg.ServiceSettings.BurnOnReadDurationSeconds = new(600)
})
t.Run("should allow burn-on-read posts in direct messages with another user", func(t *testing.T) {
// Create a direct message channel between two different users
dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
require.Nil(t, appErr)
require.Equal(t, model.ChannelTypeDirect, dmChannel.Type)
post := &model.Post{
ChannelId: dmChannel.Id,
Message: "This is a burn-on-read message in DM",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}
createdPost, _, err := th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})
t.Run("should allow burn-on-read posts in group messages", func(t *testing.T) {
// Create a group message channel with at least 3 users
user3 := th.CreateUser(t)
th.LinkUserToTeam(t, user3, th.BasicTeam)
gmChannel := th.CreateGroupChannel(t, th.BasicUser2, user3)
require.Equal(t, model.ChannelTypeGroup, gmChannel.Type)
// This should succeed - group messages allow BoR
post := &model.Post{
ChannelId: gmChannel.Id,
Message: "This is a burn-on-read message in GM",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}
createdPost, _, err := th.App.CreatePost(th.Context, post, gmChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})
t.Run("should allow burn-on-read posts from bot users", func(t *testing.T) {
// Create a bot user
bot := &model.Bot{
Username: "testbot",
DisplayName: "Test Bot",
Description: "Test Bot for burn-on-read (bots can send BoR for OTP, integrations, etc.)",
OwnerId: th.BasicUser.Id,
}
createdBot, appErr := th.App.CreateBot(th.Context, bot)
require.Nil(t, appErr)
// Get the bot user
botUser, appErr := th.App.GetUser(createdBot.UserId)
require.Nil(t, appErr)
require.True(t, botUser.IsBot)
// Create a burn-on-read post as bot (should succeed - bots can send BoR)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This is a burn-on-read message from bot",
UserId: botUser.Id,
Type: model.PostTypeBurnOnRead,
}
createdPost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})
t.Run("should reject burn-on-read posts in self DMs", func(t *testing.T) {
// Create a self DM channel (user messaging themselves)
selfDMChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser.Id)
require.Nil(t, appErr)
require.Equal(t, model.ChannelTypeDirect, selfDMChannel.Type)
// Try to create a burn-on-read post in self DM
post := &model.Post{
ChannelId: selfDMChannel.Id,
Message: "This is a burn-on-read message to myself",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}
_, _, err := th.App.CreatePost(th.Context, post, selfDMChannel, model.CreatePostFlags{SetOnline: true})
require.NotNil(t, err)
require.Equal(t, "api.post.fill_in_post_props.burn_on_read.self_dm.app_error", err.Id)
})
t.Run("should reject burn-on-read posts in DMs with bots/AI agents", func(t *testing.T) {
// Create a bot user
bot := &model.Bot{
Username: "aiagent",
DisplayName: "AI Agent",
Description: "Test AI Agent for burn-on-read restrictions",
OwnerId: th.BasicUser.Id,
}
createdBot, appErr := th.App.CreateBot(th.Context, bot)
require.Nil(t, appErr)
// Get the bot user
botUser, appErr := th.App.GetUser(createdBot.UserId)
require.Nil(t, appErr)
require.True(t, botUser.IsBot)
// Create a DM channel between the regular user and the bot
dmWithBotChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, botUser.Id)
require.Nil(t, appErr)
require.Equal(t, model.ChannelTypeDirect, dmWithBotChannel.Type)
// Try to create a burn-on-read post in DM with bot (regular user sending)
post := &model.Post{
ChannelId: dmWithBotChannel.Id,
Message: "This is a burn-on-read message to AI agent",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}
_, _, err := th.App.CreatePost(th.Context, post, dmWithBotChannel, model.CreatePostFlags{SetOnline: true})
require.NotNil(t, err)
require.Equal(t, "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error", err.Id)
})
t.Run("should reject burn-on-read posts in DMs with deleted users", func(t *testing.T) {
// Create a user that we'll delete
userToDelete := th.CreateUser(t)
th.LinkUserToTeam(t, userToDelete, th.BasicTeam)
// Create a DM channel between the regular user and the user we'll delete
dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, userToDelete.Id)
require.Nil(t, appErr)
require.Equal(t, model.ChannelTypeDirect, dmChannel.Type)
// Delete the user
appErr = th.App.PermanentDeleteUser(th.Context, userToDelete)
require.Nil(t, appErr)
// Try to create a burn-on-read post in DM with deleted user
post := &model.Post{
ChannelId: dmChannel.Id,
Message: "This is a burn-on-read message to deleted user",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}
// This should fail because we can't validate the other user (deleted)
_, _, err := th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{SetOnline: true})
require.NotNil(t, err)
require.Equal(t, "api.post.fill_in_post_props.burn_on_read.user.app_error", err.Id)
})
t.Run("should allow burn-on-read posts in public channels", func(t *testing.T) {
// This should succeed - public channel, regular user
require.Equal(t, model.ChannelTypeOpen, th.BasicChannel.Type)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "This is a burn-on-read message in public channel",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}
createdPost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})
t.Run("should allow burn-on-read posts in private channels", func(t *testing.T) {
// Create a private channel using helper
createdPrivateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
require.Equal(t, model.ChannelTypePrivate, createdPrivateChannel.Type)
// This should succeed - private channel, regular user
post := &model.Post{
ChannelId: createdPrivateChannel.Id,
Message: "This is a burn-on-read message in private channel",
UserId: th.BasicUser.Id,
Type: model.PostTypeBurnOnRead,
}
createdPost, _, err := th.App.CreatePost(th.Context, post, createdPrivateChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
require.NotNil(t, createdPost)
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
})
}
func TestGetBurnOnReadPost(t *testing.T) {
t.Run("success - temporary post found", func(t *testing.T) {
th := Setup(t).InitBasic(t)
post := &model.Post{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "placeholder message",
FileIds: model.StringArray{"file1"},
Type: model.PostTypeBurnOnRead,
}
temporaryPost := &model.TemporaryPost{
ID: post.Id,
Type: model.PostTypeBurnOnRead,
ExpireAt: model.GetMillis() + 3600000,
Message: "actual secret message",
FileIDs: model.StringArray{"file2", "file3"},
}
_, err := th.App.Srv().Store().TemporaryPost().Save(th.Context, temporaryPost)
require.NoError(t, err)
resultPost, appErr := th.App.getBurnOnReadPost(th.Context, post)
require.Nil(t, appErr)
require.NotNil(t, resultPost)
assert.Equal(t, temporaryPost.Message, resultPost.Message)
assert.Equal(t, temporaryPost.FileIDs, resultPost.FileIds)
// Ensure original post is not modified
assert.Equal(t, "placeholder message", post.Message)
assert.Equal(t, model.StringArray{"file1"}, post.FileIds)
})
t.Run("temporary post not found - returns app error", func(t *testing.T) {
th := Setup(t).InitBasic(t)
post := &model.Post{
Id: model.NewId(),
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "placeholder message",
Type: model.PostTypeBurnOnRead,
}
resultPost, appErr := th.App.getBurnOnReadPost(th.Context, post)
require.NotNil(t, appErr)
require.Nil(t, resultPost)
assert.Equal(t, "app.post.get_post.app_error", appErr.Id)
assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode)
})
}
func TestPostChannelMentionsWithPrivateChannels(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channel := th.BasicChannel
user := th.BasicUser
// Create context with session for the user to properly test sanitization
ctx := th.Context.WithSession(&model.Session{UserId: user.Id})
// Create a private channel where user IS a member
privateChannelMember, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Private Member",
Name: "private-member",
Type: model.ChannelTypePrivate,
TeamId: th.BasicTeam.Id,
}, false)
require.Nil(t, err)
th.AddUserToChannel(t, user, privateChannelMember)
// Create a private channel where user is NOT a member
privateChannelNonMember, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Private Non-Member",
Name: "private-non-member",
Type: model.ChannelTypePrivate,
TeamId: th.BasicTeam.Id,
}, false)
require.Nil(t, err)
// Create a public channel where user is NOT a member
publicChannel, err := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "Public Channel",
Name: "public-channel",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
}, false)
require.Nil(t, err)
post := &model.Post{
Message: fmt.Sprintf("~%v and ~%v and ~%v", privateChannelMember.Name, privateChannelNonMember.Name, publicChannel.Name),
ChannelId: channel.Id,
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
UserId: user.Id,
CreateAt: 0,
}
post, _, err = th.App.CreatePostAsUser(ctx, post, "", true)
require.Nil(t, err)
mentions := post.GetProp(model.PostPropsChannelMentions)
require.NotNil(t, mentions)
mentionsMap, ok := mentions.(map[string]any)
require.True(t, ok)
// Should include private channel where user IS a member
assert.Contains(t, mentionsMap, privateChannelMember.Name)
// Should NOT include private channel where user is NOT a member
assert.NotContains(t, mentionsMap, privateChannelNonMember.Name)
// Should include public channel (user has team access)
assert.Contains(t, mentionsMap, publicChannel.Name)
}
func TestGetPostsForView(t *testing.T) {
mainHelper.Parallel(t)
t.Run("returns posts for channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Create a few posts
post1, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "post 1",
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
post2, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "post 2",
}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
options := model.GetPostsOptions{
ChannelId: th.BasicChannel.Id,
Page: 0,
PerPage: 10,
UserId: th.BasicUser.Id,
}
postList, appErr := th.App.GetPostsForView(th.Context, options)
require.Nil(t, appErr)
require.NotNil(t, postList)
assert.Contains(t, postList.Posts, post1.Id)
assert.Contains(t, postList.Posts, post2.Id)
})
t.Run("returns empty list for channel with no posts", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channel := th.CreateChannel(t, th.BasicTeam)
options := model.GetPostsOptions{
ChannelId: channel.Id,
Page: 0,
PerPage: 10,
UserId: th.BasicUser.Id,
}
postList, appErr := th.App.GetPostsForView(th.Context, options)
require.Nil(t, appErr)
require.NotNil(t, postList)
assert.Empty(t, postList.Posts)
})
t.Run("respects pagination", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channel := th.CreateChannel(t, th.BasicTeam)
for i := range 5 {
_, _, err := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
Message: fmt.Sprintf("post %d", i),
}, channel, model.CreatePostFlags{})
require.Nil(t, err)
}
options := model.GetPostsOptions{
ChannelId: channel.Id,
Page: 0,
PerPage: 2,
UserId: th.BasicUser.Id,
}
postList, appErr := th.App.GetPostsForView(th.Context, options)
require.Nil(t, appErr)
require.NotNil(t, postList)
assert.Len(t, postList.Posts, 2)
})
t.Run("invalid channel id returns error", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
options := model.GetPostsOptions{
ChannelId: model.NewId(),
Page: 0,
PerPage: 10,
UserId: th.BasicUser.Id,
}
postList, appErr := th.App.GetPostsForView(th.Context, options)
require.Nil(t, appErr)
require.NotNil(t, postList)
assert.Empty(t, postList.Posts)
})
}