mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
MM-67312: Restrict Burn-on-Read for self DMs and bot users (#35116)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
* MM-67312: Restrict Burn-on-Read for self DMs and bot users * fix lint issues * use utility function to make code more reliable * add test case for deleted user and handle restrictively that scenario * fix i18n * Allow bots to send BoR; block only self-DMs & DMs with bots * Refactor BoR validation to API layer with individual params * adjust comment * Fix BoR validation to fail-closed when context unavailable * Fix variable shadowing in CreatePost burn-on-read validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * remove translation entry * fix linter --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
152d8eb845
commit
e46bea673d
10 changed files with 775 additions and 2 deletions
|
|
@ -81,6 +81,11 @@ func createPostChecks(where string, c *Context, post *model.Post) {
|
|||
}
|
||||
|
||||
postPriorityCheckWithContext(where, c, post.GetPriority(), post.RootId)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
postBurnOnReadCheckWithContext(where, c, post, nil)
|
||||
}
|
||||
|
||||
func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ func postPriorityCheckWithContext(where string, c *Context, priority *model.Post
|
|||
}
|
||||
}
|
||||
|
||||
func postBurnOnReadCheckWithContext(where string, c *Context, post *model.Post, channel *model.Channel) {
|
||||
appErr := app.PostBurnOnReadCheckWithApp(where, c.App, c.AppContext, post.UserId, post.ChannelId, post.Type, channel)
|
||||
if appErr != nil {
|
||||
appErr.Where = where
|
||||
c.Err = appErr
|
||||
}
|
||||
}
|
||||
|
||||
// checkUploadFilePermissionForNewFiles checks upload_file permission only when
|
||||
// adding new files to a post, preventing permission bypass via cross-channel file attachments.
|
||||
func checkUploadFilePermissionForNewFiles(c *Context, newFileIds []string, originalPost *model.Post) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,17 @@ func scheduledPostChecks(where string, c *Context, scheduledPost *model.Schedule
|
|||
}
|
||||
|
||||
postPriorityCheckWithContext(where, c, scheduledPost.GetPriority(), scheduledPost.RootId)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate burn-on-read restrictions for scheduled post
|
||||
post := &model.Post{
|
||||
ChannelId: scheduledPost.ChannelId,
|
||||
UserId: scheduledPost.UserId,
|
||||
Type: scheduledPost.Type,
|
||||
}
|
||||
postBurnOnReadCheckWithContext(where, c, post, nil)
|
||||
}
|
||||
|
||||
func requireScheduledPostsEnabled(c *Context) {
|
||||
|
|
|
|||
|
|
@ -165,6 +165,12 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan
|
|||
return nil, false, model.NewAppError("CreatePost", "app.post.create_post.shared_dm_or_gm.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Validate burn-on-read restrictions (self-DMs, DMs with bots)
|
||||
err = PostBurnOnReadCheckWithApp("App.CreatePost", a, rctx, post.UserId, post.ChannelId, post.Type, channel)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
foundPost, err := a.deduplicateCreatePost(rctx, post)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
|
|
|
|||
|
|
@ -114,3 +114,47 @@ func userCreatePostPermissionCheckWithApp(rctx request.CTX, a *App, userId, chan
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostBurnOnReadCheckWithApp validates whether a burn-on-read post can be created
|
||||
// based on channel type and participants. This is called from the API layer before
|
||||
// post creation to enforce burn-on-read restrictions.
|
||||
func PostBurnOnReadCheckWithApp(where string, a *App, rctx request.CTX, userId, channelId, postType string, channel *model.Channel) *model.AppError {
|
||||
// Only validate if this is a burn-on-read post
|
||||
if postType != model.PostTypeBurnOnRead {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get channel if not provided
|
||||
if channel == nil {
|
||||
ch, err := a.GetChannel(rctx, channelId)
|
||||
if err != nil {
|
||||
return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
channel = ch
|
||||
}
|
||||
|
||||
// Burn-on-read is not allowed in self-DMs or DMs with bots (including AI agents, plugins)
|
||||
if channel.Type == model.ChannelTypeDirect {
|
||||
// Check if it's a self-DM by comparing the channel name with the expected self-DM name
|
||||
selfDMName := model.GetDMNameFromIds(userId, userId)
|
||||
if channel.Name == selfDMName {
|
||||
return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.self_dm.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Check if the DM is with a bot (AI agents, plugins, etc.)
|
||||
otherUserId := channel.GetOtherUserIdForDM(userId)
|
||||
if otherUserId != "" && otherUserId != userId {
|
||||
otherUser, err := a.GetUser(otherUserId)
|
||||
if err != nil {
|
||||
// Failed to retrieve the other user (user not found, DB error, etc.)
|
||||
// Block burn-on-read post as we cannot validate the recipient
|
||||
return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
if otherUser.IsBot {
|
||||
return model.NewAppError(where, "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5368,6 +5368,210 @@ func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -314,6 +314,19 @@ func (a *App) canPostScheduledPost(rctx request.CTX, scheduledPost *model.Schedu
|
|||
return model.ScheduledPostErrorInvalidPost, nil
|
||||
}
|
||||
|
||||
// Validate burn-on-read restrictions for scheduled post
|
||||
if appErr := PostBurnOnReadCheckWithApp("ScheduledPostJob.postChecks", a, rctx, scheduledPost.UserId, scheduledPost.ChannelId, scheduledPost.Type, channel); appErr != nil {
|
||||
rctx.Logger().Debug(
|
||||
"canPostScheduledPost burn-on-read check failed",
|
||||
mlog.String("scheduled_post_id", scheduledPost.Id),
|
||||
mlog.String("user_id", scheduledPost.UserId),
|
||||
mlog.String("channel_id", scheduledPost.ChannelId),
|
||||
mlog.String("error_code", model.ScheduledPostErrorInvalidPost),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
return model.ScheduledPostErrorInvalidPost, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2800,6 +2800,14 @@
|
|||
"id": "api.post.error_get_post_id.pending",
|
||||
"translation": "Unable to get the pending post."
|
||||
},
|
||||
{
|
||||
"id": "api.post.fill_in_post_props.burn_on_read.bot_dm.app_error",
|
||||
"translation": "Burn-on-read posts are not allowed in direct messages with bots or AI agents."
|
||||
},
|
||||
{
|
||||
"id": "api.post.fill_in_post_props.burn_on_read.channel.app_error",
|
||||
"translation": "An error occurred while validating the channel for burn-on-read post."
|
||||
},
|
||||
{
|
||||
"id": "api.post.fill_in_post_props.burn_on_read.config.app_error",
|
||||
"translation": "Burn-on-read posts are not enabled. Please enable the feature flag and service setting."
|
||||
|
|
@ -2808,6 +2816,14 @@
|
|||
"id": "api.post.fill_in_post_props.burn_on_read.license.app_error",
|
||||
"translation": "Burn-on-read posts require an Enterprise Advanced license."
|
||||
},
|
||||
{
|
||||
"id": "api.post.fill_in_post_props.burn_on_read.self_dm.app_error",
|
||||
"translation": "Burn-on-read posts are not allowed when messaging yourself."
|
||||
},
|
||||
{
|
||||
"id": "api.post.fill_in_post_props.burn_on_read.user.app_error",
|
||||
"translation": "An error occurred while validating the user for burn-on-read post."
|
||||
},
|
||||
{
|
||||
"id": "api.post.fill_in_post_props.invalid_ai_generated_user.app_error",
|
||||
"translation": "The AI-generated user must be either the post creator or a bot."
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {renderHook} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {PostType} from '@mattermost/types/posts';
|
||||
|
||||
import {PostTypes} from 'mattermost-redux/constants/posts';
|
||||
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {
|
||||
isBurnOnReadEnabled,
|
||||
getBurnOnReadDurationMinutes,
|
||||
canUserSendBurnOnRead,
|
||||
} from 'selectors/burn_on_read';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
import type {PostDraft} from 'types/store/draft';
|
||||
|
||||
import useBurnOnRead from './use_burn_on_read';
|
||||
|
||||
// Mock the selectors
|
||||
jest.mock('selectors/burn_on_read', () => ({
|
||||
isBurnOnReadEnabled: jest.fn(),
|
||||
getBurnOnReadDurationMinutes: jest.fn(),
|
||||
canUserSendBurnOnRead: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
|
||||
getChannel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('mattermost-redux/selectors/entities/users', () => ({
|
||||
getCurrentUser: jest.fn(),
|
||||
getUser: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked selectors
|
||||
|
||||
describe('useBurnOnRead', () => {
|
||||
const mockHandleDraftChange = jest.fn();
|
||||
const mockFocusTextbox = jest.fn();
|
||||
|
||||
const createMockStore = (state: Partial<GlobalState> = {}) => ({
|
||||
getState: () => state,
|
||||
dispatch: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
replaceReducer: jest.fn(),
|
||||
[Symbol.observable]: jest.fn(),
|
||||
});
|
||||
|
||||
const createMockChannel = (type: 'O' | 'P' | 'D' | 'G', name?: string): Channel => ({
|
||||
id: 'channel-id',
|
||||
create_at: 0,
|
||||
update_at: 0,
|
||||
delete_at: 0,
|
||||
team_id: 'team-id',
|
||||
type,
|
||||
display_name: 'Test Channel',
|
||||
name: name || 'test-channel',
|
||||
header: '',
|
||||
purpose: '',
|
||||
last_post_at: 0,
|
||||
last_root_post_at: 0,
|
||||
creator_id: 'user-id',
|
||||
scheme_id: '',
|
||||
group_constrained: false,
|
||||
});
|
||||
|
||||
const createMockDraft = (type?: PostType): PostDraft => ({
|
||||
message: 'test message',
|
||||
fileInfos: [],
|
||||
uploadsInProgress: [],
|
||||
channelId: 'channel-id',
|
||||
rootId: '',
|
||||
type,
|
||||
props: {},
|
||||
createAt: 0,
|
||||
updateAt: 0,
|
||||
show: true,
|
||||
});
|
||||
|
||||
const wrapper = ({children}: {children: React.ReactNode}) => (
|
||||
<Provider store={createMockStore()}>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(isBurnOnReadEnabled as jest.Mock).mockReturnValue(true);
|
||||
(getBurnOnReadDurationMinutes as jest.Mock).mockReturnValue(10);
|
||||
(canUserSendBurnOnRead as jest.Mock).mockReturnValue(true);
|
||||
(getCurrentUser as jest.Mock).mockReturnValue({id: 'user-id', is_bot: false});
|
||||
});
|
||||
|
||||
describe('button visibility in different channel types', () => {
|
||||
it('should show burn-on-read button in direct messages (DM) with another user', () => {
|
||||
// DM with another user - channel name is user-id__other-user-id
|
||||
const dmChannel = createMockChannel('D', 'other-user-id__user-id');
|
||||
(getChannel as jest.Mock).mockReturnValue(dmChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
// DMs with another user should show the button
|
||||
expect(result.current.additionalControl).toBeDefined();
|
||||
});
|
||||
|
||||
it('should hide burn-on-read button in self-DMs', () => {
|
||||
// Self-DM - channel name is user-id__user-id
|
||||
const selfDMChannel = createMockChannel('D', 'user-id__user-id');
|
||||
(getChannel as jest.Mock).mockReturnValue(selfDMChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
// Self-DMs should hide the button
|
||||
expect(result.current.additionalControl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should hide burn-on-read button in DMs with bots/AI agents', () => {
|
||||
const {getUser} = require('mattermost-redux/selectors/entities/users');
|
||||
|
||||
// DM with a bot - channel name is user-id__bot-id
|
||||
const dmWithBotChannel = createMockChannel('D', 'bot-id__user-id');
|
||||
(getChannel as jest.Mock).mockReturnValue(dmWithBotChannel);
|
||||
(getUser as jest.Mock).mockReturnValue({id: 'bot-id', is_bot: true});
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
// DMs with bots should hide the button
|
||||
expect(result.current.additionalControl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show burn-on-read button in group messages (GM)', () => {
|
||||
const gmChannel = createMockChannel('G');
|
||||
(getChannel as jest.Mock).mockReturnValue(gmChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.additionalControl).toBeDefined();
|
||||
});
|
||||
|
||||
it('should show burn-on-read button in public channels', () => {
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.additionalControl).toBeDefined();
|
||||
});
|
||||
|
||||
it('should show burn-on-read button in private channels', () => {
|
||||
const privateChannel = createMockChannel('P');
|
||||
(getChannel as jest.Mock).mockReturnValue(privateChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.additionalControl).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('button visibility with feature flags', () => {
|
||||
it('should hide button when burn-on-read is disabled', () => {
|
||||
(isBurnOnReadEnabled as jest.Mock).mockReturnValue(false);
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.additionalControl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should hide button when user cannot send burn-on-read', () => {
|
||||
(canUserSendBurnOnRead as jest.Mock).mockReturnValue(false);
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.additionalControl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show button when user is a bot (bots can send BoR for OTP, integrations)', () => {
|
||||
(getCurrentUser as jest.Mock).mockReturnValue({id: 'bot-user-id', is_bot: true});
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.additionalControl).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('button visibility in threads', () => {
|
||||
it('should hide burn-on-read button in thread replies', () => {
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const draftWithRootId: PostDraft = {
|
||||
...createMockDraft(),
|
||||
rootId: 'root-post-id',
|
||||
};
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
draftWithRootId,
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.additionalControl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should hide button when channel is missing (fail-closed)', () => {
|
||||
(getChannel as jest.Mock).mockReturnValue(null);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
// Should hide button when channel is missing (fail-closed: if we can't validate, don't show)
|
||||
expect(result.current.additionalControl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should hide button when currentUser is missing (fail-closed principle)', () => {
|
||||
(getCurrentUser as jest.Mock).mockReturnValue(null);
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
// Should hide button when currentUser is missing (fail-closed: if we can't validate, don't show)
|
||||
expect(result.current.additionalControl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('label visibility', () => {
|
||||
it('should show label when burn-on-read is active', () => {
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const draftWithBoR = createMockDraft(PostTypes.BURN_ON_READ);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
draftWithBoR,
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.labels).toBeDefined();
|
||||
});
|
||||
|
||||
it('should hide label in thread replies even if burn-on-read is active', () => {
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const draftWithBoRAndRootId: PostDraft = {
|
||||
...createMockDraft(PostTypes.BURN_ON_READ),
|
||||
rootId: 'root-post-id',
|
||||
};
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
draftWithBoRAndRootId,
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.labels).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlers', () => {
|
||||
it('should provide handleBurnOnReadApply handler', () => {
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.handleBurnOnReadApply).toBeDefined();
|
||||
expect(typeof result.current.handleBurnOnReadApply).toBe('function');
|
||||
});
|
||||
|
||||
it('should provide handleRemoveBurnOnRead handler', () => {
|
||||
const publicChannel = createMockChannel('O');
|
||||
(getChannel as jest.Mock).mockReturnValue(publicChannel);
|
||||
|
||||
const {result} = renderHook(
|
||||
() => useBurnOnRead(
|
||||
createMockDraft(),
|
||||
mockHandleDraftChange,
|
||||
mockFocusTextbox,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
{wrapper},
|
||||
);
|
||||
|
||||
expect(result.current.handleRemoveBurnOnRead).toBeDefined();
|
||||
expect(typeof result.current.handleRemoveBurnOnRead).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,9 @@ import React, {useCallback, useMemo} from 'react';
|
|||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {PostTypes} from 'mattermost-redux/constants/posts';
|
||||
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentUser, getUser} from 'mattermost-redux/selectors/entities/users';
|
||||
import {getDirectChannelName, getUserIdFromChannelName, isDirectChannel} from 'mattermost-redux/utils/channel_utils';
|
||||
|
||||
import {
|
||||
isBurnOnReadEnabled,
|
||||
|
|
@ -18,6 +21,7 @@ import BurnOnReadTourTip from 'components/burn_on_read/burn_on_read_tour_tip';
|
|||
|
||||
import 'components/burn_on_read/burn_on_read_control.scss';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
import type {PostDraft} from 'types/store/draft';
|
||||
|
||||
/**
|
||||
|
|
@ -40,9 +44,43 @@ const useBurnOnRead = (
|
|||
showIndividualCloseButton = true,
|
||||
) => {
|
||||
const rootId = draft.rootId;
|
||||
const channelId = draft.channelId;
|
||||
const isEnabled = useSelector(isBurnOnReadEnabled);
|
||||
const durationMinutes = useSelector(getBurnOnReadDurationMinutes);
|
||||
const canSend = useSelector(canUserSendBurnOnRead);
|
||||
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
|
||||
const currentUser = useSelector(getCurrentUser);
|
||||
|
||||
// Burn-on-read is not allowed in self-DMs or DMs with bots (AI agents, plugins, etc.)
|
||||
const otherUserId = useMemo(() => {
|
||||
if (!channel || !currentUser || !isDirectChannel(channel)) {
|
||||
return null;
|
||||
}
|
||||
return getUserIdFromChannelName(currentUser.id, channel.name);
|
||||
}, [channel, currentUser]);
|
||||
|
||||
const otherUser = useSelector((state: GlobalState) => (otherUserId ? getUser(state, otherUserId) : null));
|
||||
|
||||
const isAllowedInChannel = useMemo(() => {
|
||||
if (!channel || !currentUser) {
|
||||
return false; // Fail-closed: if we can't validate, don't show the button
|
||||
}
|
||||
|
||||
// Check if it's a self-DM by comparing channel name with expected self-DM name
|
||||
if (isDirectChannel(channel)) {
|
||||
const selfDMName = getDirectChannelName(currentUser.id, currentUser.id);
|
||||
if (channel.name === selfDMName) {
|
||||
return false; // Block self-DMs
|
||||
}
|
||||
|
||||
// Block DMs with bots (AI agents, plugins, etc.)
|
||||
if (otherUser?.is_bot) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true; // Allow all other channel types
|
||||
}, [channel, currentUser, otherUser]);
|
||||
|
||||
const hasBurnOnReadSet = isEnabled && draft.type === PostTypes.BURN_ON_READ;
|
||||
|
||||
|
|
@ -73,7 +111,7 @@ const useBurnOnRead = (
|
|||
|
||||
// Button component with tour tip wrapper (in formatting bar)
|
||||
const additionalControl = useMemo(() =>
|
||||
(!rootId && isEnabled && canSend ? (
|
||||
(!rootId && isEnabled && canSend && isAllowedInChannel ? (
|
||||
<div
|
||||
key='burn-on-read-control-key'
|
||||
className='BurnOnReadControl'
|
||||
|
|
@ -90,7 +128,7 @@ const useBurnOnRead = (
|
|||
onTryItOut={() => handleBurnOnReadApply(true)}
|
||||
/>
|
||||
</div>
|
||||
) : undefined), [rootId, isEnabled, canSend, hasBurnOnReadSet, handleBurnOnReadApply, shouldShowPreview, durationMinutes]);
|
||||
) : undefined), [rootId, isEnabled, canSend, isAllowedInChannel, hasBurnOnReadSet, handleBurnOnReadApply, shouldShowPreview, durationMinutes]);
|
||||
|
||||
return {
|
||||
labels,
|
||||
|
|
|
|||
Loading…
Reference in a new issue