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

* 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:
Pablo Vélez 2026-02-12 14:10:05 -05:00 committed by GitHub
parent 152d8eb845
commit e46bea673d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 775 additions and 2 deletions

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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."

View file

@ -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');
});
});
});

View file

@ -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,