snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {duration, expect, test, testConfig} from '@mattermost/playwright-lib';
|
||||
|
||||
async function postToWebhook(webhookId: string, payload: Record<string, unknown>) {
|
||||
const response = await fetch(`${testConfig.baseURL}/hooks/${webhookId}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(duration.ten_sec),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook POST failed: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @objective Verify interactive attachment buttons remain visible across Indigo, Onyx, and Denim themes.
|
||||
*/
|
||||
test(
|
||||
'MM-T5672 displays attachment buttons correctly across premade themes',
|
||||
{tag: '@message_attachments'},
|
||||
async ({pw}) => {
|
||||
// # Post an attachment containing primary, danger, and default buttons
|
||||
const {adminClient, team, user} = await pw.initSetup();
|
||||
const channel = await adminClient.getChannelByName(team.id, 'town-square');
|
||||
const webhook = await adminClient.createIncomingWebhook({
|
||||
channel_id: channel.id,
|
||||
display_name: 'Theme buttons',
|
||||
});
|
||||
await postToWebhook(webhook.id, {
|
||||
attachments: [
|
||||
{
|
||||
text: 'Theme button test',
|
||||
actions: [
|
||||
{id: 'primary', name: 'Primary action', type: 'button', style: 'primary'},
|
||||
{id: 'danger', name: 'Danger action', type: 'button', style: 'danger'},
|
||||
{id: 'default', name: 'Default action', type: 'button'},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
const post = await channelsPage.getLastPost();
|
||||
|
||||
for (const theme of ['Indigo', 'Onyx', 'Denim'] as const) {
|
||||
// # Select and save the premade theme
|
||||
const settingsModal = await channelsPage.openSettings();
|
||||
const displaySettings = await settingsModal.openDisplayTab();
|
||||
await displaySettings.selectPremadeTheme(theme);
|
||||
await settingsModal.close();
|
||||
|
||||
// * Verify all attachment buttons remain visible
|
||||
await expect(post.container.getByRole('button', {name: 'Primary action'})).toBeVisible();
|
||||
await expect(post.container.getByRole('button', {name: 'Danger action'})).toBeVisible();
|
||||
await expect(post.container.getByRole('button', {name: 'Default action'})).toBeVisible();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Locator} from '@playwright/test';
|
||||
|
||||
import {expect, test, testConfig} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* Posts a JSON payload to an incoming webhook URL.
|
||||
*/
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy attachments are translated to mm_blocks; author/title live in markdown text blocks. */
|
||||
function mmBlocks(lastPost: {container: Locator}) {
|
||||
return lastPost.container.locator('.mm-blocks');
|
||||
}
|
||||
|
||||
async function expectMmBlocksAuthorName(lastPost: {container: Locator}, name: string) {
|
||||
const blocks = mmBlocks(lastPost);
|
||||
// Attachment translation renders author before title; title <p> also contains the link text via hasText.
|
||||
await expect(blocks.locator('p').first()).toHaveText(name);
|
||||
}
|
||||
|
||||
async function expectMmBlocksTitleLink(lastPost: {container: Locator}, title: string) {
|
||||
await expect(mmBlocks(lastPost).getByRole('link', {name: title})).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Message attachment special character decoding', () => {
|
||||
/**
|
||||
* @objective Verify that HTML entities in message attachment author_name and title
|
||||
* fields are decoded to their readable characters when rendered in the UI.
|
||||
* This ensures parity with the server-side Go html.UnescapeString behavior.
|
||||
*/
|
||||
test(
|
||||
'decodes HTML entities in attachment title and author_name from an incoming webhook',
|
||||
{tag: ['@smoke', '@message_attachments']},
|
||||
async ({pw}) => {
|
||||
const {team, user, adminClient} = await pw.initSetup();
|
||||
|
||||
const channels = await adminClient.getMyChannels(team.id);
|
||||
const townSquare = channels.find((ch) => ch.name === 'town-square');
|
||||
if (!townSquare) {
|
||||
throw new Error('Town Square channel not found');
|
||||
}
|
||||
|
||||
// # Create an incoming webhook for the channel
|
||||
const webhook = await adminClient.createIncomingWebhook({
|
||||
channel_id: townSquare.id,
|
||||
display_name: 'E2E Attachment Test Hook',
|
||||
});
|
||||
|
||||
// # Post a webhook payload with HTML-encoded entities in title and author_name
|
||||
await postToWebhook(webhook.id, {
|
||||
attachments: [
|
||||
{
|
||||
author_name: 'Bot (v2.1) & Integrations',
|
||||
title: 'Future Plan for Plugins (3rd party & core)',
|
||||
title_link: 'https://example.com',
|
||||
text: 'Attachment body text',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// # Log in and navigate to the channel
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Get the last post which should contain the webhook attachment
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await lastPost.toBeVisible();
|
||||
|
||||
await expect(mmBlocks(lastPost)).toBeVisible();
|
||||
|
||||
// * Verify the author name has decoded entities: ( → (, ) → ), & → &
|
||||
await expectMmBlocksAuthorName(lastPost, 'Bot (v2.1) & Integrations');
|
||||
|
||||
// * Verify the title has decoded entities
|
||||
await expectMmBlocksTitleLink(lastPost, 'Future Plan for Plugins (3rd party & core)');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'decodes numeric HTML entities in attachment title with a title_link',
|
||||
{tag: ['@message_attachments']},
|
||||
async ({pw}) => {
|
||||
const {team, user, adminClient} = await pw.initSetup();
|
||||
|
||||
const channels = await adminClient.getMyChannels(team.id);
|
||||
const townSquare = channels.find((ch) => ch.name === 'town-square');
|
||||
if (!townSquare) {
|
||||
throw new Error('Town Square channel not found');
|
||||
}
|
||||
|
||||
const webhook = await adminClient.createIncomingWebhook({
|
||||
channel_id: townSquare.id,
|
||||
display_name: 'E2E Numeric Entities Hook',
|
||||
});
|
||||
|
||||
// # Post with numeric entities: " ("), ' ('), : (:), [ ([), ] (])
|
||||
await postToWebhook(webhook.id, {
|
||||
attachments: [
|
||||
{
|
||||
title: '"All Hands" Meeting [Q1] - 9:00',
|
||||
title_link: 'https://example.com',
|
||||
text: 'Some body text',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await lastPost.toBeVisible();
|
||||
|
||||
await expect(mmBlocks(lastPost)).toBeVisible();
|
||||
|
||||
// * Verify the numeric entities are decoded in the title link
|
||||
await expectMmBlocksTitleLink(lastPost, '"All Hands" Meeting [Q1] - 9:00');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'decodes named HTML entities (< > " ') in author_name',
|
||||
{tag: ['@message_attachments']},
|
||||
async ({pw}) => {
|
||||
const {team, user, adminClient} = await pw.initSetup();
|
||||
|
||||
const channels = await adminClient.getMyChannels(team.id);
|
||||
const townSquare = channels.find((ch) => ch.name === 'town-square');
|
||||
if (!townSquare) {
|
||||
throw new Error('Town Square channel not found');
|
||||
}
|
||||
|
||||
const webhook = await adminClient.createIncomingWebhook({
|
||||
channel_id: townSquare.id,
|
||||
display_name: 'E2E Named Entities Hook',
|
||||
});
|
||||
|
||||
await postToWebhook(webhook.id, {
|
||||
attachments: [
|
||||
{
|
||||
author_name: 'CI <Build> "System" 'Owner'',
|
||||
text: 'Build results',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await lastPost.toBeVisible();
|
||||
|
||||
await expect(mmBlocks(lastPost)).toBeVisible();
|
||||
|
||||
// * Verify named entities are decoded in the author name
|
||||
await expectMmBlocksAuthorName(lastPost, 'CI <Build> "System" \'Owner\'');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'does not double-decode already-encoded entities in attachment fields',
|
||||
{tag: ['@message_attachments']},
|
||||
async ({pw}) => {
|
||||
const {team, user, adminClient} = await pw.initSetup();
|
||||
|
||||
const channels = await adminClient.getMyChannels(team.id);
|
||||
const townSquare = channels.find((ch) => ch.name === 'town-square');
|
||||
if (!townSquare) {
|
||||
throw new Error('Town Square channel not found');
|
||||
}
|
||||
|
||||
const webhook = await adminClient.createIncomingWebhook({
|
||||
channel_id: townSquare.id,
|
||||
display_name: 'E2E Double Encode Hook',
|
||||
});
|
||||
|
||||
// &lt; should decode only one level: & → &, leaving < as literal text
|
||||
await postToWebhook(webhook.id, {
|
||||
attachments: [
|
||||
{
|
||||
author_name: '&lt;safe&gt;',
|
||||
title: '&lt;safe&gt;',
|
||||
title_link: 'https://example.com',
|
||||
text: 'Testing double-encoding safety',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await lastPost.toBeVisible();
|
||||
|
||||
await expect(mmBlocks(lastPost)).toBeVisible();
|
||||
|
||||
// * Should show <safe> literally, not <safe>
|
||||
await expectMmBlocksAuthorName(lastPost, '<safe>');
|
||||
await expectMmBlocksTitleLink(lastPost, '<safe>');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'renders plain text without entities unchanged in attachment title and author_name',
|
||||
{tag: ['@message_attachments']},
|
||||
async ({pw}) => {
|
||||
const {team, user, adminClient} = await pw.initSetup();
|
||||
|
||||
const channels = await adminClient.getMyChannels(team.id);
|
||||
const townSquare = channels.find((ch) => ch.name === 'town-square');
|
||||
if (!townSquare) {
|
||||
throw new Error('Town Square channel not found');
|
||||
}
|
||||
|
||||
const webhook = await adminClient.createIncomingWebhook({
|
||||
channel_id: townSquare.id,
|
||||
display_name: 'E2E Plain Text Hook',
|
||||
});
|
||||
|
||||
await postToWebhook(webhook.id, {
|
||||
attachments: [
|
||||
{
|
||||
author_name: 'Simple Bot Name',
|
||||
title: 'A normal title with no special chars',
|
||||
title_link: 'https://example.com',
|
||||
text: 'Regular body',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await lastPost.toBeVisible();
|
||||
|
||||
await expect(mmBlocks(lastPost)).toBeVisible();
|
||||
|
||||
// * Verify text passes through unchanged
|
||||
await expectMmBlocksAuthorName(lastPost, 'Simple Bot Name');
|
||||
await expectMmBlocksTitleLink(lastPost, 'A normal title with no special chars');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'decodes realistic calendar plugin payload with parentheses and ampersands',
|
||||
{tag: ['@message_attachments']},
|
||||
async ({pw}) => {
|
||||
const {team, user, adminClient} = await pw.initSetup();
|
||||
|
||||
const channels = await adminClient.getMyChannels(team.id);
|
||||
const townSquare = channels.find((ch) => ch.name === 'town-square');
|
||||
if (!townSquare) {
|
||||
throw new Error('Town Square channel not found');
|
||||
}
|
||||
|
||||
const webhook = await adminClient.createIncomingWebhook({
|
||||
channel_id: townSquare.id,
|
||||
display_name: 'E2E Calendar Hook',
|
||||
});
|
||||
|
||||
// Realistic payload from a Google Calendar-like plugin
|
||||
await postToWebhook(webhook.id, {
|
||||
attachments: [
|
||||
{
|
||||
author_name: 'Google Calendar | via Plugin (v1.0)',
|
||||
title: 'Team Standup (Daily) - 9:00 AM',
|
||||
title_link: 'https://calendar.example.com/event/123',
|
||||
text: 'You have an upcoming event',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await lastPost.toBeVisible();
|
||||
|
||||
await expect(mmBlocks(lastPost)).toBeVisible();
|
||||
|
||||
// * Verify realistic plugin content is properly decoded
|
||||
await expectMmBlocksAuthorName(lastPost, 'Google Calendar | via Plugin (v1.0)');
|
||||
await expectMmBlocksTitleLink(lastPost, 'Team Standup (Daily) - 9:00 AM');
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user