Files
2c92168422 MM-68952: Resolve public channel mentions for non-members under Compliance (#36815) (#36883)
* MM-68952: Resolve public channel mentions for non-members under Compliance

Channel mention name resolution reused HasPermissionToReadChannel, a
content-read check that returns false for non-members of a public channel
when Compliance Monitoring is enabled (MM-45272) or when the channel is on
another team (MM-66791). As a result, the channel_mentions post prop was
stripped per-viewer (since #34235), and the webapp fell back to rendering the
raw (anonymized) channel slug instead of a clickable link.

Introduce HasPermissionToResolveChannelMention, which exposes only a public
channel display name and link (not content) and is therefore independent of
ComplianceSettings, while still requiring team membership for public channels
(blocks cross-team disclosure) and channel membership for private/DM/GM
channels. Switch the three mention call sites (FillInPostProps,
sanitizeChannelMentionsForUser, channelMentionsBroadcastHook) to the new
helper. HasPermissionToReadChannel and all content-read paths are unchanged.

* MM-68952: Add author-side and E2E coverage for channel mention resolution

Add a Go test (TestFillInPostPropsChannelMentionResolution) that exercises the
author-side persistence of the channel_mentions prop in FillInPostProps. It
locks in the new behavior: an author who is a team member but not a member of a
referenced public channel now persists the mention prop even when Compliance
Monitoring is enabled, while public channels on other teams and private
channels the author is not in are still dropped.

Add a Playwright spec (channel_mention_resolution.spec.ts) with a license-free
cross-team case (a public channel mention stays unresolved for a viewer not on
the channel's team) and a license-gated case (with Compliance enabled, a team
member who is not in the channel sees the resolved mention link).

* MM-68952: Tighten channel mention test assertions and fix lint

Use strings.Builder when assembling the test message to avoid the
golangci-lint stringsbuilder (modernize) warning about string += string
in a loop. Tighten the cross-team public and private non-member cases to
assert.Nil on the resolved mentions so the contract requires no persisted
channel_mentions map rather than merely an empty one.

---------

(cherry picked from commit c9759c392e)

Co-authored-by: Maria A Nunez <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-04 07:04:58 +00:00

326 lines
9.6 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"os"
"path/filepath"
"sync"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
type TestHelper struct {
Context request.CTX
Service *PlatformService
Suite SuiteIFace
Store store.Store
BasicTeam *model.Team
BasicUser *model.User
BasicUser2 *model.User
BasicChannel *model.Channel
// BasicPost *model.Post
SystemAdminUser *model.User
runShutdown sync.Once
tempWorkspace string
}
type mockSuite struct{}
func (ms *mockSuite) SetStatusLastActivityAt(userID string, activityAt int64) {}
func (ms *mockSuite) SetStatusOffline(userID string, manual bool, force bool) {}
func (ms *mockSuite) IsUserAway(lastActivityAt int64) bool { return false }
func (ms *mockSuite) SetStatusOnline(userID string, manual bool) {}
func (ms *mockSuite) UpdateLastActivityAtIfNeeded(session model.Session) {}
func (ms *mockSuite) SetStatusAwayIfNeeded(userID string, manual bool) {}
func (ms *mockSuite) GetSession(token string) (*model.Session, *model.AppError) {
return &model.Session{}, nil
}
func (ms *mockSuite) RolesGrantPermission(roleNames []string, permissionId string) bool { return true }
func (ms *mockSuite) UserCanSeeOtherUser(rctx request.CTX, userID string, otherUserId string) (bool, *model.AppError) {
return true, nil
}
func (ms *mockSuite) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) (bool, bool) {
return true, true
}
func (ms *mockSuite) HasPermissionToResolveChannelMention(rctx request.CTX, userID string, channel *model.Channel) bool {
return true
}
func (ms *mockSuite) HasPermissionToFileAction(rctx request.CTX, userID string, roles string, channelID string, action string) bool {
return true
}
func (ms *mockSuite) MFARequired(rctx request.CTX) *model.AppError {
return nil
}
func (ms *mockSuite) MakeAuditRecord(rctx request.CTX, event string, initialStatus string) *model.AuditRecord {
return &model.AuditRecord{
Status: initialStatus,
EventName: event,
}
}
func (ms *mockSuite) LogAuditRec(rctx request.CTX, auditRec *model.AuditRecord, err error) {
}
func setupDBStore(tb testing.TB) (store.Store, *model.SqlSettings) {
var dbStore store.Store
var dbSettings *model.SqlSettings
if mainHelper.Options.RunParallel {
dbStore, _, dbSettings, _ = mainHelper.GetNewStores(tb)
tb.Cleanup(func() {
dbStore.Close()
})
} else {
dbStore = mainHelper.GetStore()
dbStore.DropAllTables()
dbStore.MarkSystemRanUnitTests()
dbSettings = mainHelper.Settings
mainHelper.PreloadMigrations()
}
return dbStore, dbSettings
}
func Setup(tb testing.TB, options ...Option) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
dbStore, dbSettings := setupDBStore(tb)
return setupTestHelper(dbStore, dbSettings, false, true, tb, options...)
}
func (th *TestHelper) InitBasic(tb testing.TB) *TestHelper {
th.SystemAdminUser = th.CreateAdmin(tb)
th.BasicUser = th.CreateUserOrGuest(tb, false)
th.BasicUser2 = th.CreateUserOrGuest(tb, false)
th.BasicTeam = th.CreateTeam(tb)
// th.LinkUserToTeam(t, th.BasicUser, th.BasicTeam)
// th.LinkUserToTeam(t, th.BasicUser2, th.BasicTeam)
th.BasicChannel = th.CreateChannel(tb, th.BasicTeam)
// th.BasicPost = th.CreatePost(t, th.BasicChannel)
return th
}
func SetupWithStoreMock(tb testing.TB, options ...Option) *TestHelper {
mockStore := testlib.GetMockStoreForSetupFunctions()
options = append(options, StoreOverride(mockStore))
th := setupTestHelper(mockStore, &model.SqlSettings{}, false, false, tb, options...)
return th
}
func SetupWithCluster(tb testing.TB, cluster einterfaces.ClusterInterface) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
dbStore, dbSettings := setupDBStore(tb)
th := setupTestHelper(dbStore, dbSettings, true, true, tb)
th.Service.clusterIFace = cluster
return th
}
func setupTestHelper(dbStore store.Store, dbSettings *model.SqlSettings, enterprise bool, includeCacheLayer bool, tb testing.TB, options ...Option) *TestHelper {
tempWorkspace, err := os.MkdirTemp("", "apptest")
require.NoError(tb, err)
configStore := config.NewTestMemoryStore()
memoryConfig := configStore.Get()
memoryConfig.SqlSettings = *dbSettings
// Disable connection pool cleanup goroutines to prevent DATA RACE with
// testify mock argument diffing under the race detector. The sql.DB
// connectionCleaner goroutine writes to internal fields while testify's
// mock.Called() → Arguments.Diff() → fmt.Sprintf reads them via reflect.
// Setting lifetime/idle to 0 prevents the cleaner from starting.
memoryConfig.SqlSettings.ConnMaxLifetimeMilliseconds = new(0)
memoryConfig.SqlSettings.ConnMaxIdleTimeMilliseconds = new(0)
*memoryConfig.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins")
*memoryConfig.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp")
*memoryConfig.PluginSettings.AutomaticPrepackagedPlugins = false
*memoryConfig.LogSettings.EnableSentry = false // disable error reporting during tests
*memoryConfig.AnnouncementSettings.AdminNoticesEnabled = false
*memoryConfig.AnnouncementSettings.UserNoticesEnabled = false
*memoryConfig.MetricsSettings.Enable = true
*memoryConfig.ServiceSettings.ListenAddress = "localhost:0"
*memoryConfig.MetricsSettings.ListenAddress = "localhost:0"
_, _, err = configStore.Set(memoryConfig)
require.NoError(tb, err)
options = append(options, ConfigStore(configStore))
ps, err := New(
ServiceConfig{
Store: dbStore,
}, options...)
if err != nil {
require.NoError(tb, err)
}
th := &TestHelper{
Context: request.TestContext(tb),
Service: ps,
Suite: &mockSuite{},
Store: dbStore,
tempWorkspace: tempWorkspace,
}
if _, ok := dbStore.(*mocks.Store); ok {
statusMock := mocks.StatusStore{}
statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil)
statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil)
statusMock.On("UpdateLastActivityAt", "user1", mock.Anything).Return(nil)
statusMock.On("SaveOrUpdate", mock.AnythingOfType("*model.Status")).Return(nil)
emptyMockStore := mocks.Store{}
emptyMockStore.On("Close").Return(nil)
emptyMockStore.On("Status").Return(&statusMock)
th.Service.Store = &emptyMockStore
}
// Share same configuration with app.TestHelper
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.MaxUsersPerTeam = 50
*cfg.RateLimitSettings.Enable = false
*cfg.TeamSettings.EnableOpenServer = true
})
if enterprise {
th.Service.SetLicense(model.NewTestLicense())
} else {
th.Service.SetLicense(nil)
}
err = th.Service.Start(nil)
require.NoError(tb, err)
tb.Cleanup(func() {
th.Shutdown(tb)
})
return th
}
// Shutdown may be called by tests to manually shut down the [TestHelper].
// If it's not called manually, it will get called automatically via [testing.TB.Cleanup].
func (th *TestHelper) Shutdown(tb testing.TB) {
th.runShutdown.Do(func() {
err := th.Service.ShutdownMetrics()
require.NoError(tb, err)
err = th.Service.Shutdown()
require.NoError(tb, err)
err = th.Service.ShutdownConfig()
require.NoError(tb, err)
if th.tempWorkspace != "" {
err := os.RemoveAll(th.tempWorkspace)
require.NoError(tb, err)
}
})
}
func (th *TestHelper) CreateTeam(tb testing.TB) *model.Team {
id := model.NewId()
team := &model.Team{
DisplayName: "dn_" + id,
Name: "name" + id,
Email: "success+" + id + "@simulator.amazonses.com",
Type: model.TeamOpen,
}
team, err := th.Service.Store.Team().Save(team)
require.NoError(tb, err)
return team
}
func (th *TestHelper) CreateUserOrGuest(tb testing.TB, guest bool) *model.User {
id := model.NewId()
user := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
Roles: model.SystemUserRoleId,
}
user, err := th.Service.Store.User().Save(th.Context, user)
require.NoError(tb, err)
return user
}
func (th *TestHelper) CreateAdmin(tb testing.TB) *model.User {
id := model.NewId()
user := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
Roles: model.SystemAdminRoleId + " " + model.SystemUserRoleId,
}
user, err := th.Service.Store.User().Save(th.Context, user)
require.NoError(tb, err)
return user
}
type ChannelOption func(*model.Channel)
func WithShared(v bool) ChannelOption {
return func(channel *model.Channel) {
channel.Shared = new(v)
}
}
func (th *TestHelper) CreateChannel(tb testing.TB, team *model.Team, options ...ChannelOption) *model.Channel {
id := model.NewId()
channel := &model.Channel{
TeamId: team.Id,
DisplayName: "dn_" + id,
Name: "name" + id,
Type: model.ChannelTypeOpen,
}
for _, option := range options {
option(channel)
}
channel, err := th.Service.Store.Channel().Save(th.Context, channel, 999)
require.NoError(tb, err)
return channel
}