Files
team/server/channels/app/post_test.go
9ba7032f5c Cherry-pick: Go 1.25.8 + FIPS password fixes for release-11.5 (#36117)
* ci: shard server Postgres tests into 4 parallel runners (#35739)

* ci: add test sharding plumbing to server CI

Add infrastructure for upcoming test sharding without changing behavior:

- Add shard-index and shard-total inputs to server-test-template.yml
  (defaults preserve existing single-runner behavior)
- Add timing cache restore step (activates only when shard-total > 1)
- Add merge-postgres-test-results job to server-ci.yml that:
  - Merges JUnit XML reports from shard artifacts
  - Saves timing data cache for future shard balancing
  - Handles both single-artifact and multi-shard scenarios
- Add .gitignore entries for timing cache and shard work files

* ci: shard server Postgres tests into 4 parallel runners

Extract sharding logic into standalone, tested scripts and enable
4-shard parallel test execution for server Postgres CI:

Scripts:
- server/scripts/shard-split.js: Node.js bin-packing solver that
  assigns test packages to shards using timing data from previous runs.
  Two-tier strategy: light packages (<2min) whole, heavy packages
  (api4, app) split at individual test level.
- server/scripts/run-shard-tests.sh: Multi-run wrapper that calls
  gotestsum directly for each package group with -run regex filters.
- server/scripts/shard-split.test.js: 8 test cases covering round-robin
  fallback, timing-based balancing, heavy package splitting, JUnit XML
  fallback, and enterprise package separation.

Workflow changes:
- server-test-template.yml: Add shard splitting step that discovers test
  packages and runs the solver. Modified Run Tests step to use wrapper
  script when sharding is active.
- server-ci.yml: Add 4-shard matrix to test-postgres-normal. Update
  merge job artifact patterns for shard-specific names.

Performance: 7.2 min with timing cache vs 62.5 min baseline = 88%
wall-time improvement. First run without cache uses JUnit XML fallback
or round-robin, then populates the cache for subsequent runs.

* fix: raise heavy package threshold to 5 min to preserve test isolation

sqlstore integrity tests scan the entire database and fail when other
packages' test data is present. At 182s, sqlstore was just over the
120s threshold and getting split at test level. Raising to 300s keeps
only api4 (~38 min) and app (~15 min) as heavy — where the real
sharding gains are — while sqlstore, elasticsearch, etc. stay whole
and maintain their test isolation guarantees.

* ci: only save test timing cache on default branch

PR branches always restore from master's timing cache via restore-keys
prefix matching. Timing data is stable day-to-day so this eliminates
cache misses on first PR runs and reduces cache storage.

* ci: skip FIPS tests on PRs (enterprise CI handles compile check)

Per review feedback: the enterprise CI already runs a FIPS compile
check on every PR. Running the full FIPS test suite on PRs is redundant
since it uses the identical test suite as non-FIPS — the only
FIPS-specific failure mode is a build failure from non-approved crypto
imports, which the enterprise compile check catches.

Full FIPS tests continue to run on every push to master.

* fix: address review feedback on run-shard-tests.sh

- Remove set -e so all test runs execute even if earlier ones fail;
  track failures and exit with error at the end (wiggin77)
- Remove unused top-level COVERAGE_FLAG variable (wiggin77)
- Fix RUN_IDX increment position so report, json, and coverage files
  share the same index (wiggin77)
- Update workflow comment: heavy threshold is 5 min, not 2 min (wiggin77)

* style: use node: prefix for built-in fs module in shard-split.js

* fix: avoid interpolating file paths into generated shell script

Read shard package lists from files at runtime instead of interpolating
them into the generated script via printf. This prevents theoretical
shell metacharacter injection from directory names, as flagged by
DryRun Security.

* fix(ci): rename merged artifact to match server-ci-report glob

The merged artifact was named postgres-server-test-logs-merged which
does not match the *-test-logs pattern in server-ci-report.yml,
causing Postgres test results to be missing from PR/commit reports.

Also pins junit-report-merger to exact version 7.0.0 for supply chain
safety.

* fix(ci): pass RACE_MODE env into Docker container

RACE_MODE was set on the host runner but never included in the docker
run --env list. The light-package path worked because the heredoc
expanded on the host, but run-shard-tests.sh reads RACE_MODE at
runtime inside the container where it was unset. This caused heavy
packages (api4, app) to silently lose -race detection.

* fix(ci): discover new tests in heavy packages not in timing cache

Tests not present in the timing cache (newly added or renamed) would
not appear in any shard -run regex, causing them to silently skip.
After building items from the cache, run go test -list to discover
current test names and assign any cache-missing tests to shards via
the normal bin-packing algorithm with a small default duration.

* fix(ci): add missing line continuation backslash in docker run

The previous --env FIPS_ENABLED line was missing a trailing backslash
after adding --env RACE_MODE, causing docker run to see a truncated
command and fail with "requires at least 1 argument".

* fix(ci): add setup-go step for shard test discovery

go test -list in shard-split.js runs on the host runner via execSync,
but Go is only available inside the Docker container. Without this
step, every invocation fails silently and new-test discovery is a
no-op. Adding actions/setup-go before the shard split step ensures
the Go toolchain is available on the host.

---------

* updated go to version 1.25.8 (#35817)

* updated go to version 1.25.8

* updated gotestsum version to work with go 1.25.8

go 1.25 does not work with indirect tools 0.11 dependency pulled by
gotestsum.

* Use sync.WaitGroup.Go to simplify goroutine creation

Replace the wg.Add(1) + go func() { defer wg.Done() }() pattern with
wg.Go(), which was introduced in Go 1.25.

* pushes fips image on workflow dispatch to allow fips test to run on go version update

* fix new requirements for FIPS compliance imposed on updating to go 1.25.8

* updates openssl symbol check for library shipped with FIPS new versions

go-openssl v2 shipped with FIPS versions starting from 1.25 uses mkcgo to generate
bindings causing symbol names to be different.

* removes temp workflow-dispatch condition

* keep versions out of agents md file

* Move password hashers from server/v8 to server/public to fix module layering violation (#35805)a

* Move password hashers from server/v8 to server/public to fix layering violation

* Revert "Move password hashers from server/v8 to server/public to fix layering violation"

This reverts commit 8cad5b8dc93df032d62f20634efff7398dcd76f0.

* invert dependency between hashers and model

* make modules-tidy

---------

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

* Generate instead of hard-coding test passwords, enforce new minimum for FIPS, shard CI, fix FIPS builds (#35905)

* Replace hardcoded test passwords with model.NewTestPassword()

Add model.NewTestPassword() utility that generates 14+ character
passwords meeting complexity requirements for FIPS compliance. Replace
all short hardcoded test passwords across the test suite with calls to
this function.

* Enforce FIPS compliance for passwords and HMAC keys

FIPS OpenSSL requires HMAC keys to be at least 14 bytes. PBKDF2 uses
the password as the HMAC key internally, so short passwords cause
PKCS5_PBKDF2_HMAC to fail.

- Add FIPSEnabled and PasswordFIPSMinimumLength build-tag constants
- Raise the password minimum length floor to 14 when compiled with
  requirefips, applied in SetDefaults only when unset and validated
  independently in IsValid
- Return ErrMismatchedHashAndPassword for too-short passwords in
  PBKDF2 CompareHashAndPassword rather than a cryptic OpenSSL error
- Validate atmos/camo HMAC key length under FIPS and lengthen test
  keys accordingly
- Adjust password validation tests to use PasswordFIPSMinimumLength
  so they work under both FIPS and non-FIPS builds

* CI: shard FIPS test suite and extract merge template

Run FIPS tests on PRs that touch go.mod or have 'fips' in the branch
name. Shard FIPS tests across 4 runners matching the normal Postgres
suite. Extract the test result merge logic into a reusable workflow
template to deduplicate the normal and FIPS merge jobs.

* more

* Fix email test helper to respect FIPS minimum password length

* Fix test helpers to respect FIPS minimum password length

* Remove unnecessary "disable strict password requirements" blocks from test helpers

* Fix CodeRabbit review comments on PR #35905

- Add server-test-merge-template.yml to server-ci.yml pull_request.paths
  so changes to the reusable merge workflow trigger Server CI validation
- Skip merge-postgres-fips-test-results job when test-postgres-normal-fips
  was skipped, preventing failures due to missing artifacts
- Set guest.Password on returned guest in CreateGuestAndClient helper
  to keep contract consistent with CreateUserWithClient
- Use shared LowercaseLetters/UppercaseLetters/NUMBERS/PasswordFIPSMinimumLength
  constants in NewTestPassword() to avoid drift if FIPS floor changes

* Rename FIPS test artifact to match server-ci-report pattern

The server-ci-report job searches for artifacts matching "*-test-logs",
so rename from postgres-server-test-logs-fips to
postgres-server-fips-test-logs to be included in the report.

---------

* upgrade golangci-lint (#35845)

* feat: automatically trigger fips e2e tests for relevant prs (#36014)

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

* Fix FIPS-incompatible passwords and config in e2e test suites (#36001)

* Fix FIPS-incompatible passwords and config in e2e test suites

FIPS OpenSSL requires PBKDF2 HMAC keys >= 14 bytes, so the server
now enforces PasswordSettings.MinimumLength >= 14 under FIPS builds.
The e2e suites were failing (Cypress 0/447, Playwright 16/353) because
they used short passwords and set MinimumLength below the FIPS floor.

- Add newTestPassword() to both Cypress and Playwright utilities,
  returning a static FIPS-compliant password (>= 14 chars)
- Replace all short hardcoded Mattermost user passwords ('passwd',
  'Test123456!', 'Testing123', 'Password123!') with newTestPassword()
- Raise PasswordSettings.MinimumLength from 5 to 14 in default configs
- Update password validation tests for the FIPS minimum (error messages,
  default expectations, test password lengths)

* Fix additional FIPS e2e test failures

- Revert server-constant-dependent assertions (MinimumLength defaults
  and validation errors) since those depend on FIPS build tags, not config
- Fix short hardcoded passwords in signup, forgot_password, and
  existing_email_address specs to use newTestPassword()
- Fix password_spec cancel test to use a different password than
  testUser.password (both resolve to newTestPassword())
- Fix Playwright password reset test to use newTestPassword() instead
  of pw.random.id() (7 chars, too short for MinimumLength=14)
- Update password help text assertions to match MinimumLength=14 config
- Use regex for Playwright signup page password error locator
- Fix import order in ABAC support.ts

* Fix FIPS vs non-FIPS build-dependent e2e test assertions

The server's password minimum constants differ between FIPS (14) and
non-FIPS (8/5) builds. Make three test assertions adaptive:

- MM-T1770: Accept default MinimumLength of either 8 or 14
- MM-T1771: Match validation error with either 5 or 14 as the minimum
- MM-T1773: Reload after save and compare against actual server config,
  since saving MinimumLength=5 (webapp default) is rejected on FIPS

* e2e: upgrade demo plugin to v0.11.0 and configure required settings

* update demo plugin download link

---------

Co-authored-by: sabril <[email protected]>

* fix(cypress): demo plugin (#36056)

* test: clean up channel store data after TestChannelStore (#36066)

TestChannelStore sub-tests create channels, members, and team members
using fake TeamIds and UserIds (model.NewId() for non-existent rows).
These records are left in the database and cause integrity tests
(TestCheck*) running in the same binary to fail their full-table scans.

Register a t.Cleanup on TestChannelStore that purges the affected
tables entirely. A blanket purge is safe: the schema enforces no FK
constraints, and every test suite creates its own data independently.

* Fix command injection in server-test-template workflow (#36080)

Replace the unquoted heredoc (which embedded GITHUB_HEAD_REF into a
generated script) with a cp of the existing run-shard-tests.sh, which
already handles the light-only case. Pass BUILD_NUMBER and TEST_TARGET
as explicit docker env vars instead of interpolating them into script
content.

* fix(ci): restore testname format in sharded gotestsum runs (#36078)

run-shard-tests.sh called gotestsum directly without --format, so it
fell back to gotestsum's default (pkgname) instead of the testname
format set by the Makefile. Pass --format "${GOTESTSUM_FORMAT:-testname}"
to match the Makefile default.

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

* fix: remove UpdatePostsChannelTypeByChannelId/BackfillPostsChannelType from release-11.5

These functions were added to master but not backported to release-11.5.
The golangci-lint cherry-pick incorrectly included them via conflict resolution.

* style: gofmt elasticsearch/opensearch files after conflict resolution

* fix: remove actions.spec.ts (references SystemConsolePage.users, absent on release-11.5)

---------

Co-authored-by: Pavel Zeman <[email protected]>

Co-authored-by: Carlos Garcia <[email protected]>
Co-authored-by: Christopher Speller <[email protected]>
Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: sabril <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-04-16 13:49:18 +02:00

5834 lines
196 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"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/platform/services/imageproxy"
"github.com/mattermost/mattermost/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 = model.NewPointer(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/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/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/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/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.Id, 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.Id, 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/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/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/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/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/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/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/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/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
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(th.Context, 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(th.Context, 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(th.Context, result, nil)
require.True(t, isMemberForPreviews)
require.Nil(t, err)
assert.Nil(t, result.GetProp(model.PostPropsChannelMentions))
}
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/api/v4/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/api/v4/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 = model.NewPointer(true)
cfg.ImageProxySettings.ImageProxyType = model.NewPointer(tc.ProxyType)
cfg.ImageProxySettings.RemoteImageProxyOptions = model.NewPointer(tc.ProxyOptions)
cfg.ImageProxySettings.RemoteImageProxyURL = model.NewPointer(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 TestCreatePost(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/api/v4/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) {
mainHelper.Parallel(t)
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 = model.NewPointer(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) {
mainHelper.Parallel(t)
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 = model.NewPointer(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 remove post file IDs for burn on read posts", 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)
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)
})
}
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/api/v4/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: model.NewPointer("![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: model.NewPointer("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: model.NewPointer("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: model.NewPointer("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: model.NewPointer("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: model.NewPointer("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,
}
post.AddProp(model.PostPropsFromWebhook, "true")
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 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: model.NewPointer(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/api/v4/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("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("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("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("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)
_, _, err = th.App.CreatePost(th.Context, &model.Post{
UserId: user2.Id,
ChannelId: channel.Id,
Message: fmt.Sprintf("@%s", user2.Username),
Props: map[string]any{
model.PostPropsFromWebhook: "true",
},
}, channel, model.CreatePostFlags{SetOnline: 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)
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)
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: model.NewPointer("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 TestComputeLastAccessiblePostTime(t *testing.T) {
mainHelper.Parallel(t)
t.Run("Updates the time, if Entry license limit is applicable", func(t *testing.T) {
th := SetupWithStoreMock(t)
// Set Entry license with post history limit of 100 messages
entryLicensePostsLimit := model.NewTestLicenseSKU(model.LicenseShortSkuMattermostEntry)
entryLicensePostsLimit.Limits = &model.LicenseLimits{PostHistory: 100}
th.App.Srv().SetLicense(entryLicensePostsLimit)
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockPostStore := storemocks.PostStore{}
mockPostStore.On("GetNthRecentPostTime", int64(100)).Return(int64(1234567890), nil)
mockSystemStore := storemocks.SystemStore{}
mockSystemStore.On("SaveOrUpdate", mock.Anything).Return(nil)
mockStore.On("Post").Return(&mockPostStore)
mockStore.On("System").Return(&mockSystemStore)
err := th.App.ComputeLastAccessiblePostTime()
assert.NoError(t, err)
// Verify that the system value was saved with the calculated timestamp
mockSystemStore.AssertCalled(t, "SaveOrUpdate", &model.System{
Name: model.SystemLastAccessiblePostTime,
Value: "1234567890",
})
})
t.Run("Remove the time if license limit is NOT applicable", func(t *testing.T) {
th := SetupWithStoreMock(t)
// Set license without post history limits (using test license without limits)
license := model.NewTestLicense()
license.Limits = nil // No limits
th.App.Srv().SetLicense(license)
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockSystemStore := storemocks.SystemStore{}
mockSystemStore.On("GetByName", model.SystemLastAccessiblePostTime).Return(&model.System{Name: model.SystemLastAccessiblePostTime, Value: "1234567890"}, nil)
mockSystemStore.On("PermanentDeleteByName", model.SystemLastAccessiblePostTime).Return(nil, nil)
mockStore.On("System").Return(&mockSystemStore)
err := th.App.ComputeLastAccessiblePostTime()
assert.NoError(t, err)
// Verify that SaveOrUpdate was not called (no new timestamp calculated)
mockSystemStore.AssertNotCalled(t, "SaveOrUpdate", mock.Anything)
// Verify that the previous value was deleted
mockSystemStore.AssertCalled(t, "PermanentDeleteByName", model.SystemLastAccessiblePostTime)
})
}
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: model.NewPointer("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: model.NewPointer("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: model.NewPointer("new message edited"),
}
_, _, appErr := th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
patch = &model.PostPatch{
Message: model.NewPointer("new message edited 2"),
}
_, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
patch = &model.PostPatch{
Message: model.NewPointer("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: model.NewPointer("new message edited"),
}
_, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
patch = &model.PostPatch{
Message: model.NewPointer("new message edited 2"),
}
_, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil)
require.Nil(t, appErr)
patch = &model.PostPatch{
Message: model.NewPointer("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 = model.NewPointer(true)
cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable = model.NewPointer(true)
cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable = model.NewPointer(true)
cfg.WranglerSettings.MoveThreadToAnotherTeamEnable = model.NewPointer(true)
cfg.WranglerSettings.MoveThreadMaxCount = model.NewPointer(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 = model.NewPointer(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 = model.NewPointer(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 = model.NewPointer(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 = model.NewPointer(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) {
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
t.Cleanup(func() {
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
})
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
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 = model.NewPointer(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 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 = model.NewPointer(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 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) {
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
t.Cleanup(func() {
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
})
th := Setup(t).InitBasic(t)
// 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) {
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
t.Cleanup(func() {
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
})
th := Setup(t).InitBasic(t)
// 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 = model.NewPointer(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) {
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
t.Cleanup(func() {
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
})
th := Setup(t).InitBasic(t)
// 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) {
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
defer func() {
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
}()
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.EnableBurnOnRead = model.NewPointer(true)
cfg.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds = model.NewPointer(600)
cfg.ServiceSettings.BurnOnReadDurationSeconds = model.NewPointer(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)
})
}