Files
753a043946 MM-68754: Add silent post delivery for bots and integrations (#36771)
* MM-68754: Add silent post delivery for bots and integrations

  Bot, OAuth, incoming webhook, and plugin posts can request silent
  delivery via ?silent=true (REST), silent:true (webhook payload), or
  silent_notification:true (plugin Props). Silent posts are visible in
  the channel but produce no notifications, unread, or New Messages
  line. force_notification overrides silent. Non-integration senders
  requesting silent receive HTTP 403.

  Also tightens SanitizeProps to strip from_webhook, from_bot,
  from_oauth_app, from_plugin, force_notification, and
  silent_notification from client input. These props are now set
  server-side based on session and entry-point flags, preventing
  identity spoofing. Legitimate callers see no behavior change since
  the server already controlled these props at the response shape;
  the hardening only affects callers attempting to forge them.

* Drop redundant prop-bag silent_notification path from webhooks

* Address CodeRabbit feedback + rebase test fixup

* Update tests to use CreatePostFlags.FromIncomingWebhook instead of forging from_webhook prop

* update api documentation

* test fixes

* added additional tests

* review fixes, update comments

* code review changes, fix test

* fix formatting issues

* fix lint errors

* avoid backward compatibility issues, by no longer stripping the  Post properties.
will add them back in v12 when breaking changes are allowed

---------

Co-authored-by: Mattermost Build <[email protected]>
2026-07-09 17:02:22 -06:00

210 lines
9.7 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, testConfig} from '@mattermost/playwright-lib';
async function postToWebhook(webhookId: string, payload: Record<string, unknown>) {
const hookUrl = `${testConfig.baseURL}/hooks/${webhookId}`;
const resp = await fetch(hookUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!resp.ok) {
throw new Error(`Webhook POST failed: ${resp.status} ${await resp.text()}`);
}
}
function notificationText(n: {title?: string; body?: string}) {
return `${n.title ?? ''}\n${n.body ?? ''}`;
}
function channelHasMessage(posts: {posts: Record<string, {message?: string}>}, message: string) {
return Object.values(posts.posts).some((p) => p.message?.includes(message));
}
test.describe('Silent notification webhook delivery', () => {
/**
* @objective Verify integration posts with silent=true are delivered in-channel without unread or desktop notify side effects.
*
* @precondition
* - User is a member of a dedicated channel with an incoming webhook
* - User is viewing a different channel so webhook posts are not auto-marked read
* - Desktop notify level is mention-based (default); messages include @username
*/
test(
'incoming webhook silent post is visible without unread or desktop notification',
{tag: ['@notifications']},
async ({pw, headless, browserName}) => {
test.skip(
headless && browserName !== 'firefox' && browserName !== 'webkit',
'In headless mode, Notification stubbing is only reliable in Firefox (and WebKit when configured). Use npm run playwright-ui (headed), --project=firefox, or PW_HEADLESS=false.',
);
const {team, user, adminClient} = await pw.initSetup();
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'silent-hook',
displayName: 'Silent Hook Test',
unique: true,
}),
);
await adminClient.addToChannel(user.id, channel.id);
const webhook = await adminClient.createIncomingWebhook({
channel_id: channel.id,
display_name: 'Silent E2E Hook',
});
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Mark the webhook channel as read, then view another channel
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await channelsPage.goto(team.name, 'off-topic');
await channelsPage.toBeVisible();
await pw.stubNotification(page, 'granted');
const channelLink = channelsPage.sidebarLeft.container.locator(`#sidebarItem_${channel.name}`);
// # Post a normal webhook message with @mention to verify unread + notification baseline
const normalMessage = `normal webhook @${user.username} ${Date.now()}`;
await postToWebhook(webhook.id, {text: normalMessage});
await expect(channelLink).toHaveClass(/unread-title/, {timeout: 15000});
await expect
.poll(async () => (await page.evaluate(() => window.getNotifications())).length)
.toBeGreaterThan(0);
await channelsPage.sidebarLeft.goToItem(channel.name);
const normalPost = await channelsPage.getLastPost();
await normalPost.toContainText(normalMessage);
await channelsPage.goto(team.name, 'off-topic');
await channelsPage.toBeVisible();
await expect(channelLink).not.toHaveClass(/unread-title/);
const syncChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'sync-pulse',
displayName: 'Sync Pulse',
unique: true,
}),
);
await adminClient.addToChannel(user.id, syncChannel.id);
const syncChannelLink = channelsPage.sidebarLeft.container.locator(`#sidebarItem_${syncChannel.name}`);
await pw.clearCapturedNotifications(page);
// # Post silent webhook, then a normal post on another channel to flush the WS pipeline (FIFO)
const silentMessage = `silent webhook @${user.username} ${Date.now()}`;
await postToWebhook(webhook.id, {text: silentMessage, silent: true});
await expect
.poll(async () => channelHasMessage(await adminClient.getPosts(channel.id, 0, 30), silentMessage))
.toBe(true);
const pulseMessage = `pulse ${Date.now()}`;
await adminClient.createPost({channel_id: syncChannel.id, message: pulseMessage});
await expect(syncChannelLink).toHaveClass(/unread-title/, {timeout: 15000});
await expect(channelLink).not.toHaveClass(/unread-title/);
const notificationsAfterSilent = await page.evaluate(() => window.getNotifications());
expect(notificationsAfterSilent.some((n) => notificationText(n).includes(silentMessage))).toBe(false);
// * Silent post is still visible; no New Messages separator (final assertions — goToItem marks viewed)
await channelsPage.sidebarLeft.goToItem(channel.name);
const silentPost = await channelsPage.getLastPost();
await silentPost.toContainText(silentMessage);
await expect(page.locator('[data-testid="NotificationSeparator"]')).not.toBeVisible();
},
);
/**
* @objective Verify a user @mentioned in a silent webhook post does not receive a desktop notification.
*/
test(
'silent webhook @mention does not desktop-notify the mentioned user',
{tag: ['@notifications']},
async ({pw, headless, browserName}) => {
test.skip(
headless && browserName !== 'firefox' && browserName !== 'webkit',
'Desktop notification stubbing is only reliable in headless Firefox and WebKit.',
);
const {team, user, adminClient} = await pw.initSetup();
const mentionedUser = await pw.createNewUserProfile(adminClient);
await adminClient.addToTeam(team.id, mentionedUser.id);
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'silent-mention',
displayName: 'Silent Mention Test',
unique: true,
}),
);
await adminClient.addToChannel(user.id, channel.id);
await adminClient.addToChannel(mentionedUser.id, channel.id);
const webhook = await adminClient.createIncomingWebhook({
channel_id: channel.id,
display_name: 'Silent Mention Hook',
});
const {channelsPage: posterChannelsPage} = await pw.testBrowser.login(user);
await posterChannelsPage.goto(team.name, channel.name);
await posterChannelsPage.toBeVisible();
await posterChannelsPage.goto(team.name, 'off-topic');
await posterChannelsPage.toBeVisible();
const {page: mentioneePage, channelsPage: mentioneeChannelsPage} =
await pw.testBrowser.login(mentionedUser);
await mentioneeChannelsPage.goto(team.name, 'off-topic');
await mentioneeChannelsPage.toBeVisible();
await pw.stubNotification(mentioneePage, 'granted');
const channelLink = mentioneeChannelsPage.sidebarLeft.container.locator(`#sidebarItem_${channel.name}`);
// # Baseline: normal @mention webhook should notify the mentioned user and mark channel unread
const normalMessage = `normal mention @${mentionedUser.username} ${Date.now()}`;
await postToWebhook(webhook.id, {text: normalMessage});
await expect(channelLink).toHaveClass(/unread-title/, {timeout: 15000});
await expect
.poll(async () => (await mentioneePage.evaluate(() => window.getNotifications())).length)
.toBeGreaterThan(0);
// # Mark the channel read by opening it, then return to off-topic for a clean baseline
await mentioneeChannelsPage.sidebarLeft.goToItem(channel.name);
await mentioneeChannelsPage.toBeVisible();
await mentioneeChannelsPage.goto(team.name, 'off-topic');
await mentioneeChannelsPage.toBeVisible();
await expect(channelLink).not.toHaveClass(/unread-title/);
await pw.clearCapturedNotifications(mentioneePage);
// # Silent @mention should not notify the mentioned user nor mark the channel unread
const silentMessage = `silent mention @${mentionedUser.username} ${Date.now()}`;
await postToWebhook(webhook.id, {text: silentMessage, silent: true});
await expect
.poll(async () => channelHasMessage(await adminClient.getPosts(channel.id, 0, 30), silentMessage))
.toBe(true);
await expect
.poll(async () => (await mentioneePage.evaluate(() => window.getNotifications())).length)
.toBe(0);
await expect(channelLink).not.toHaveClass(/unread-title/);
const notificationsAfterSilent = await mentioneePage.evaluate(() => window.getNotifications());
expect(notificationsAfterSilent.some((n) => notificationText(n).includes(silentMessage))).toBe(false);
},
);
});