Address coderabbit comments

This commit is contained in:
Daniel Espino 2026-05-25 14:53:25 +02:00
parent 8975ec3cf9
commit d1290a80a9
28 changed files with 1628 additions and 45 deletions

View file

@ -62,6 +62,8 @@ describe('Collapsed Reply Threads', () => {
cy.postMessage('/poll "Do you like https://mattermost.com?"');
cy.getLastPostId().then((pollId) => {
const threadAuthorName = user1.nickname || user1.username;
// # Post a reply on the POLL to create a thread and follow
cy.postMessageAs({sender: user1, message: MESSAGES.SMALL, channelId: testChannel.id, rootId: pollId});
@ -75,7 +77,7 @@ describe('Collapsed Reply Threads', () => {
// * Text in ThreadItem should say 'username: Do you like https://mattermost.com?'
cy.get('.ThreadItem').last().within(() => {
cy.contains(user1.nickname + ': Do you like https://mattermost.com?').should('be.visible');
cy.contains(threadAuthorName + ': Do you like https://mattermost.com?').should('be.visible');
cy.contains('Total votes: 1').should('be.visible');
});
@ -96,7 +98,7 @@ describe('Collapsed Reply Threads', () => {
// * Text in ThreadItem should say 'username: Do you like https://mattermost.com?'
cy.get('.ThreadItem').last().within(() => {
cy.contains(user1.nickname + ': Do you like https://mattermost.com?').should('be.visible');
cy.contains(threadAuthorName + ': Do you like https://mattermost.com?').should('be.visible');
cy.contains('This poll has ended. The results are:').should('be.visible');
});

View file

@ -79,7 +79,11 @@ describe('Incoming webhook', () => {
attachments: [{image_url: 'https://cdn.pixabay.com/photo/2017/10/10/22/24/wide-format-2839089_960_720.jpg'}],
};
cy.postIncomingWebhook({url: hookUrl, data: payload});
cy.get('.mm-blocks-image').should('be.visible');
cy.getLastPostId().then((postId) => {
cy.get(`#post_${postId}`).within(() => {
cy.get('.mm-blocks-image').should('be.visible');
});
});
});
});
});

View file

@ -540,7 +540,10 @@ func TestPostAction(t *testing.T) {
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = "http://127.1.1.1"
// Unreachable localhost port fails immediately (connection refused) instead of
// waiting for OutgoingIntegrationRequestsTimeout on black-holed addresses.
*cfg.ServiceSettings.SiteURL = "http://127.0.0.1:1"
cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = new(int64(1))
})
interactivePostSiteURL := model.Post{
@ -562,7 +565,7 @@ func TestPostAction(t *testing.T) {
"s": "foo",
"n": 3,
},
URL: "http://127.1.1.1/plugins/myplugin/myaction",
URL: "http://127.0.0.1:1/plugins/myplugin/myaction",
},
},
},
@ -2804,6 +2807,9 @@ func TestCreateWebhookPostKeepsMmBlocksActions(t *testing.T) {
post, appErr := th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, "hello", "user", "http://iconurl", "",
model.StringInterface{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "button", "text": "Go", "action_id": "actx"},
},
model.PostPropsMmBlocksActions: inline,
},
"", "", nil)
@ -2821,6 +2827,73 @@ func TestCreateWebhookPostKeepsMmBlocksActions(t *testing.T) {
assert.Equal(t, "http://127.0.0.1/plugins/myplugin/x", spec.URL)
}
// TestCreateWebhookPostKeepsMmBlocksActionsOnInteractiveSplit verifies split
// webhook posts persist mm_blocks_actions on the chunk that carries mm_blocks.
// CreateWebhookPost returns the first split (typically the leading message chunk).
func TestCreateWebhookPostKeepsMmBlocksActionsOnInteractiveSplit(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true })
hook, hookErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id})
require.Nil(t, hookErr)
defer func() {
_ = th.App.DeleteIncomingWebhook(hook.Id)
}()
inline := buildMmBlocksActionsProp(
"actx",
"http://127.0.0.1/plugins/myplugin/x",
nil,
)
marker := "mm-blocks-split-" + model.NewId()
longMessage := marker + strings.Repeat("x", th.App.MaxPostSize()+100)
returned, appErr := th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, longMessage, "user", "http://iconurl", "",
model.StringInterface{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "text", "text": "interactive body"},
map[string]any{"type": "button", "text": "Go", "action_id": "actx"},
},
model.PostPropsMmBlocksActions: inline,
},
"", "", nil)
require.Nil(t, appErr)
require.True(t, strings.HasPrefix(returned.Message, marker))
require.Nil(t, returned.GetProp(model.PostPropsMmBlocks))
require.Nil(t, returned.GetProp(model.PostPropsMmBlocksActions))
list, listErr := th.App.GetPosts(th.Context, th.BasicChannel.Id, 0, 20)
require.Nil(t, listErr)
var mmBlocksPost *model.Post
messageChunks := 0
for _, p := range list.Posts {
if p.Message != "" && strings.Contains(longMessage, p.Message) {
messageChunks++
}
if p.GetProp(model.PostPropsMmBlocks) != nil {
mmBlocksPost = p
}
}
require.Greater(t, messageChunks, 1, "message should be split into multiple posts")
require.NotNil(t, mmBlocksPost)
require.NotEqual(t, returned.Id, mmBlocksPost.Id, "interactive props should be on a later split, not the returned first chunk")
stored, err := th.App.Srv().Store().Post().GetSingle(th.Context, mmBlocksPost.Id, false)
require.NoError(t, err)
require.NotNil(t, stored.GetProp(model.PostPropsMmBlocksActions),
"mm_blocks_actions must be on the post that carries mm_blocks")
for _, p := range list.Posts {
if p.GetProp(model.PostPropsMmBlocksActions) != nil {
assert.Equal(t, mmBlocksPost.Id, p.Id, "only the mm_blocks post should keep mm_blocks_actions")
}
}
}
func TestSendEphemeralPostEncryptsMmBlocksActionsCookie(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)

View file

@ -1589,6 +1589,56 @@ func TestGetImagesForPost(t *testing.T) {
assert.Equal(t, images, map[string]*model.PostImage{})
})
t.Run("with message attachment image URLs", func(t *testing.T) {
th := Setup(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1"
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
file, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
w.Header().Set("Content-Type", "image/png")
_, err = w.Write(file)
require.NoError(t, err)
}))
defer server.Close()
imageURL := server.URL + "/attachment.png"
thumbURL := server.URL + "/thumb.png"
authorIconURL := server.URL + "/author.png"
footerIconURL := server.URL + "/footer.png"
post := &model.Post{
Metadata: &model.PostMetadata{},
Props: model.StringInterface{
model.PostPropsAttachments: []*model.MessageAttachment{
{
ImageURL: imageURL,
ThumbURL: thumbURL,
AuthorIcon: authorIconURL,
FooterIcon: footerIconURL,
},
},
},
}
images := th.App.getImagesForPost(th.Context, post, false)
expected := &model.PostImage{
Format: "png",
Width: 408,
Height: 336,
}
assert.Equal(t, map[string]*model.PostImage{
imageURL: expected,
thumbURL: expected,
authorIconURL: expected,
footerIconURL: expected,
}, images)
})
t.Run("skips all when unsafe links including interactive props", func(t *testing.T) {
th := Setup(t)
@ -1597,7 +1647,7 @@ func TestGetImagesForPost(t *testing.T) {
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
t.Fatalf("unexpected HTTP request to test server: %s %s", r.Method, r.URL.String())
}))
t.Cleanup(server.Close)

View file

@ -234,15 +234,104 @@ func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType s
return &hookResp, nil
}
func webhookSplitDeferredProp(key string) bool {
switch key {
case model.PostPropsAttachments,
model.PostPropsMmBlocks,
model.PostPropsBlockKitBlocks,
model.PostPropsAdaptiveCards,
model.PostPropsMmBlocksActions:
return true
default:
return false
}
}
func webhookPropsJSONArray(raw any) ([]any, bool) {
arr, ok := raw.([]any)
return arr, ok
}
func splitWebhookPostPropsTooLarge() *model.AppError {
return model.NewAppError("splitWebhookPost", "web.incoming_webhook.split_props_length.app_error", map[string]any{"Max": model.PostPropsMaxUserRunes}, "", http.StatusBadRequest)
}
func refreshSplitInteractiveActions(split *model.Post, allActions any) {
if allActions == nil {
return
}
model.RefreshInteractiveActionsOnPost(split, allActions)
}
// cloneWebhookSplitPost returns a split post with a deep-copied props map. Post.Clone only
// shallow-copies Props, so message chunks must not share one map when refresh updates actions.
func cloneWebhookSplitPost(p *model.Post) *model.Post {
split := p.Clone()
if props := p.GetProps(); len(props) > 0 {
newProps := make(map[string]any, len(props))
maps.Copy(newProps, props)
split.SetProps(newProps)
} else {
split.SetProps(make(map[string]any))
}
return split
}
// distributeWebhookJSONArrayProp appends each element of a JSON array prop onto webhook post
// splits, mirroring message attachment distribution.
func distributeWebhookJSONArrayProp(splits []*model.Post, base *model.Post, propKey string, items []any, allActions any) ([]*model.Post, *model.AppError) {
for _, item := range items {
for {
lastSplit := splits[len(splits)-1]
newProps := make(map[string]any)
maps.Copy(newProps, lastSplit.GetProps())
orig, _ := newProps[propKey].([]any)
newProps[propKey] = append(orig, item)
candidate := lastSplit.Clone()
candidate.SetProps(newProps)
refreshSplitInteractiveActions(candidate, allActions)
if utf8.RuneCountInString(model.StringInterfaceToJSON(candidate.GetProps())) <= model.PostPropsMaxUserRunes {
lastSplit.SetProps(candidate.GetProps())
break
}
if len(orig) > 0 {
splits = append(splits, cloneWebhookSplitPost(base))
continue
}
return nil, splitWebhookPostPropsTooLarge()
}
}
return splits, nil
}
func validateWebhookPostInteractiveActions(post *model.Post) *model.AppError {
if err := model.ValidateInteractiveActionsForWebhook(post); err != nil {
return model.NewAppError("CreateWebhookPost", "api.context.invalid_body.app_error", nil, err.Error(), http.StatusBadRequest)
}
return nil
}
func splitWebhookPost(post *model.Post, maxPostSize int) ([]*model.Post, *model.AppError) {
// Fast path: message and full props already fit one post. Pairing was validated
// before split, so mm_blocks_actions need not be subset/refreshed here.
if utf8.RuneCountInString(post.Message) <= maxPostSize {
if utf8.RuneCountInString(model.StringInterfaceToJSON(post.GetProps())) <= model.PostPropsMaxUserRunes {
return []*model.Post{post.Clone()}, nil
}
}
splits := make([]*model.Post, 0)
remainingText := post.Message
mmBlocksActions := post.GetProp(model.PostPropsMmBlocksActions)
base := post.Clone()
base.Message = ""
base.SetProps(make(map[string]any))
for k, v := range post.GetProps() {
if k != model.PostPropsAttachments {
if !webhookSplitDeferredProp(k) {
base.AddProp(k, v)
}
}
@ -252,7 +341,7 @@ func splitWebhookPost(post *model.Post, maxPostSize int) ([]*model.Post, *model.
}
for utf8.RuneCountInString(remainingText) > maxPostSize {
split := base.Clone()
split := cloneWebhookSplitPost(base)
x := 0
for index := range remainingText {
x++
@ -262,11 +351,19 @@ func splitWebhookPost(post *model.Post, maxPostSize int) ([]*model.Post, *model.
break
}
}
refreshSplitInteractiveActions(split, mmBlocksActions)
if utf8.RuneCountInString(model.StringInterfaceToJSON(split.GetProps())) > model.PostPropsMaxUserRunes {
return nil, splitWebhookPostPropsTooLarge()
}
splits = append(splits, split)
}
split := base.Clone()
split := cloneWebhookSplitPost(base)
split.Message = remainingText
refreshSplitInteractiveActions(split, mmBlocksActions)
if utf8.RuneCountInString(model.StringInterfaceToJSON(split.GetProps())) > model.PostPropsMaxUserRunes {
return nil, splitWebhookPostPropsTooLarge()
}
splits = append(splits, split)
attachments, _ := post.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment)
@ -287,8 +384,7 @@ func splitWebhookPost(post *model.Post, maxPostSize int) ([]*model.Post, *model.
}
if len(origAttachments) > 0 {
newSplit := base.Clone()
splits = append(splits, newSplit)
splits = append(splits, cloneWebhookSplitPost(base))
continue
}
@ -310,6 +406,16 @@ func splitWebhookPost(post *model.Post, maxPostSize int) ([]*model.Post, *model.
}
}
for _, propKey := range []string{model.PostPropsMmBlocks, model.PostPropsBlockKitBlocks, model.PostPropsAdaptiveCards} {
if items, ok := webhookPropsJSONArray(post.GetProp(propKey)); ok && len(items) > 0 {
var err *model.AppError
splits, err = distributeWebhookJSONArrayProp(splits, base, propKey, items, mmBlocksActions)
if err != nil {
return nil, err
}
}
}
return splits, nil
}
@ -373,18 +479,28 @@ func (a *App) CreateWebhookPost(rctx request.CTX, userID string, channel *model.
}
}
if err := validateWebhookPostInteractiveActions(post); err != nil {
return nil, err
}
splits, err := splitWebhookPost(post, a.MaxPostSize())
if err != nil {
return nil, err
}
for _, split := range splits {
if _, _, err := a.CreatePost(rctx, split, channel, model.CreatePostFlags{AllowMmBlocksActions: true}); err != nil {
var returnPost *model.Post
for i, split := range splits {
flags := model.CreatePostFlags{AllowMmBlocksActions: split.GetProp(model.PostPropsMmBlocksActions) != nil}
created, _, err := a.CreatePost(rctx, split, channel, flags)
if err != nil {
return nil, model.NewAppError("CreateWebhookPost", "api.post.create_webhook_post.creating.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if i == 0 {
returnPost = created
}
}
return splits[0], nil
return returnPost, nil
}
func (a *App) CreateIncomingWebhookForChannel(creatorId string, channel *model.Channel, hook *model.IncomingWebhook) (*model.IncomingWebhook, *model.AppError) {

View file

@ -660,11 +660,44 @@ func TestCreateWebhookPostLinks(t *testing.T) {
}
}
func TestValidateWebhookPostInteractiveActions(t *testing.T) {
mainHelper.Parallel(t)
t.Run("orphan mm_blocks_actions", func(t *testing.T) {
post := &model.Post{
Message: "foo",
Props: map[string]any{
model.PostPropsMmBlocksActions: map[string]any{
"act": map[string]any{"type": "external", "url": "http://example.com"},
},
},
}
err := validateWebhookPostInteractiveActions(post)
require.NotNil(t, err)
})
t.Run("button without mm_blocks_actions", func(t *testing.T) {
post := &model.Post{
Message: "foo",
Props: map[string]any{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "button", "text": "Go", "action_id": "act"},
},
},
}
err := validateWebhookPostInteractiveActions(post)
require.NotNil(t, err)
})
}
func TestSplitWebhookPost(t *testing.T) {
mainHelper.Parallel(t)
type TestCase struct {
Post *model.Post
Expected []*model.Post
// ExpectSplitError: props/message cannot be split to fit.
ExpectSplitError bool
}
maxPostSize := 10000
@ -735,20 +768,147 @@ func TestSplitWebhookPost(t *testing.T) {
"foo": strings.Repeat("x", model.PostPropsMaxUserRunes*2),
},
},
ExpectSplitError: true,
},
"NoSplitFastPath": {
Post: &model.Post{
Message: "hello",
Props: map[string]any{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "button", "text": "Go", "action_id": "act"},
},
model.PostPropsMmBlocksActions: map[string]any{
"act": map[string]any{"type": "external", "url": "http://example.com"},
},
},
},
Expected: []*model.Post{
{
Message: "hello",
Props: map[string]any{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "button", "text": "Go", "action_id": "act"},
},
model.PostPropsMmBlocksActions: map[string]any{
"act": map[string]any{"type": "external", "url": "http://example.com"},
},
},
},
},
},
"MessageMmactionWithActions": {
// Message exceeds maxPostSize so splitWebhookPost uses the slow path; interactive
// content lands on the final chunk where the mmaction link lives.
Post: &model.Post{
Message: strings.Repeat("x", maxPostSize) + "Click [go](mmaction://go1)",
Props: map[string]any{
model.PostPropsMmBlocksActions: map[string]any{
"go1": map[string]any{"type": "external", "url": "http://example.com"},
},
},
},
Expected: []*model.Post{
{
Message: strings.Repeat("x", maxPostSize),
},
{
Message: "Click [go](mmaction://go1)",
Props: map[string]any{
model.PostPropsMmBlocksActions: map[string]any{
"go1": map[string]any{"type": "external", "url": "http://example.com"},
},
},
},
},
},
"BlockKitWithActions": {
Post: &model.Post{
Message: strings.Repeat("x", maxPostSize) + "hi",
Props: map[string]any{
model.PostPropsBlockKitBlocks: []any{
map[string]any{
"type": "actions",
"elements": []any{
map[string]any{
"type": "button",
"text": map[string]any{"type": "plain_text", "text": "Go"},
"action_id": "bk1",
},
},
},
},
model.PostPropsMmBlocksActions: map[string]any{
"bk1": map[string]any{"type": "external", "url": "http://example.com"},
},
},
},
Expected: []*model.Post{
{
Message: strings.Repeat("x", maxPostSize),
},
{
Message: "hi",
Props: map[string]any{
model.PostPropsBlockKitBlocks: []any{
map[string]any{
"type": "actions",
"elements": []any{
map[string]any{
"type": "button",
"text": map[string]any{"type": "plain_text", "text": "Go"},
"action_id": "bk1",
},
},
},
},
model.PostPropsMmBlocksActions: map[string]any{
"bk1": map[string]any{"type": "external", "url": "http://example.com"},
},
},
},
},
},
"LongPostWithMmBlocks": {
Post: &model.Post{
Message: strings.Repeat("本", maxPostSize*3/2),
Props: map[string]any{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "text", "text": "block-a"},
map[string]any{"type": "text", "text": "block-b"},
},
},
},
Expected: []*model.Post{
{
Message: strings.Repeat("本", maxPostSize),
},
{
Message: strings.Repeat("本", maxPostSize/2),
Props: map[string]any{
model.PostPropsMmBlocks: []any{
map[string]any{"type": "text", "text": "block-a"},
map[string]any{"type": "text", "text": "block-b"},
},
},
},
},
},
} {
t.Run(name, func(t *testing.T) {
splits, err := splitWebhookPost(tc.Post, maxPostSize)
if tc.Expected == nil {
if tc.ExpectSplitError {
require.NotNil(t, err)
} else {
require.Nil(t, err)
return
}
require.Nil(t, err)
assert.Equal(t, len(tc.Expected), len(splits))
for i, split := range splits {
if i < len(tc.Expected) {
assert.Equal(t, tc.Expected[i].Message, split.Message)
assert.Equal(t, tc.Expected[i].GetProp(model.PostPropsAttachments), split.GetProp(model.PostPropsAttachments))
assert.Equal(t, tc.Expected[i].GetProp(model.PostPropsMmBlocks), split.GetProp(model.PostPropsMmBlocks))
assert.Equal(t, tc.Expected[i].GetProp(model.PostPropsBlockKitBlocks), split.GetProp(model.PostPropsBlockKitBlocks))
assert.Equal(t, tc.Expected[i].GetProp(model.PostPropsMmBlocksActions), split.GetProp(model.PostPropsMmBlocksActions))
}
}
})

View file

@ -91,10 +91,17 @@ func (o *Post) PostActionPreserveState() PostActionPreserve {
if o.RootId != "" {
rootPostId = o.RootId
}
var originalProps map[string]any
if props := o.GetProps(); props != nil {
originalProps = make(map[string]any, len(props))
for k, v := range props {
originalProps[k] = v
}
}
return PostActionPreserve{
Retain: retain,
Remove: remove,
OriginalProps: o.GetProps(),
OriginalProps: originalProps,
OriginalIsPinned: o.IsPinned,
OriginalHasReactions: o.HasReactions,
RootPostId: rootPostId,
@ -1024,8 +1031,12 @@ var mmBlocksActionIDRegex = regexp.MustCompile(`^[A-Za-z0-9]+$`)
// expected shape and bounds. Each entry must coerce to a valid spec via
// mmBlocksEntryMapToSpec.
func ValidateMmBlocksActions(o *Post) error {
referenced := CollectInteractiveActionIDsFromPost(o)
raw := o.GetProp(PostPropsMmBlocksActions)
if raw == nil {
if len(referenced) > 0 {
return fmt.Errorf("interactive content requires mm_blocks_actions")
}
return nil
}
actions, ok := coerceToStringAnyMap(raw)
@ -1074,7 +1085,7 @@ func ValidateMmBlocksActions(o *Post) error {
}
}
}
return nil
return validateMmBlocksActionsPairing(o, actions)
}
// ValidateActionQuery bounds the size of user-supplied per-click query

View file

@ -1701,6 +1701,15 @@ func TestPost_PostActionPreserveState(t *testing.T) {
p := &Post{Id: "replyid", RootId: "rootid"}
assert.Equal(t, "rootid", p.PostActionPreserveState().RootPostId)
})
t.Run("original props snapshot", func(t *testing.T) {
p := &Post{Props: StringInterface{"k": "v"}}
state := p.PostActionPreserveState()
p.Props["k"] = "mutated"
p.Props["new"] = "y"
assert.Equal(t, "v", state.OriginalProps["k"])
assert.NotContains(t, state.OriginalProps, "new")
})
}
func TestNormalizePostActionIntegrationContext(t *testing.T) {
@ -1796,6 +1805,21 @@ func mmBlocksExternalEntry(url string, context map[string]any) map[string]any {
return entry
}
// ensureMmBlocksReferenceActions adds mm_blocks buttons so each mm_blocks_actions key is referenced.
func ensureMmBlocksReferenceActions(p *Post) {
actions, ok := coerceToStringAnyMap(p.GetProp(PostPropsMmBlocksActions))
if !ok {
return
}
blocks := make([]any, 0, len(actions))
for id := range actions {
blocks = append(blocks, map[string]any{
"type": "button", "text": "Btn", "action_id": id,
})
}
p.AddProp(PostPropsMmBlocks, blocks)
}
func TestGetMmBlocksActionSpec(t *testing.T) {
t.Run("prop absent returns nil", func(t *testing.T) {
p := &Post{}
@ -1890,6 +1914,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
"btn2": mmBlocksExternalEntry("/plugins/myplugin/action", nil),
"btn3": mmBlocksExternalEntry("plugins/myplugin/action", nil),
})
ensureMmBlocksReferenceActions(p)
assert.NoError(t, ValidateMmBlocksActions(p))
})
@ -1900,6 +1925,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
}
p := &Post{}
p.AddProp(PostPropsMmBlocksActions, actions)
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds maximum")
@ -1921,6 +1947,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
key: mmBlocksExternalEntry("http://example.com/hook", nil),
})
ensureMmBlocksReferenceActions(p)
assert.NoError(t, ValidateMmBlocksActions(p))
})
@ -1960,6 +1987,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": mmBlocksExternalEntry("", nil),
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "non-empty URL")
@ -1973,6 +2001,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": mmBlocksExternalEntry("/plugins/../../../etc/passwd", nil),
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "path traversal")
@ -1983,6 +2012,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": mmBlocksExternalEntry("/plugins/myplugin/..", nil),
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "path traversal")
@ -2004,6 +2034,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": mmBlocksExternalEntry(encoded, nil),
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err, "url=%q must be rejected", encoded)
assert.Contains(t, err.Error(), "path traversal", "url=%q", encoded)
@ -2015,6 +2046,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": map[string]any{"url": "http://example.com/hook"},
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid type or shape")
@ -2028,6 +2060,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
"url": "http://example.com/hook",
},
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid type or shape")
@ -2038,6 +2071,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": "not-an-object",
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "must be an object")
@ -2048,6 +2082,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": mmBlocksExternalEntry("javascript://alert(1)", nil),
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "valid integration URL")
@ -2058,6 +2093,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": mmBlocksExternalEntry("http://legit.com", nil),
})
ensureMmBlocksReferenceActions(p)
assert.NoError(t, ValidateMmBlocksActions(p))
})
@ -2066,6 +2102,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": mmBlocksExternalEntry("/plugins/foo", nil),
})
ensureMmBlocksReferenceActions(p)
assert.NoError(t, ValidateMmBlocksActions(p))
})
@ -2090,6 +2127,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
"query": query,
},
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "static query")
@ -2104,6 +2142,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
"query": map[string]any{"k": strings.Repeat("a", MaxActionQueryValueLength+1)},
},
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "static query")
@ -2122,6 +2161,7 @@ func TestValidateMmBlocksActions(t *testing.T) {
"context": ctx,
},
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "context exceeds maximum")
@ -2136,10 +2176,58 @@ func TestValidateMmBlocksActions(t *testing.T) {
"context": map[string]any{strings.Repeat("a", MaxActionQueryKeyLength+1): "v"},
},
})
ensureMmBlocksReferenceActions(p)
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "context key exceeds")
})
t.Run("orphan mm_blocks_actions entry is rejected", func(t *testing.T) {
p := &Post{}
p.AddProp(PostPropsMmBlocks, []any{
map[string]any{"type": "button", "text": "Go", "action_id": "needed"},
})
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"needed": mmBlocksExternalEntry("http://example.com/hook", nil),
"unused": mmBlocksExternalEntry("http://example.com/other", nil),
})
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "not referenced")
})
t.Run("missing mm_blocks_actions entry is rejected", func(t *testing.T) {
p := &Post{}
p.AddProp(PostPropsMmBlocks, []any{
map[string]any{"type": "button", "text": "Go", "action_id": "needed"},
})
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"other": mmBlocksExternalEntry("http://example.com/hook", nil),
})
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "missing entry")
})
t.Run("mm_blocks_actions without interactive references is rejected", func(t *testing.T) {
p := &Post{}
p.AddProp(PostPropsMmBlocksActions, map[string]any{
"btn1": mmBlocksExternalEntry("http://example.com/hook", nil),
})
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "must only define actions referenced")
})
t.Run("interactive control without mm_blocks_actions is rejected", func(t *testing.T) {
p := &Post{}
p.AddProp(PostPropsMmBlocks, []any{
map[string]any{"type": "button", "text": "Go", "action_id": "act"},
})
err := ValidateMmBlocksActions(p)
require.Error(t, err)
assert.Contains(t, err.Error(), "requires mm_blocks_actions")
})
}
func TestStripActionIntegrations_MmBlocksActions(t *testing.T) {

View file

@ -769,10 +769,10 @@ func (o *Post) AllStrings() []string {
}
// InteractiveBlocksImageURLs collects non-markdown image URLs from props.mm_blocks, props.blocks
// (Block Kit), and props.cards (Adaptive Cards): direct mm_blocks image URLs, Block Kit image blocks,
// and Adaptive Card Image elements. Markdown ![alt](url) in interactive text is not included; merge
// with URLs from Post.AllStrings separately. Link preview restrictions (e.g. RestrictLinkPreviews) are
// not applied here; callers enforce policy when fetching metadata.
// (Block Kit), props.cards (Adaptive Cards), and message attachments (image_url, thumb_url, author_icon, footer_icon).
// Direct mm_blocks image URLs, Block Kit image blocks, and Adaptive Card Image elements are included.
// Markdown ![alt](url) in interactive text is not included; merge with URLs from Post.AllStrings separately.
// Link preview restrictions (e.g. RestrictLinkPreviews) are not applied here; callers enforce policy when fetching metadata.
func (o *Post) InteractiveBlocksImageURLs() []string {
props := o.GetProps()
if props == nil {
@ -788,6 +788,7 @@ func (o *Post) InteractiveBlocksImageURLs() []string {
if raw, ok := props[PostPropsAdaptiveCards]; ok {
collectAdaptiveCardImageURLs(raw, &out)
}
collectAttachmentsImageURLs(o.Attachments(), &out)
return out
}
@ -957,10 +958,8 @@ func (o *Post) propsIsValid() error {
}
}
if props[PostPropsMmBlocksActions] != nil {
if err := ValidateMmBlocksActions(o); err != nil {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid mm_blocks_actions: %w", err))
}
if err := ValidateMmBlocksActions(o); err != nil {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid mm_blocks_actions: %w", err))
}
for i, a := range o.Attachments() {

View file

@ -3,6 +3,13 @@
package model
import (
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/shared/markdown"
)
func appendHumanReadableInteractiveStrings(o *Post, out *[]string) {
props := o.GetProps()
if props == nil {
@ -123,8 +130,10 @@ func appendHumanStringsFromBlockKitTree(v any, out *[]string) {
}
}
case "header":
if s, ok := blockMap["text"].(string); ok {
appendNonWhitespaceOnlyMessage(out, s)
if textBlock, ok := blockMap["text"].(map[string]any); ok {
if s, ok := textBlock["text"].(string); ok {
appendNonWhitespaceOnlyMessage(out, s)
}
}
}
}
@ -329,3 +338,397 @@ func collectAdaptiveCardImageURLsFromItem(item any, out *[]string) {
}
}
}
func collectAttachmentsImageURLs(attachments []*MessageAttachment, out *[]string) {
for _, attachment := range attachments {
if attachment == nil {
continue
}
if attachment.ImageURL != "" {
*out = append(*out, attachment.ImageURL)
}
if attachment.ThumbURL != "" {
*out = append(*out, attachment.ThumbURL)
}
if attachment.AuthorIcon != "" {
*out = append(*out, attachment.AuthorIcon)
}
if attachment.FooterIcon != "" {
*out = append(*out, attachment.FooterIcon)
}
}
}
const mmactionScheme = "mmaction://"
// collectMmactionIDsFromText collects action ids from mmaction:// markdown links only.
// Inline code, fenced code blocks, and other non-link text are ignored (same approach as mentions).
func collectMmactionIDsFromText(text string, ids map[string]struct{}) {
markdown.Inspect(text, func(blockOrInline any) bool {
switch v := blockOrInline.(type) {
case *markdown.InlineLink:
collectMmactionIDFromURL(v.Destination(), ids)
case *markdown.ReferenceLink:
if v.ReferenceDefinition != nil {
collectMmactionIDFromURL(v.ReferenceDefinition.Destination(), ids)
}
case *markdown.Autolink:
collectMmactionIDFromURL(v.Destination(), ids)
}
return true
})
}
func collectMmactionIDFromURL(url string, ids map[string]struct{}) {
if !strings.HasPrefix(url, mmactionScheme) {
return
}
withoutScheme := url[len(mmactionScheme):]
actionID := withoutScheme
if i := strings.IndexAny(withoutScheme, "/?#"); i >= 0 {
actionID = withoutScheme[:i]
}
if actionID != "" && mmBlocksActionIDRegex.MatchString(actionID) {
ids[actionID] = struct{}{}
}
}
func mergeActionIDs(into, from map[string]struct{}) {
for id := range from {
into[id] = struct{}{}
}
}
func collectMmBlockActionIDsFromMap(m map[string]any, ids map[string]struct{}) {
typ, _ := m["type"].(string)
switch typ {
case "text":
if s, ok := m["text"].(string); ok {
collectMmactionIDsFromText(s, ids)
}
case "button", "static_select":
if id, ok := m["action_id"].(string); ok && id != "" {
ids[id] = struct{}{}
}
case "container":
collectMmBlockActionIDsFromArray(m["content"], ids)
case "collapsible":
collectMmBlockActionIDsFromArray(m["header"], ids)
collectMmBlockActionIDsFromArray(m["content"], ids)
case "column_set":
if cols, ok := m["columns"].([]any); ok {
for _, col := range cols {
cm, ok := col.(map[string]any)
if !ok {
continue
}
colTyp, _ := cm["type"].(string)
if colTyp != "column" {
continue
}
collectMmBlockActionIDsFromArray(cm["items"], ids)
}
}
}
}
func collectMmBlockActionIDsFromArray(raw any, ids map[string]struct{}) {
arr, ok := interactivePropJSONArray(raw)
if !ok {
return
}
for _, el := range arr {
m, ok := el.(map[string]any)
if !ok {
continue
}
collectMmBlockActionIDsFromMap(m, ids)
}
}
// CollectMmBlockActionIDs returns action_id values referenced by interactive mm_blocks controls.
func CollectMmBlockActionIDs(blocks []any) map[string]struct{} {
ids := make(map[string]struct{})
for _, b := range blocks {
m, ok := b.(map[string]any)
if !ok {
continue
}
collectMmBlockActionIDsFromMap(m, ids)
}
return ids
}
func collectBlockKitTextMmaction(raw any, ids map[string]struct{}) {
if raw == nil {
return
}
switch v := raw.(type) {
case string:
collectMmactionIDsFromText(v, ids)
case map[string]any:
if s, ok := v["text"].(string); ok {
collectMmactionIDsFromText(s, ids)
}
}
}
func collectBlockKitAccessory(accessory map[string]any, ids map[string]struct{}) {
typ, _ := accessory["type"].(string)
switch typ {
case "button", "static_select":
if id, ok := accessory["action_id"].(string); ok && id != "" {
ids[id] = struct{}{}
}
}
}
func collectBlockKitActionElement(el any, ids map[string]struct{}) {
e, ok := el.(map[string]any)
if !ok {
return
}
typ, _ := e["type"].(string)
switch typ {
case "button", "static_select":
if id, ok := e["action_id"].(string); ok && id != "" {
ids[id] = struct{}{}
}
}
}
func collectBlockKitActionIDsFromBlock(m map[string]any, ids map[string]struct{}) {
typ, _ := m["type"].(string)
switch typ {
case "actions":
if elements, ok := m["elements"].([]any); ok {
for _, el := range elements {
collectBlockKitActionElement(el, ids)
}
}
case "section":
collectBlockKitTextMmaction(m["text"], ids)
if accessory, ok := m["accessory"].(map[string]any); ok {
collectBlockKitAccessory(accessory, ids)
}
if fields, ok := m["fields"].([]any); ok {
for _, field := range fields {
collectBlockKitTextMmaction(field, ids)
}
}
case "markdown":
collectBlockKitTextMmaction(m["text"], ids)
case "header":
collectBlockKitTextMmaction(m["text"], ids)
}
}
// CollectBlockKitActionIDs returns action_id values from Block Kit blocks (props.blocks).
func CollectBlockKitActionIDs(blocks []any) map[string]struct{} {
ids := make(map[string]struct{})
for _, b := range blocks {
m, ok := b.(map[string]any)
if !ok {
continue
}
collectBlockKitActionIDsFromBlock(m, ids)
}
return ids
}
func collectAdaptiveCardActionElement(action any, ids map[string]struct{}) {
ac, ok := action.(map[string]any)
if !ok {
return
}
typ, _ := ac["type"].(string)
if typ == "Action.Submit" {
if id, ok := ac["id"].(string); ok && id != "" {
ids[id] = struct{}{}
}
}
}
func collectAdaptiveCardActionIDsFromItem(item any, ids map[string]struct{}) {
itemMap, ok := item.(map[string]any)
if !ok {
return
}
typ, _ := itemMap["type"].(string)
switch typ {
case "TextBlock":
if s, ok := itemMap["text"].(string); ok {
collectMmactionIDsFromText(s, ids)
}
case "Container":
if items, ok := itemMap["items"].([]any); ok {
for _, nested := range items {
collectAdaptiveCardActionIDsFromItem(nested, ids)
}
}
case "ColumnSet":
if columns, ok := itemMap["columns"].([]any); ok {
for _, column := range columns {
columnMap, ok := column.(map[string]any)
if !ok {
continue
}
if items, ok := columnMap["items"].([]any); ok {
for _, nested := range items {
collectAdaptiveCardActionIDsFromItem(nested, ids)
}
}
}
}
case "ActionSet":
if actions, ok := itemMap["actions"].([]any); ok {
for _, action := range actions {
collectAdaptiveCardActionElement(action, ids)
}
}
}
}
// CollectAdaptiveCardActionIDs returns action ids from Adaptive Cards (props.cards).
func CollectAdaptiveCardActionIDs(cards []any) map[string]struct{} {
ids := make(map[string]struct{})
for _, card := range cards {
cardMap, ok := card.(map[string]any)
if !ok {
continue
}
if body, ok := cardMap["body"].([]any); ok {
for _, item := range body {
collectAdaptiveCardActionIDsFromItem(item, ids)
}
}
if actions, ok := cardMap["actions"].([]any); ok {
for _, action := range actions {
collectAdaptiveCardActionElement(action, ids)
}
}
}
return ids
}
// CollectInteractiveActionIDs returns action ids referenced by interactive post props.
func CollectInteractiveActionIDs(props map[string]any) map[string]struct{} {
ids := make(map[string]struct{})
if props == nil {
return ids
}
if raw, ok := props[PostPropsMmBlocks]; ok {
if blocks, ok := interactivePropJSONArray(raw); ok {
mergeActionIDs(ids, CollectMmBlockActionIDs(blocks))
}
}
if raw, ok := props[PostPropsBlockKitBlocks]; ok {
if blocks, ok := interactivePropJSONArray(raw); ok {
mergeActionIDs(ids, CollectBlockKitActionIDs(blocks))
}
}
if raw, ok := props[PostPropsAdaptiveCards]; ok {
if cards, ok := interactivePropJSONArray(raw); ok {
mergeActionIDs(ids, CollectAdaptiveCardActionIDs(cards))
}
}
return ids
}
// CollectInteractiveActionIDsFromPost includes mmaction:// links in the post message.
func CollectInteractiveActionIDsFromPost(o *Post) map[string]struct{} {
ids := CollectInteractiveActionIDs(o.GetProps())
if o.Message != "" {
collectMmactionIDsFromText(o.Message, ids)
}
return ids
}
// CollectMmactionIDsFromText returns action ids from mmaction:// links in a string.
func CollectMmactionIDsFromText(text string) map[string]struct{} {
ids := make(map[string]struct{})
collectMmactionIDsFromText(text, ids)
return ids
}
// SubsetMmBlocksActions returns registry entries referenced by actionIDs.
func SubsetMmBlocksActions(allActions any, actionIDs map[string]struct{}) map[string]any {
if allActions == nil || len(actionIDs) == 0 {
return nil
}
top, ok := allActions.(map[string]any)
if !ok {
return nil
}
out := make(map[string]any, len(actionIDs))
for id := range actionIDs {
if entry, ok := top[id]; ok {
out[id] = entry
}
}
if len(out) == 0 {
return nil
}
return out
}
// RefreshInteractiveActionsOnPost sets mm_blocks_actions to the subset needed by this post's interactive content.
func RefreshInteractiveActionsOnPost(o *Post, allActions any) {
ids := CollectInteractiveActionIDsFromPost(o)
props := o.GetProps()
if props == nil {
props = make(map[string]any)
}
if len(ids) == 0 {
delete(props, PostPropsMmBlocksActions)
} else if subset := SubsetMmBlocksActions(allActions, ids); len(subset) > 0 {
props[PostPropsMmBlocksActions] = subset
} else {
delete(props, PostPropsMmBlocksActions)
}
o.SetProps(props)
}
// ApplyMmBlocksWithActionsToProps sets mm_blocks and refreshes mm_blocks_actions for the props payload.
func ApplyMmBlocksWithActionsToProps(props map[string]any, blocks []any, allActions any) {
props[PostPropsMmBlocks] = blocks
RefreshInteractiveActionsOnPost(&Post{Props: props}, allActions)
}
// validateMmBlocksActionsPairing requires mm_blocks_actions to define exactly the actions
// referenced by mm_blocks, blocks, cards, and mmaction:// links in the post message.
func validateMmBlocksActionsPairing(o *Post, actions map[string]any) error {
referenced := CollectInteractiveActionIDsFromPost(o)
if len(referenced) == 0 {
if len(actions) > 0 {
return fmt.Errorf("mm_blocks_actions must only define actions referenced by interactive content")
}
return nil
}
for id := range referenced {
if _, ok := actions[id]; !ok {
return fmt.Errorf("mm_blocks_actions missing entry for action_id %q", id)
}
}
for key := range actions {
if _, ok := referenced[key]; !ok {
return fmt.Errorf("mm_blocks_actions entry %q is not referenced by interactive content", key)
}
}
return nil
}
// ValidateInteractiveActionsForWebhook checks interactive payloads and mm_blocks_actions are paired.
func ValidateInteractiveActionsForWebhook(o *Post) error {
return ValidateMmBlocksActions(o)
}
// ValidateMmBlocksActionsForWebhook validates mm_blocks-only webhook payloads (legacy helper).
func ValidateMmBlocksActionsForWebhook(blocks []any, actions any) error {
return ValidateInteractiveActionsForWebhook(&Post{
Props: map[string]any{
PostPropsMmBlocks: blocks,
PostPropsMmBlocksActions: actions,
},
})
}

View file

@ -7,9 +7,180 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCollectMmBlockActionIDs(t *testing.T) {
blocks := []any{
map[string]any{"type": "text", "text": "hi"},
map[string]any{
"type": "container",
"content": []any{
map[string]any{"type": "button", "text": "A", "action_id": "a1"},
map[string]any{"type": "static_select", "action_id": "s1", "placeholder": "pick"},
},
},
}
ids := CollectMmBlockActionIDs(blocks)
assert.Equal(t, map[string]struct{}{"a1": {}, "s1": {}}, ids)
}
func TestCollectMmBlockActionIDs_columnSet(t *testing.T) {
blocks := []any{
map[string]any{
"type": "column_set",
"columns": []any{
map[string]any{
"type": "column",
"items": []any{
map[string]any{"type": "button", "text": "A", "action_id": "inset"},
},
},
map[string]any{
"type": "text",
"text": "not a column",
},
},
},
map[string]any{
"type": "column",
"items": []any{
map[string]any{"type": "button", "text": "B", "action_id": "orphan"},
},
},
}
ids := CollectMmBlockActionIDs(blocks)
assert.Equal(t, map[string]struct{}{"inset": {}}, ids)
}
func TestSubsetMmBlocksActions(t *testing.T) {
all := map[string]any{
"a1": map[string]any{"type": "external", "url": "http://example.com/a"},
"b2": map[string]any{"type": "external", "url": "http://example.com/b"},
}
subset := SubsetMmBlocksActions(all, map[string]struct{}{"a1": {}})
require.Len(t, subset, 1)
assert.Contains(t, subset, "a1")
}
func TestValidateMmBlocksActionsForWebhook(t *testing.T) {
blocks := []any{
map[string]any{"type": "button", "text": "Go", "action_id": "act"},
}
require.Error(t, ValidateMmBlocksActionsForWebhook(blocks, nil))
require.Error(t, ValidateMmBlocksActionsForWebhook(nil, map[string]any{"act": map[string]any{}}))
require.NoError(t, ValidateMmBlocksActionsForWebhook(blocks, map[string]any{
"act": map[string]any{"type": "external", "url": "http://example.com"},
}))
}
func TestCollectMmactionIDsFromMmBlockText(t *testing.T) {
blocks := []any{
map[string]any{
"type": "text",
"text": "Choose [one](mmaction://pick1) or [two](mmaction://pick2)",
},
}
ids := CollectMmBlockActionIDs(blocks)
assert.Equal(t, map[string]struct{}{"pick1": {}, "pick2": {}}, ids)
}
func TestCollectMmactionIDsFromText_skipsCode(t *testing.T) {
ids := CollectMmactionIDsFromText("Use [real](mmaction://real1) not `mmaction://inline`")
assert.Equal(t, map[string]struct{}{"real1": {}}, ids)
ids = CollectMmactionIDsFromText("```\n[mmaction://fence](mmaction://fence)\n```\n[ok](mmaction://ok1)")
assert.Equal(t, map[string]struct{}{"ok1": {}}, ids)
}
func TestCollectBlockKitActionIDs(t *testing.T) {
blocks := []any{
map[string]any{
"type": "section",
"text": map[string]any{"type": "mrkdwn", "text": "[Go](mmaction://md1)"},
"accessory": map[string]any{
"type": "button", "text": map[string]any{"type": "plain_text", "text": "Btn"},
"action_id": "acc1",
},
},
map[string]any{
"type": "actions",
"elements": []any{
map[string]any{
"type": "button",
"text": map[string]any{"type": "plain_text", "text": "OK"},
"action_id": "row1",
},
},
},
}
ids := CollectBlockKitActionIDs(blocks)
assert.Equal(t, map[string]struct{}{"md1": {}, "acc1": {}, "row1": {}}, ids)
}
func TestCollectAdaptiveCardActionIDs(t *testing.T) {
cards := []any{
map[string]any{
"type": "AdaptiveCard",
"body": []any{
map[string]any{"type": "TextBlock", "text": "See [here](mmaction://cardmd)"},
map[string]any{
"type": "ActionSet",
"actions": []any{
map[string]any{"type": "Action.Submit", "title": "OK", "id": "submit1"},
},
},
},
"actions": []any{
map[string]any{"type": "Action.Submit", "title": "Footer", "id": "footer1"},
},
},
}
ids := CollectAdaptiveCardActionIDs(cards)
assert.Equal(t, map[string]struct{}{"cardmd": {}, "submit1": {}, "footer1": {}}, ids)
}
func TestValidateMmBlocksActions_pairing(t *testing.T) {
post := &Post{
Props: map[string]any{
PostPropsMmBlocks: []any{
map[string]any{"type": "button", "text": "Go", "action_id": "act"},
},
PostPropsMmBlocksActions: map[string]any{
"act": map[string]any{"type": "external", "url": "http://example.com"},
},
},
}
require.NoError(t, ValidateMmBlocksActions(post))
extra := post.Clone()
extraProps := extra.GetProps()
extraProps[PostPropsMmBlocksActions] = map[string]any{
"act": map[string]any{"type": "external", "url": "http://example.com"},
"extra": map[string]any{"type": "external", "url": "http://example.com/2"},
}
extra.SetProps(extraProps)
require.Error(t, ValidateMmBlocksActions(extra))
}
func TestValidateInteractiveActionsForWebhook_messageMmaction(t *testing.T) {
post := &Post{
Message: "Click [go](mmaction://go1)",
Props: map[string]any{
PostPropsMmBlocksActions: map[string]any{
"go1": map[string]any{"type": "external", "url": "http://example.com"},
},
},
}
require.NoError(t, ValidateInteractiveActionsForWebhook(post))
postMissing := &Post{Message: "Click [go](mmaction://go1)"}
require.Error(t, ValidateInteractiveActionsForWebhook(postMissing))
}
func TestPost_InteractiveBlocksImageURLs(t *testing.T) {
assert.Nil(t, (&Post{}).InteractiveBlocksImageURLs())
post := &Post{
Props: StringInterface{
PostPropsMmBlocks: []any{
@ -84,6 +255,17 @@ func TestPost_InteractiveBlocksImageURLs(t *testing.T) {
},
},
},
PostPropsAttachments: []*MessageAttachment{
{
ImageURL: "https://example.com/attach_main.png",
ThumbURL: "https://example.com/attach_thumb.png",
AuthorIcon: "https://example.com/attach_author.png",
FooterIcon: "https://example.com/attach_footer.png",
},
{ImageURL: "https://example.com/attach_second.png"},
nil,
{Text: "no images"},
},
},
}
@ -101,5 +283,10 @@ func TestPost_InteractiveBlocksImageURLs(t *testing.T) {
"https://example.com/ac_card1_a.png",
"https://example.com/ac_card1_b.png",
"https://example.com/ac_card2.png",
"https://example.com/attach_main.png",
"https://example.com/attach_thumb.png",
"https://example.com/attach_author.png",
"https://example.com/attach_footer.png",
"https://example.com/attach_second.png",
}, urls)
}

View file

@ -1072,6 +1072,25 @@ func TestPost_AllStrings_interactiveProps(t *testing.T) {
require.Contains(t, got, "card-line")
}
func TestPost_AllStrings_blockKitHeaderPlainText(t *testing.T) {
p := &Post{
Props: StringInterface{
PostPropsBlockKitBlocks: []any{
map[string]any{
"type": "header",
"text": map[string]any{
"type": "plain_text",
"text": "Section title",
"emoji": true,
},
},
},
},
}
got := p.AllStrings()
require.Contains(t, got, "Section title")
}
func TestPost_AllStrings_includesMessageAttachments(t *testing.T) {
p := &Post{
Message: "hi",

View file

@ -85,6 +85,32 @@ describe('ImageBlock', () => {
expect(screen.getByTestId('size-aware-image')).toHaveClass('mm-blocks-image__img--person');
});
it('infers extension from pathname when url has query params', async () => {
const user = userEvent.setup();
renderWithContext(
<MmBlocksImagesMetadataContext.Provider value={undefined}>
<ImageBlock
block={{
type: 'image',
url: 'https://example.com/photo.png?sig=abc123',
alt_text: 'Signed photo',
}}
postId='post-42'
/>
</MmBlocksImagesMetadataContext.Provider>,
);
await user.click(screen.getByTestId('size-aware-image'));
expect(openModal).toHaveBeenCalledWith(expect.objectContaining({
dialogProps: expect.objectContaining({
fileInfos: [expect.objectContaining({
link: 'https://example.com/photo.png?sig=abc123',
extension: 'png',
})],
}),
}));
});
it('opens file preview modal when image is clicked', async () => {
const user = userEvent.setup();
renderWithContext(

View file

@ -26,6 +26,17 @@ type ImageBlockProps = {
postId: string;
};
function extensionFromImageURL(src: string): string {
let pathForExt = src;
try {
pathForExt = new URL(src, window.location.href).pathname;
} catch {
// Relative or malformed URLs: fall back to parsing src as-is.
}
const index = pathForExt.lastIndexOf('.');
return index > 0 ? pathForExt.substring(index + 1) : '';
}
export const ImageBlock = ({block, postId}: ImageBlockProps) => {
const dispatch = useDispatch();
const imagesMetadata = useContext(MmBlocksImagesMetadataContext);
@ -38,8 +49,7 @@ export const ImageBlock = ({block, postId}: ImageBlockProps) => {
link = '',
) => {
const src = link || url;
const index = src.lastIndexOf('.');
const extension = index > 0 ? src.substring(index + 1) : '';
const extension = extensionFromImageURL(src);
e.preventDefault();

View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MmImageBlock} from '@mattermost/types/mm_blocks';
import {translateAdaptiveCards} from './adaptive_cards';
function imageFromCards(width: unknown, height?: unknown): MmImageBlock | undefined {
const blocks = translateAdaptiveCards([{
type: 'AdaptiveCard',
body: [{
type: 'Image',
url: 'https://example.com/x.png',
width,
...(height === undefined ? {} : {height}),
}],
}]);
return blocks.find((b): b is MmImageBlock => b.type === 'image');
}
describe('translateAdaptiveCards Image pixel dimensions', () => {
it('accepts plain numbers, px suffix, and decimal literals', () => {
expect(imageFromCards(120)?.max_width).toBe(120);
expect(imageFromCards('80px')?.max_width).toBe(80);
expect(imageFromCards('12.5')?.max_width).toBe(13);
});
it('rejects percent and partially numeric strings', () => {
const image = imageFromCards('100%', '10abc');
expect(image?.max_width).toBeUndefined();
expect(image?.max_height).toBeUndefined();
});
});

View file

@ -215,7 +215,10 @@ function parseAdaptiveCardPixelDimension(v: unknown): number | undefined {
if (px) {
return parseInt(px[1], 10);
}
const num = parseFloat(trimmed);
if (!(/^\d+(\.\d+)?$/).test(trimmed)) {
return undefined;
}
const num = Number.parseFloat(trimmed);
if (Number.isFinite(num) && num > 0) {
return Math.round(num);
}

View file

@ -18,10 +18,7 @@ import {isUrlSafe} from 'utils/url';
import {parseMmButtonStyle} from '../utils/button';
/** Matches legacy `.attachment__author-icon` in `_webhooks.scss` (14×14). */
const AUTHOR_ICON_MAX_PX = 20;
/** Matches legacy `.attachment__footer-icon` in `message_attachment.tsx` (16×16). */
const FOOTER_ICON_MAX_PX = 16;
/** Placeholder so the stretch column still exists when the body is otherwise empty (thumb-only attachment). */

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MmButtonBlock, MmColumnSetBlock} from '@mattermost/types/mm_blocks';
import {translateBlockKit} from './block_kit';
describe('translateBlockKit section accessory button', () => {
it('requires a non-empty action_id', () => {
const blocks = translateBlockKit([{
type: 'section',
text: {type: 'plain_text', text: 'Body'},
accessory: {
type: 'button',
text: {type: 'plain_text', text: 'Go'},
action_id: '',
},
}]);
expect(blocks).toEqual([{type: 'text', text: 'Body'}]);
});
it('keeps accessory button when action_id is present', () => {
const blocks = translateBlockKit([{
type: 'section',
text: {type: 'plain_text', text: 'Body'},
accessory: {
type: 'button',
text: {type: 'plain_text', text: 'Go'},
action_id: 'go_action',
style: 'primary',
},
}]);
const columnSet = blocks[0] as MmColumnSetBlock;
const button = columnSet.columns[1].items[0] as MmButtonBlock;
expect(button).toMatchObject({
type: 'button',
action_id: 'go_action',
text: 'Go',
style: 'primary',
});
});
});

View file

@ -160,12 +160,12 @@ function translateBlockKitAccessory(
): MmBlock | null {
if (accessory.type === 'button') {
const text = extractBlockKitPlainText(accessory.text);
if (!text) {
if (!text || typeof accessory.action_id !== 'string' || !accessory.action_id) {
return null;
}
return {
type: 'button',
action_id: typeof accessory.action_id === 'string' ? accessory.action_id : '',
action_id: accessory.action_id,
text,
style: parseMmButtonStyle(typeof accessory.style === 'string' ? accessory.style : undefined),
};

View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {translateMMBlocks} from './mm_block';
describe('translateMMBlocks interactive blocks', () => {
it('rejects button blocks with empty text or action_id', () => {
expect(translateMMBlocks([
{type: 'button', text: ' ', action_id: 'ok'},
{type: 'button', text: 'Go', action_id: ''},
])).toEqual([]);
});
it('accepts button blocks with non-empty text and action_id', () => {
expect(translateMMBlocks([
{type: 'button', text: 'Go', action_id: 'go_action'},
])).toEqual([{
type: 'button',
text: 'Go',
action_id: 'go_action',
}]);
});
it('rejects static_select blocks with empty placeholder or action_id', () => {
expect(translateMMBlocks([
{
type: 'static_select',
action_id: 'sel',
placeholder: ' ',
options: [{text: 'A', value: 'a'}],
},
{
type: 'static_select',
action_id: ' ',
placeholder: 'Pick',
options: [{text: 'A', value: 'a'}],
},
])).toEqual([]);
});
it('accepts static_select blocks with non-empty placeholder and action_id', () => {
expect(translateMMBlocks([
{
type: 'static_select',
action_id: 'sel_action',
placeholder: 'Pick one',
options: [{text: 'A', value: 'a'}],
},
])).toEqual([{
type: 'static_select',
action_id: 'sel_action',
placeholder: 'Pick one',
options: [{text: 'A', value: 'a'}],
}]);
});
});

View file

@ -201,7 +201,8 @@ function translateButtonBlock(raw: Record<string, unknown>): MmButtonBlock | nul
if (!keysAreSubset(raw, BUTTON_KEYS)) {
return null;
}
if (typeof raw.text !== 'string' || typeof raw.action_id !== 'string') {
if (typeof raw.text !== 'string' || raw.text.trim() === '' ||
typeof raw.action_id !== 'string' || raw.action_id.trim() === '') {
return null;
}
const styleRaw = raw.style;
@ -268,7 +269,8 @@ function translateStaticSelectBlock(raw: Record<string, unknown>): MmStaticSelec
if (!keysAreSubset(raw, STATIC_SELECT_KEYS)) {
return null;
}
if (typeof raw.action_id !== 'string' || typeof raw.placeholder !== 'string') {
if (typeof raw.action_id !== 'string' || raw.action_id.trim() === '' ||
typeof raw.placeholder !== 'string' || raw.placeholder.trim() === '') {
return null;
}
let options: MmStaticSelectOption[] | undefined;

View file

@ -6,6 +6,7 @@ import React from 'react';
import {doPostActionWithCookie} from 'mattermost-redux/actions/posts';
import {act, fireEvent, renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {applyIntegrationGotoLocation} from 'utils/integration_navigation';
import InlineActionButton from './index';
@ -13,7 +14,12 @@ jest.mock('mattermost-redux/actions/posts', () => ({
doPostActionWithCookie: jest.fn(),
}));
jest.mock('utils/integration_navigation', () => ({
applyIntegrationGotoLocation: jest.fn(),
}));
const mockedDoPostActionWithCookie = doPostActionWithCookie as jest.MockedFunction<typeof doPostActionWithCookie>;
const mockedApplyIntegrationGotoLocation = applyIntegrationGotoLocation as jest.MockedFunction<typeof applyIntegrationGotoLocation>;
/**
* Creates a thunk-shaped mock whose inner promise is externally controllable.
@ -42,6 +48,7 @@ describe('InlineActionButton', () => {
beforeEach(() => {
mockedDoPostActionWithCookie.mockReset();
mockedApplyIntegrationGotoLocation.mockReset();
});
test('renders with children as button label', () => {
@ -416,9 +423,10 @@ describe('InlineActionButton', () => {
}
});
test('uses doPostActionWithCookie when mmBlocksActionCookie is set', async () => {
test('uses doPostActionWithCookie when mmBlocksActionCookie is set and applies goto_location from response', async () => {
const gotoLocation = '/some-location';
mockedDoPostActionWithCookie.mockImplementation(
() => (() => Promise.resolve({data: {}})) as unknown as ReturnType<typeof doPostActionWithCookie>,
() => (() => Promise.resolve({data: {goto_location: gotoLocation}})) as unknown as ReturnType<typeof doPostActionWithCookie>,
);
renderWithContext(
@ -440,5 +448,7 @@ describe('InlineActionButton', () => {
{tail: '214', mds: 'C130J'},
'mm_block',
);
expect(mockedApplyIntegrationGotoLocation).toHaveBeenCalledTimes(1);
expect(mockedApplyIntegrationGotoLocation).toHaveBeenCalledWith(gotoLocation);
});
});

View file

@ -8,6 +8,7 @@
// the existing doPostAction layer, consistent with Attachments.
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Post} from '@mattermost/types/posts';
@ -25,6 +26,7 @@ type Props = {
const InteractiveMessages = ({post}: Props) => {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const [actionError, setActionError] = useState<string | null>(null);
const postProps = post.props as Record<string, unknown> | undefined;
@ -33,6 +35,10 @@ const InteractiveMessages = ({post}: Props) => {
const integrationFormat = getPostInteractiveIntegrationFormat(postProps ?? {});
const handleAction = useCallback(async (actionId: string, selectedOption?: string, query?: Record<string, string>, attachmentCookie?: string) => {
const actionFailedMessage = formatMessage({
id: 'post.message_attachment.action_failed',
defaultMessage: 'Action failed to execute',
});
setActionError(null);
let actionCookie = '';
if (integrationFormat === 'attachment') {
@ -44,7 +50,7 @@ const InteractiveMessages = ({post}: Props) => {
const result = await dispatch(doPostActionWithCookie(post.id, actionId, actionCookie, selectedOption ?? '', query, integrationFormat));
if (result.error) {
const message = typeof result.error.message === 'string' && result.error.message ? result.error.message : undefined;
setActionError(message ?? 'Action failed to execute');
setActionError(message ?? actionFailedMessage);
return;
}
const goToLocation =
@ -57,9 +63,9 @@ const InteractiveMessages = ({post}: Props) => {
}
} catch (error) {
const message = error instanceof Error ? error.message : undefined;
setActionError(message ?? 'Action failed to execute');
setActionError(message ?? actionFailedMessage);
}
}, [dispatch, post.id, integrationFormat, mmBlocksActionCookie]);
}, [dispatch, post.id, integrationFormat, mmBlocksActionCookie, formatMessage]);
const blocks = translatePostProps(post.props as Record<string, unknown>);
if (!blocks || blocks.length === 0) {

View file

@ -680,6 +680,72 @@ describe('Actions.Posts', () => {
}),
expected: new Set(['user1:org1', 'user2:org2', 'user3:org3', 'user4:org4', 'user5:org5', 'user6:org6']),
},
{
name: 'should return at-mentions from mm_blocks text blocks but not button labels',
input: TestHelper.getPostMock({
props: {
mm_blocks: [
{type: 'text', text: 'hello @bbb'},
{type: 'button', text: '@ccc', action_id: 'act'},
],
},
}),
expected: new Set(['bbb']),
},
{
name: 'should return at-mentions from nested mm_blocks containers',
input: TestHelper.getPostMock({
props: {
mm_blocks: [
{
type: 'container',
content: [
{type: 'text', text: '@ddd in container'},
],
},
],
},
}),
expected: new Set(['ddd']),
},
{
name: 'should return at-mentions from Block Kit markdown and section blocks',
input: TestHelper.getPostMock({
props: {
blocks: [
{type: 'markdown', text: 'markdown @eee'},
{
type: 'section',
text: {type: 'mrkdwn', text: 'section @fff'},
fields: [{type: 'mrkdwn', text: 'field @ggg'}],
},
{type: 'header', text: 'header @hhh'},
],
},
}),
expected: new Set(['eee', 'fff', 'ggg', 'hhh']),
},
{
name: 'should return at-mentions from Adaptive Card TextBlock elements',
input: TestHelper.getPostMock({
props: {
cards: [
{
type: 'AdaptiveCard',
version: '1.0',
body: [
{type: 'TextBlock', text: 'card @iii'},
{
type: 'Container',
items: [{type: 'TextBlock', text: 'nested @jjj'}],
},
],
},
],
},
}),
expected: new Set(['iii', 'jjj']),
},
];
for (const specialMention of [

View file

@ -37,6 +37,7 @@ import {getUnreadScrollPositionPreference, isCollapsedThreadsEnabled} from 'matt
import {getCurrentUserId, getUsersByUsername} from 'mattermost-redux/selectors/entities/users';
import type {ActionResult, DispatchFunc, GetStateFunc, ActionFunc, ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions';
import {DelayedDataLoader} from 'mattermost-redux/utils/data_loader';
import {scanHumanReadableStringsFromInteractiveProps} from 'mattermost-redux/utils/post_interactive_utils';
import {isCombinedUserActivityPost} from 'mattermost-redux/utils/post_list';
import {logError, LogErrorBarMode} from './errors';
@ -1149,6 +1150,10 @@ export function getNeededAtMentionedUsernamesAndGroups(state: GlobalState, posts
}
}
}
for (const text of scanHumanReadableStringsFromInteractiveProps(post.props)) {
findNeededUsernamesAndGroups(text);
}
}
return usernamesAndGroupsToLoad;

View file

@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
function appendHumanReadableStringsFromMmBlockMap(m: Record<string, unknown>, out: string[]) {
const typ = m.type;
if (typeof typ !== 'string') {
return;
}
switch (typ) {
case 'text':
if (typeof m.text === 'string') {
out.push(m.text);
}
break;
case 'container':
appendHumanReadableStringsFromMmBlocksArray(m.content, out);
break;
case 'collapsible':
appendHumanReadableStringsFromMmBlocksArray(m.header, out);
appendHumanReadableStringsFromMmBlocksArray(m.content, out);
break;
case 'column_set':
if (Array.isArray(m.columns)) {
for (const col of m.columns) {
if (col && typeof col === 'object') {
appendHumanReadableStringsFromMmBlockMap(col as Record<string, unknown>, out);
}
}
}
break;
case 'column':
appendHumanReadableStringsFromMmBlocksArray(m.items, out);
break;
default:
break;
}
}
function appendHumanReadableStringsFromMmBlocksArray(raw: unknown, out: string[]) {
if (!Array.isArray(raw)) {
return;
}
for (const el of raw) {
if (el && typeof el === 'object') {
appendHumanReadableStringsFromMmBlockMap(el as Record<string, unknown>, out);
}
}
}
function appendHumanReadableStringsFromMmBlocks(raw: unknown, out: string[]) {
if (!Array.isArray(raw)) {
return;
}
for (const b of raw) {
if (b && typeof b === 'object') {
appendHumanReadableStringsFromMmBlockMap(b as Record<string, unknown>, out);
}
}
}
function appendHumanReadableStringsFromBlockKitTree(raw: unknown, out: string[]) {
if (!Array.isArray(raw)) {
return;
}
for (const block of raw) {
if (!block || typeof block !== 'object') {
continue;
}
const blockMap = block as Record<string, unknown>;
const typ = blockMap.type;
if (typeof typ !== 'string') {
continue;
}
switch (typ) {
case 'markdown':
if (typeof blockMap.text === 'string') {
out.push(blockMap.text);
}
break;
case 'section': {
const textBlock = blockMap.text;
if (textBlock && typeof textBlock === 'object' && typeof (textBlock as Record<string, unknown>).text === 'string') {
out.push((textBlock as Record<string, unknown>).text as string);
}
if (Array.isArray(blockMap.fields)) {
for (const field of blockMap.fields) {
if (field && typeof field === 'object' && typeof (field as Record<string, unknown>).text === 'string') {
out.push((field as Record<string, unknown>).text as string);
}
}
}
break;
}
case 'header':
if (typeof blockMap.text === 'string') {
out.push(blockMap.text);
}
break;
default:
break;
}
}
}
function appendHumanReadableStringsFromAdaptiveCardsItem(item: unknown, out: string[]) {
if (!item || typeof item !== 'object') {
return;
}
const itemMap = item as Record<string, unknown>;
const typ = itemMap.type;
if (typeof typ !== 'string') {
return;
}
switch (typ) {
case 'TextBlock':
if (typeof itemMap.text === 'string') {
out.push(itemMap.text);
}
break;
case 'Container':
if (Array.isArray(itemMap.items)) {
for (const nested of itemMap.items) {
appendHumanReadableStringsFromAdaptiveCardsItem(nested, out);
}
}
break;
case 'ColumnSet':
if (Array.isArray(itemMap.columns)) {
for (const column of itemMap.columns) {
if (!column || typeof column !== 'object') {
continue;
}
const items = (column as Record<string, unknown>).items;
if (Array.isArray(items)) {
for (const nested of items) {
appendHumanReadableStringsFromAdaptiveCardsItem(nested, out);
}
}
}
}
break;
default:
break;
}
}
function appendHumanReadableStringsFromAdaptiveCardsTree(raw: unknown, out: string[]) {
if (!Array.isArray(raw)) {
return;
}
for (const card of raw) {
if (!card || typeof card !== 'object') {
continue;
}
const body = (card as Record<string, unknown>).body;
if (!Array.isArray(body)) {
continue;
}
for (const item of body) {
appendHumanReadableStringsFromAdaptiveCardsItem(item, out);
}
}
}
// Mirrors model.appendHumanReadableInteractiveStrings (mm_blocks, Block Kit blocks, Adaptive cards).
export function scanHumanReadableStringsFromInteractiveProps(props: Record<string, unknown> | undefined): string[] {
const out: string[] = [];
if (!props) {
return out;
}
if (props.mm_blocks) {
appendHumanReadableStringsFromMmBlocks(props.mm_blocks, out);
}
if (props.blocks) {
appendHumanReadableStringsFromBlockKitTree(props.blocks, out);
}
if (props.cards) {
appendHumanReadableStringsFromAdaptiveCardsTree(props.cards, out);
}
return out;
}

View file

@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {applyIntegrationGotoLocation} from './integration_navigation';
jest.mock('utils/browser_history', () => ({
getHistory: () => ({push: jest.fn()}),
}));
jest.mock('utils/url', () => ({
getSiteURL: () => 'https://mattermost.example.com',
isUrlSafe: (url: string) => url.startsWith('https://'),
}));
describe('applyIntegrationGotoLocation', () => {
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
beforeEach(() => {
openSpy.mockClear();
});
afterAll(() => {
openSpy.mockRestore();
});
it('opens external urls with noopener and noreferrer', () => {
applyIntegrationGotoLocation('https://external.example.com/path');
expect(openSpy).toHaveBeenCalledWith(
'https://external.example.com/path',
'_blank',
'noopener,noreferrer',
);
});
});

View file

@ -21,5 +21,5 @@ export function applyIntegrationGotoLocation(gotoLocation: string | undefined):
getHistory().push(gotoLocation.substring(siteURL.length));
return;
}
window.open(gotoLocation);
window.open(gotoLocation, '_blank', 'noopener,noreferrer');
}