[MM-62427] Add message attachments validation (#30180)

* Add message attachments validation

* Add props validation

* Validate slack attachment fields

* Update tests and library usage

* Improve interactive dialog error for length checks

* Allow predefined colors for slack attachments

* Fix TestPostAction

* Use const for data source

* Add tests

* Cleanup unused props

* Add happy path tests

* lint fixes

* Add validation for PostActionOptions
Этот коммит содержится в:
Ben Schumacher
2025-03-20 12:53:50 +01:00
коммит произвёл GitHub
родитель 5609489e86
Коммит 9b5d8d52bf
47 изменённых файлов: 1402 добавлений и 355 удалений

Просмотреть файл

@@ -36,10 +36,11 @@ func TestClient4TrimTrailingSlash(t *testing.T) {
func TestClient4CreatePost(t *testing.T) {
post := &model.Post{
Props: map[string]any{
"attachments": []*model.SlackAttachment{
model.PostPropsAttachments: []*model.SlackAttachment{
{
Actions: []*model.PostAction{
{
Type: model.PostActionTypeButton,
Integration: &model.PostActionIntegration{
Context: map[string]any{
"foo": "bar",
@@ -63,6 +64,7 @@ func TestClient4CreatePost(t *testing.T) {
{
Actions: []*model.PostAction{
{
Type: model.PostActionTypeButton,
Integration: &model.PostActionIntegration{
Context: map[string]any{
"foo": "bar",

Просмотреть файл

@@ -17,6 +17,7 @@ import (
"math/big"
"net/http"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -38,13 +39,18 @@ const (
DialogElementBoolMaxLength = 150
)
var PostActionRetainPropKeys = []string{"from_webhook", "override_username", "override_icon_url"}
var PostActionRetainPropKeys = []string{PostPropsFromWebhook, PostPropsOverrideUsername, PostPropsOverrideIconURL}
type DoPostActionRequest struct {
SelectedOption string `json:"selected_option,omitempty"`
Cookie string `json:"cookie,omitempty"`
}
const (
PostActionDataSourceUsers = "users"
PostActionDataSourceChannels = "channels"
)
type PostAction struct {
// A unique Action ID. If not set, generated automatically.
Id string `json:"id,omitempty"`
@@ -85,6 +91,73 @@ type PostAction struct {
Cookie string `json:"cookie,omitempty" db:"-"`
}
// IsValid validates the action and returns an error if it is invalid.
func (p *PostAction) IsValid() error {
var multiErr *multierror.Error
if p.Name == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have a name"))
}
if p.Style != "" {
validStyles := []string{"default", "primary", "success", "good", "warning", "danger"}
// If not a predefined style, check if it's a hex color
if !slices.Contains(validStyles, p.Style) && !hexColorRegex.MatchString(p.Style) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid style '%s' - must be one of [default, primary, success, good, warning, danger] or a hex color", p.Style))
}
}
switch p.Type {
case PostActionTypeButton:
if len(p.Options) > 0 {
multiErr = multierror.Append(multiErr, fmt.Errorf("button action must not have options"))
}
if p.DataSource != "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("button action must not have a data source"))
}
case PostActionTypeSelect:
if p.DataSource != "" {
validSources := []string{PostActionDataSourceUsers, PostActionDataSourceChannels}
if !slices.Contains(validSources, p.DataSource) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid data_source '%s' for select action", p.DataSource))
}
if len(p.Options) > 0 {
multiErr = multierror.Append(multiErr, fmt.Errorf("select action cannot have both DataSource and Options set"))
}
} else {
if len(p.Options) == 0 {
multiErr = multierror.Append(multiErr, fmt.Errorf("select action must have either DataSource or Options set"))
} else {
for i, opt := range p.Options {
if opt == nil {
multiErr = multierror.Append(multiErr, fmt.Errorf("select action contains nil option"))
continue
}
if err := opt.IsValid(); err != nil {
multiErr = multierror.Append(multiErr, multierror.Prefix(err, fmt.Sprintf("option at index %d is invalid:", i)))
}
}
}
}
default:
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid action type: must be '%s' or '%s'", PostActionTypeButton, PostActionTypeSelect))
}
if p.Integration == nil {
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have integration settings"))
} else {
if p.Integration.URL == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have an integration URL"))
}
if !(strings.HasPrefix(p.Integration.URL, "/plugins/") || strings.HasPrefix(p.Integration.URL, "plugins/") || IsValidHTTPURL(p.Integration.URL)) {
multiErr = multierror.Append(multiErr, fmt.Errorf("action must have an valid integration URL"))
}
}
return multiErr.ErrorOrNil()
}
func (p *PostAction) Equals(input *PostAction) bool {
if p.Id != input.Id {
return false
@@ -189,7 +262,22 @@ type PostActionOptions struct {
Value string `json:"value"`
}
func (o *PostActionOptions) IsValid() error {
var multiErr *multierror.Error
if o.Text == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("text is required"))
}
if o.Value == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("value is required"))
}
return multiErr.ErrorOrNil()
}
type PostActionIntegration struct {
// URL is the endpoint that the action will be sent to.
// It can be a relative path to a plugin.
URL string `json:"url,omitempty"`
Context map[string]any `json:"context,omitempty"`
}
@@ -474,24 +562,25 @@ func isDefaultInOptions(defaultValue string, options []*PostActionOptions) bool
return false
}
func checkMaxLength(fieldName string, field string, length int) error {
var valid bool
func checkMaxLength(fieldName string, field string, maxLength int) error {
// DisplayName and Name are required fields
if fieldName == "DisplayName" || fieldName == "Name" {
valid = len(field) > 0 && len(field) > length
} else {
valid = len(field) > length
if len(field) == 0 {
return errors.Errorf("%v cannot be empty", fieldName)
}
}
if valid {
return errors.Errorf("%v cannot be longer than %d characters", fieldName, length)
if len(field) > maxLength {
return errors.Errorf("%v cannot be longer than %d characters, got %d", fieldName, maxLength, len(field))
}
return nil
}
func (o *Post) StripActionIntegrations() {
attachments := o.Attachments()
if o.GetProp("attachments") != nil {
o.AddProp("attachments", attachments)
if o.GetProp(PostPropsAttachments) != nil {
o.AddProp(PostPropsAttachments, attachments)
}
for _, attachment := range attachments {
for _, action := range attachment.Actions {
@@ -512,10 +601,10 @@ func (o *Post) GetAction(id string) *PostAction {
}
func (o *Post) GenerateActionIds() {
if o.GetProp("attachments") != nil {
o.AddProp("attachments", o.Attachments())
if o.GetProp(PostPropsAttachments) != nil {
o.AddProp(PostPropsAttachments, o.Attachments())
}
if attachments, ok := o.GetProp("attachments").([]*SlackAttachment); ok {
if attachments, ok := o.GetProp(PostPropsAttachments).([]*SlackAttachment); ok {
for _, attachment := range attachments {
for _, action := range attachment.Actions {
if action != nil && action.Id == "" {

Просмотреть файл

@@ -16,6 +16,246 @@ import (
"github.com/stretchr/testify/require"
)
func TestPostAction_IsValid(t *testing.T) {
tests := map[string]struct {
action *PostAction
wantErr string
}{
"valid button action with http URL": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "",
},
"valid button action with http URL without Id": {
action: &PostAction{
Name: "Test Button",
Type: PostActionTypeButton,
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "",
},
"valid button action with plugin path": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
Integration: &PostActionIntegration{
URL: "/plugins/myplugin/action",
},
},
wantErr: "",
},
"valid button action with relative plugin path": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
Integration: &PostActionIntegration{
URL: "plugins/myplugin/action",
},
},
wantErr: "",
},
"invalid integration URL": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
Integration: &PostActionIntegration{
URL: "invalid-url",
},
},
wantErr: "action must have an valid integration URL",
},
"valid select action with datasource": {
action: &PostAction{
Id: "validid",
Name: "Test Select",
Type: PostActionTypeSelect,
DataSource: PostActionDataSourceUsers,
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "",
},
"valid select action with options": {
action: &PostAction{
Id: "validid",
Name: "Test Select",
Type: PostActionTypeSelect,
Options: []*PostActionOptions{
{Text: "Opt1", Value: "opt1"},
},
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "",
},
"select action with nil option": {
action: &PostAction{
Id: "validid",
Name: "Test Select",
Type: PostActionTypeSelect,
Options: []*PostActionOptions{
nil,
{Text: "Opt1", Value: "opt1"},
},
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "select action contains nil option",
},
"missing name": {
action: &PostAction{
Id: "validid",
Type: PostActionTypeButton,
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "action must have a name",
},
"invalid style": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
Style: "invalid",
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "invalid style 'invalid' - must be one of [default, primary, success, good, warning, danger] or a hex color",
},
"valid style": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
Style: "primary",
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "",
},
"button with options": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
Options: []*PostActionOptions{
{Text: "Opt1", Value: "opt1"},
},
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "button action must not have options",
},
"button with datasource": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
DataSource: PostActionDataSourceUsers,
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "button action must not have a data source",
},
"select without datasource or options": {
action: &PostAction{
Id: "validid",
Name: "Test Select",
Type: PostActionTypeSelect,
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "select action must have either DataSource or Options set",
},
"select with both datasource and options": {
action: &PostAction{
Id: "validid",
Name: "Test Select",
Type: PostActionTypeSelect,
DataSource: PostActionDataSourceUsers,
Options: []*PostActionOptions{
{Text: "Opt1", Value: "opt1"},
},
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "select action cannot have both DataSource and Options set",
},
"invalid datasource": {
action: &PostAction{
Id: "validid",
Name: "Test Select",
Type: PostActionTypeSelect,
DataSource: "invalid",
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "invalid data_source 'invalid' for select action",
},
"missing integration": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
},
wantErr: "action must have integration settings",
},
"missing integration URL": {
action: &PostAction{
Id: "validid",
Name: "Test Button",
Type: PostActionTypeButton,
Integration: &PostActionIntegration{},
},
wantErr: "action must have an integration URL",
},
"invalid type": {
action: &PostAction{
Id: "validid",
Name: "Test Action",
Type: "invalid",
Integration: &PostActionIntegration{
URL: "http://localhost:8065",
},
},
wantErr: "invalid action type: must be 'button' or 'select'",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
err := tc.action.IsValid()
if tc.wantErr == "" {
assert.NoError(t, err, name)
} else {
assert.ErrorContains(t, err, tc.wantErr, name)
}
})
}
}
func TestTriggerIdDecodeAndVerification(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
@@ -182,6 +422,44 @@ func TestPostActionIntegrationEquals(t *testing.T) {
})
}
func TestPostActionOptions_IsValid(t *testing.T) {
tests := map[string]struct {
options *PostActionOptions
wantErr string
}{
"valid options": {
options: &PostActionOptions{
Text: "Option 1",
Value: "opt1",
},
wantErr: "",
},
"missing text": {
options: &PostActionOptions{
Value: "opt1",
},
wantErr: "text is required",
},
"missing value": {
options: &PostActionOptions{
Text: "Option 1",
},
wantErr: "value is required",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
err := tc.options.IsValid()
if tc.wantErr == "" {
assert.NoError(t, err)
} else {
assert.ErrorContains(t, err, tc.wantErr)
}
})
}
}
func TestOpenDialogRequestIsValid(t *testing.T) {
getBaseOpenDialogRequest := func() OpenDialogRequest {
return OpenDialogRequest{

Просмотреть файл

@@ -15,7 +15,9 @@ import (
"sync"
"unicode/utf8"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/public/shared/markdown"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
const (
@@ -72,14 +74,16 @@ const (
PostPropsFromBot = "from_bot"
PostPropsFromOAuthApp = "from_oauth_app"
PostPropsWebhookDisplayName = "webhook_display_name"
PostPropsAttachments = "attachments"
PostPropsFromPlugin = "from_plugin"
PostPropsMentionHighlightDisabled = "mentionHighlightDisabled"
PostPropsGroupHighlightDisabled = "disable_group_highlight"
PostPropsPreviewedPost = "previewed_post"
PostPropsForceNotification = "force_notification"
PostPropsChannelMentions = "channel_mentions"
PostPropsUnsafeLinks = "unsafe_links"
PostPriorityUrgent = "urgent"
PostPropsRequestedAck = "requested_ack"
PostPropsPersistentNotifications = "persistent_notifications"
PostPriorityUrgent = "urgent"
)
type Post struct {
@@ -657,6 +661,137 @@ func (o *Post) GetProp(key string) any {
return o.Props[key]
}
// ValidateProps checks all known props for validity.
// Currently, it logs warnings for invalid props rather than returning an error.
// In a future version, this will be updated to return errors for invalid props.
func (o *Post) ValidateProps(logger mlog.LoggerIFace) {
if err := o.propsIsValid(); err != nil {
logger.Warn(
"Invalid post props. In a future version this will result in an error. Please update your integration to be compliant.",
mlog.String("post_id", o.Id),
mlog.Err(err),
)
}
}
func (o *Post) propsIsValid() error {
var multiErr *multierror.Error
props := o.GetProps()
// Check basic props validity
if props == nil {
return nil
}
if props[PostPropsAddedUserId] != nil {
if addedUserID, ok := props[PostPropsAddedUserId].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("added_user_id prop must be a string"))
} else if !IsValidId(addedUserID) {
multiErr = multierror.Append(multiErr, fmt.Errorf("added_user_id prop must be a valid user ID"))
}
}
if props[PostPropsDeleteBy] != nil {
if deleteByID, ok := props[PostPropsDeleteBy].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("delete_by prop must be a string"))
} else if !IsValidId(deleteByID) {
multiErr = multierror.Append(multiErr, fmt.Errorf("delete_by prop must be a valid user ID"))
}
}
// Validate integration props
if props[PostPropsOverrideIconURL] != nil {
if iconURL, ok := props[PostPropsOverrideIconURL].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("override_icon_url prop must be a string"))
} else if iconURL == "" || !IsValidHTTPURL(iconURL) {
multiErr = multierror.Append(multiErr, fmt.Errorf("override_icon_url prop must be a valid URL"))
}
}
if props[PostPropsOverrideIconEmoji] != nil {
if _, ok := props[PostPropsOverrideIconEmoji].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("override_icon_emoji prop must be a string"))
}
}
if props[PostPropsOverrideUsername] != nil {
if _, ok := props[PostPropsOverrideUsername].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("override_username prop must be a string"))
}
}
if props[PostPropsFromWebhook] != nil {
if fromWebhook, ok := props[PostPropsFromWebhook].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_webhook prop must be a string"))
} else if fromWebhook != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_webhook prop must be \"true\""))
}
}
if props[PostPropsFromBot] != nil {
if fromBot, ok := props[PostPropsFromBot].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_bot prop must be a string"))
} else if fromBot != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_bot prop must be \"true\""))
}
}
if props[PostPropsFromOAuthApp] != nil {
if fromOAuthApp, ok := props[PostPropsFromOAuthApp].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_oauth_app prop must be a string"))
} else if fromOAuthApp != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_oauth_app prop must be \"true\""))
}
}
if props[PostPropsFromPlugin] != nil {
if fromPlugin, ok := props[PostPropsFromPlugin].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_plugin prop must be a string"))
} else if fromPlugin != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_plugin prop must be \"true\""))
}
}
if props[PostPropsUnsafeLinks] != nil {
if unsafeLinks, ok := props[PostPropsUnsafeLinks].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("unsafe_links prop must be a string"))
} else if unsafeLinks != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("unsafe_links prop must be \"true\""))
}
}
if props[PostPropsWebhookDisplayName] != nil {
if _, ok := props[PostPropsWebhookDisplayName].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("webhook_display_name prop must be a string"))
}
}
if props[PostPropsMentionHighlightDisabled] != nil {
if _, ok := props[PostPropsMentionHighlightDisabled].(bool); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("mention_highlight_disabled prop must be a boolean"))
}
}
if props[PostPropsGroupHighlightDisabled] != nil {
if _, ok := props[PostPropsGroupHighlightDisabled].(bool); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("disable_group_highlight prop must be a boolean"))
}
}
if props[PostPropsPreviewedPost] != nil {
if previewedPostID, ok := props[PostPropsPreviewedPost].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("previewed_post prop must be a string"))
} else if !IsValidId(previewedPostID) {
multiErr = multierror.Append(multiErr, fmt.Errorf("previewed_post prop must be a valid post ID"))
}
}
if props[PostPropsForceNotification] != nil {
if _, ok := props[PostPropsForceNotification].(bool); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("force_notification prop must be a boolean"))
}
}
for i, a := range o.Attachments() {
if err := a.IsValid(); err != nil {
multiErr = multierror.Append(multiErr, multierror.Prefix(err, fmt.Sprintf("message attachtment at index %d is invalid:", i)))
}
}
return multiErr.ErrorOrNil()
}
func (o *Post) IsSystemMessage() bool {
return len(o.Type) >= len(PostSystemMessagePrefix) && o.Type[:len(PostSystemMessagePrefix)] == PostSystemMessagePrefix
}
@@ -746,11 +881,11 @@ func findAtChannelMention(message string) (mention string, found bool) {
}
func (o *Post) Attachments() []*SlackAttachment {
if attachments, ok := o.GetProp("attachments").([]*SlackAttachment); ok {
if attachments, ok := o.GetProp(PostPropsAttachments).([]*SlackAttachment); ok {
return attachments
}
var ret []*SlackAttachment
if attachments, ok := o.GetProp("attachments").([]any); ok {
if attachments, ok := o.GetProp(PostPropsAttachments).([]any); ok {
for _, attachment := range attachments {
if enc, err := json.Marshal(attachment); err == nil {
var decoded SlackAttachment
@@ -885,7 +1020,7 @@ func RewriteImageURLs(message string, f func(string) string) string {
func (o *Post) IsFromOAuthBot() bool {
props := o.GetProps()
return props["from_webhook"] == "true" && props["override_username"] != ""
return props[PostPropsFromWebhook] == "true" && props[PostPropsOverrideUsername] != ""
}
func (o *Post) ToNilIfInvalid() *Post {

Просмотреть файл

@@ -140,7 +140,7 @@ func TestPostSanitizeProps(t *testing.T) {
Props: StringInterface{
PropsAddChannelMember: "no good",
PostPropsForceNotification: "no good",
"attachments": "good",
PostPropsAttachments: "good",
},
}
@@ -149,7 +149,7 @@ func TestPostSanitizeProps(t *testing.T) {
require.Nil(t, post3.GetProp(PropsAddChannelMember))
require.Nil(t, post3.GetProp(PostPropsForceNotification))
require.NotNil(t, post3.GetProp("attachments"))
require.NotNil(t, post3.GetProp(PostPropsAttachments))
}
func TestPost_ContainsIntegrationsReservedProps(t *testing.T) {
@@ -162,11 +162,11 @@ func TestPost_ContainsIntegrationsReservedProps(t *testing.T) {
post2 := &Post{
Message: "test",
Props: StringInterface{
"from_webhook": "true",
"webhook_display_name": "overridden_display_name",
"override_username": "overridden_username",
"override_icon_url": "a-custom-url",
"override_icon_emoji": ":custom_emoji_name:",
PostPropsFromWebhook: "true",
PostPropsWebhookDisplayName: "overridden_display_name",
PostPropsOverrideUsername: "overridden_username",
PostPropsOverrideIconURL: "a-custom-url",
PostPropsOverrideIconEmoji: ":custom_emoji_name:",
},
}
keys2 := post2.ContainsIntegrationsReservedProps()
@@ -176,7 +176,7 @@ func TestPost_ContainsIntegrationsReservedProps(t *testing.T) {
func TestPostPatch_ContainsIntegrationsReservedProps(t *testing.T) {
postPatch1 := &PostPatch{
Props: &StringInterface{
"from_webhook": "true",
PostPropsFromWebhook: "true",
},
}
keys1 := postPatch1.ContainsIntegrationsReservedProps()
@@ -405,8 +405,8 @@ func TestPost_AttachmentsEqual(t *testing.T) {
},
} {
t.Run(name, func(t *testing.T) {
post1.AddProp("attachments", tc.Attachments1)
post2.AddProp("attachments", tc.Attachments2)
post1.AddProp(PostPropsAttachments, tc.Attachments1)
post2.AddProp(PostPropsAttachments, tc.Attachments2)
assert.Equal(t, tc.Expected, post1.AttachmentsEqual(post2))
})
}
@@ -895,7 +895,7 @@ func TestPostPatchDisableMentionHighlights(t *testing.T) {
func TestPostAttachments(t *testing.T) {
p := &Post{
Props: map[string]any{
"attachments": []byte(`[{
PostPropsAttachments: []byte(`[{
"actions" : {null}
}]
`),
@@ -903,7 +903,7 @@ func TestPostAttachments(t *testing.T) {
}
t.Run("empty actions", func(t *testing.T) {
p.Props["attachments"] = []any{
p.Props[PostPropsAttachments] = []any{
map[string]any{"actions": []any{}},
}
attachments := p.Attachments()
@@ -911,7 +911,7 @@ func TestPostAttachments(t *testing.T) {
})
t.Run("a couple of actions", func(t *testing.T) {
p.Props["attachments"] = []any{
p.Props[PostPropsAttachments] = []any{
map[string]any{"actions": []any{
map[string]any{"id": "test1"}, map[string]any{"id": "test2"}},
},
@@ -924,7 +924,7 @@ func TestPostAttachments(t *testing.T) {
})
t.Run("should ignore null actions", func(t *testing.T) {
p.Props["attachments"] = []any{
p.Props[PostPropsAttachments] = []any{
map[string]any{"actions": []any{
map[string]any{"id": "test1"}, nil, map[string]any{"id": "test2"}, nil, nil},
},
@@ -937,7 +937,7 @@ func TestPostAttachments(t *testing.T) {
})
t.Run("nil fields", func(t *testing.T) {
p.Props["attachments"] = []any{
p.Props[PostPropsAttachments] = []any{
map[string]any{"fields": []any{
map[string]any{"value": ":emoji1:"},
nil,
@@ -995,3 +995,278 @@ func TestPostPriority(t *testing.T) {
p.Metadata.Priority.Priority = NewPointer(PostPriorityUrgent)
require.True(t, p.IsUrgent())
}
func TestPost_PropsIsValid(t *testing.T) {
tests := map[string]struct {
props StringInterface
wantErr string
}{
"valid empty props": {
props: nil,
wantErr: "",
},
"valid props": {
props: StringInterface{
"key": "value",
},
wantErr: "",
},
"valid added_user_id": {
props: StringInterface{
PostPropsAddedUserId: NewId(),
},
wantErr: "",
},
"valid delete_by": {
props: StringInterface{
PostPropsDeleteBy: NewId(),
},
wantErr: "",
},
"valid override_icon_url": {
props: StringInterface{
PostPropsOverrideIconURL: "https://example.com/icon.png",
},
wantErr: "",
},
"valid override_icon_emoji": {
props: StringInterface{
PostPropsOverrideIconEmoji: ":smile:",
},
wantErr: "",
},
"valid override_username": {
props: StringInterface{
PostPropsOverrideUsername: "testuser",
},
wantErr: "",
},
"valid from_webhook": {
props: StringInterface{
PostPropsFromWebhook: "true",
},
wantErr: "",
},
"valid from_bot": {
props: StringInterface{
PostPropsFromBot: "true",
},
wantErr: "",
},
"valid from_oauth_app": {
props: StringInterface{
PostPropsFromOAuthApp: "true",
},
wantErr: "",
},
"valid from_plugin": {
props: StringInterface{
PostPropsFromPlugin: "true",
},
wantErr: "",
},
"valid unsafe_links": {
props: StringInterface{
PostPropsUnsafeLinks: "true",
},
wantErr: "",
},
"valid webhook_display_name": {
props: StringInterface{
PostPropsWebhookDisplayName: "My Webhook",
},
wantErr: "",
},
"valid mention_highlight_disabled": {
props: StringInterface{
PostPropsMentionHighlightDisabled: true,
},
wantErr: "",
},
"valid disable_group_highlight": {
props: StringInterface{
PostPropsGroupHighlightDisabled: true,
},
wantErr: "",
},
"valid previewed_post": {
props: StringInterface{
PostPropsPreviewedPost: NewId(),
},
wantErr: "",
},
"valid force_notification": {
props: StringInterface{
PostPropsForceNotification: true,
},
wantErr: "",
},
"valid multiple props": {
props: StringInterface{
PostPropsFromWebhook: "true",
PostPropsOverrideUsername: "webhook-user",
PostPropsOverrideIconURL: "https://example.com/icon.png",
PostPropsWebhookDisplayName: "My Webhook",
PostPropsMentionHighlightDisabled: true,
},
wantErr: "",
},
"invalid added_user_id type": {
props: StringInterface{
PostPropsAddedUserId: 123,
},
wantErr: "added_user_id prop must be a string",
},
"invalid added_user_id value": {
props: StringInterface{
PostPropsAddedUserId: "invalid-id",
},
wantErr: "added_user_id prop must be a valid user ID",
},
"invalid delete_by type": {
props: StringInterface{
PostPropsDeleteBy: 123,
},
wantErr: "delete_by prop must be a string",
},
"invalid delete_by value": {
props: StringInterface{
PostPropsDeleteBy: "invalid-id",
},
wantErr: "delete_by prop must be a valid user ID",
},
"invalid override_icon_url type": {
props: StringInterface{
PostPropsOverrideIconURL: 123,
},
wantErr: "override_icon_url prop must be a string",
},
"invalid override_icon_url value": {
props: StringInterface{
PostPropsOverrideIconURL: "not-a-url",
},
wantErr: "override_icon_url prop must be a valid URL",
},
"invalid override_icon_emoji type": {
props: StringInterface{
PostPropsOverrideIconEmoji: 123,
},
wantErr: "override_icon_emoji prop must be a string",
},
"invalid override_username type": {
props: StringInterface{
PostPropsOverrideUsername: 123,
},
wantErr: "override_username prop must be a string",
},
"invalid from_webhook type": {
props: StringInterface{
PostPropsFromWebhook: 123,
},
wantErr: "from_webhook prop must be a string",
},
"invalid from_webhook value": {
props: StringInterface{
PostPropsFromWebhook: "false",
},
wantErr: "from_webhook prop must be \"true\"",
},
"invalid from_bot type": {
props: StringInterface{
PostPropsFromBot: 123,
},
wantErr: "from_bot prop must be a string",
},
"invalid from_bot value": {
props: StringInterface{
PostPropsFromBot: "false",
},
wantErr: "from_bot prop must be \"true\"",
},
"invalid from_oauth_app type": {
props: StringInterface{
PostPropsFromOAuthApp: 123,
},
wantErr: "from_oauth_app prop must be a string",
},
"invalid from_oauth_app value": {
props: StringInterface{
PostPropsFromOAuthApp: "false",
},
wantErr: "from_oauth_app prop must be \"true\"",
},
"invalid from_plugin type": {
props: StringInterface{
PostPropsFromPlugin: 123,
},
wantErr: "from_plugin prop must be a string",
},
"invalid from_plugin value": {
props: StringInterface{
PostPropsFromPlugin: "false",
},
wantErr: "from_plugin prop must be \"true\"",
},
"invalid unsafe_links type": {
props: StringInterface{
PostPropsUnsafeLinks: 123,
},
wantErr: "unsafe_links prop must be a string",
},
"invalid unsafe_links value": {
props: StringInterface{
PostPropsUnsafeLinks: "false",
},
wantErr: "unsafe_links prop must be \"true\"",
},
"invalid webhook_display_name type": {
props: StringInterface{
PostPropsWebhookDisplayName: 123,
},
wantErr: "webhook_display_name prop must be a string",
},
"invalid mention_highlight_disabled type": {
props: StringInterface{
PostPropsMentionHighlightDisabled: "true",
},
wantErr: "mention_highlight_disabled prop must be a boolean",
},
"invalid disable_group_highlight type": {
props: StringInterface{
PostPropsGroupHighlightDisabled: "true",
},
wantErr: "disable_group_highlight prop must be a boolean",
},
"invalid previewed_post type": {
props: StringInterface{
PostPropsPreviewedPost: 123,
},
wantErr: "previewed_post prop must be a string",
},
"invalid previewed_post value": {
props: StringInterface{
PostPropsPreviewedPost: "invalid-id",
},
wantErr: "previewed_post prop must be a valid post ID",
},
"invalid force_notification type": {
props: StringInterface{
PostPropsForceNotification: "true",
},
wantErr: "force_notification prop must be a boolean",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
p := &Post{}
p.SetProps(tc.props)
err := p.propsIsValid()
if tc.wantErr == "" {
assert.NoError(t, err)
} else {
assert.ErrorContains(t, err, tc.wantErr)
}
})
}
}

Просмотреть файл

@@ -6,9 +6,15 @@ package model
import (
"fmt"
"regexp"
"slices"
"github.com/hashicorp/go-multierror"
)
var linkWithTextRegex = regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`)
var (
linkWithTextRegex = regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`)
hexColorRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
)
type SlackAttachment struct {
Id int64 `json:"id"`
@@ -30,6 +36,78 @@ type SlackAttachment struct {
Actions []*PostAction `json:"actions,omitempty"`
}
func (s *SlackAttachment) IsValid() error {
var multiErr *multierror.Error
if s.Color != "" {
validStyles := []string{"good", "warning", "danger"}
// If not a predefined style, check if it's a hex color
if !slices.Contains(validStyles, s.Color) && !hexColorRegex.MatchString(s.Color) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid style '%s' - must be one of [good, warning, danger] or a hex color", s.Color))
}
}
if s.AuthorLink != "" {
if s.AuthorName == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("author link cannot be set without author name"))
}
if !IsValidHTTPURL(s.AuthorLink) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid author link URL"))
}
}
if s.AuthorIcon != "" && !IsValidHTTPURL(s.AuthorIcon) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid author icon URL"))
}
if s.TitleLink != "" {
if s.Title == "" {
multiErr = multierror.Append(multiErr, fmt.Errorf("title link cannot be set without title"))
}
if !IsValidHTTPURL(s.TitleLink) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid title link URL"))
}
}
for _, field := range s.Fields {
if err := field.IsValid(); err != nil {
multiErr = multierror.Append(multiErr, err)
}
}
if s.ImageURL != "" && !IsValidHTTPURL(s.ImageURL) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid image URL"))
}
if s.ThumbURL != "" && !IsValidHTTPURL(s.ThumbURL) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid thumb URL"))
}
if s.FooterIcon != "" && !IsValidHTTPURL(s.FooterIcon) {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid footer icon URL"))
}
// Validate timestamp is either string or int64
if s.Timestamp != nil {
switch s.Timestamp.(type) {
case string, int64:
// Valid types
default:
multiErr = multierror.Append(multiErr, fmt.Errorf("timestamp must be either a string or int64"))
}
}
for i, action := range s.Actions {
if err := action.IsValid(); err != nil {
multiErr = multierror.Append(multiErr, multierror.Prefix(err, fmt.Sprintf("action at index %d is invalid:", i)))
}
}
return multiErr.ErrorOrNil()
}
func (s *SlackAttachment) Equals(input *SlackAttachment) bool {
// Direct comparison of simple types
@@ -120,6 +198,21 @@ type SlackAttachmentField struct {
Short SlackCompatibleBool `json:"short"`
}
func (s *SlackAttachmentField) IsValid() error {
var multiErr *multierror.Error
if s.Value != nil {
switch s.Value.(type) {
case string, int:
// Valid types
default:
multiErr = multierror.Append(multiErr, fmt.Errorf("value must be either a string or int"))
}
}
return multiErr.ErrorOrNil()
}
func (s *SlackAttachmentField) Equals(input *SlackAttachmentField) bool {
if s.Title != input.Title {
return false
@@ -188,7 +281,7 @@ func ParseSlackAttachment(post *Post, attachments []*SlackAttachment) {
}
postAttachments = append(postAttachments, attachment)
}
post.AddProp("attachments", postAttachments)
post.AddProp(PostPropsAttachments, postAttachments)
}
func ParseSlackLinksToMarkdown(text string) string {

Просмотреть файл

@@ -9,6 +9,165 @@ import (
"github.com/stretchr/testify/assert"
)
func TestSlackAttachment_IsValid(t *testing.T) {
tests := map[string]struct {
attachment *SlackAttachment
wantErr string
}{
"valid attachment": {
attachment: &SlackAttachment{
Text: "This is a test",
},
wantErr: "",
},
"invalid color": {
attachment: &SlackAttachment{
Color: "invalid",
},
wantErr: "invalid style 'invalid' - must be one of [good, warning, danger] or a hex color",
},
"valid predefined color": {
attachment: &SlackAttachment{
Color: "good",
},
wantErr: "",
},
"valid warning color": {
attachment: &SlackAttachment{
Color: "warning",
},
wantErr: "",
},
"valid danger color": {
attachment: &SlackAttachment{
Color: "danger",
},
wantErr: "",
},
"valid hex color": {
attachment: &SlackAttachment{
Color: "#FF0000",
},
wantErr: "",
},
"author link without name": {
attachment: &SlackAttachment{
AuthorLink: "http://example.com",
},
wantErr: "author link cannot be set without author name",
},
"invalid author link": {
attachment: &SlackAttachment{
AuthorName: "Author",
AuthorLink: "invalid-url",
},
wantErr: "invalid author link URL",
},
"invalid author icon": {
attachment: &SlackAttachment{
AuthorIcon: "invalid-url",
},
wantErr: "invalid author icon URL",
},
"title link without title": {
attachment: &SlackAttachment{
TitleLink: "http://example.com",
},
wantErr: "title link cannot be set without title",
},
"invalid title link": {
attachment: &SlackAttachment{
Title: "Title",
TitleLink: "invalid-url",
},
wantErr: "invalid title link URL",
},
"invalid image URL": {
attachment: &SlackAttachment{
ImageURL: "invalid-url",
},
wantErr: "invalid image URL",
},
"invalid thumb URL": {
attachment: &SlackAttachment{
ThumbURL: "invalid-url",
},
wantErr: "invalid thumb URL",
},
"invalid footer icon": {
attachment: &SlackAttachment{
FooterIcon: "invalid-url",
},
wantErr: "invalid footer icon URL",
},
"invalid timestamp type": {
attachment: &SlackAttachment{
Timestamp: []string{"invalid"},
},
wantErr: "timestamp must be either a string or int64",
},
"valid timestamp string": {
attachment: &SlackAttachment{
Timestamp: "1234567890",
},
wantErr: "",
},
"valid timestamp int64": {
attachment: &SlackAttachment{
Timestamp: int64(1234567890),
},
wantErr: "",
},
"invalid action": {
attachment: &SlackAttachment{
Actions: []*PostAction{
{
Name: "", // Invalid - missing name
},
},
},
wantErr: "action must have a name",
},
"invalid field value type": {
attachment: &SlackAttachment{
Fields: []*SlackAttachmentField{
{
Title: "Title",
Value: []string{"invalid"},
},
},
},
wantErr: "value must be either a string or int",
},
"valid fields": {
attachment: &SlackAttachment{
Fields: []*SlackAttachmentField{
{
Title: "Title",
Value: "string value",
},
{
Title: "Number",
Value: 42,
},
},
},
wantErr: "",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
err := tc.attachment.IsValid()
if tc.wantErr == "" {
assert.NoError(t, err, name)
} else {
assert.ErrorContains(t, err, tc.wantErr, name)
}
})
}
}
func TestParseSlackAttachment(t *testing.T) {
t.Run("empty list", func(t *testing.T) {
post := &Post{}
@@ -19,7 +178,7 @@ func TestParseSlackAttachment(t *testing.T) {
expectedPost := &Post{
Type: PostTypeSlackAttachment,
Props: map[string]any{
"attachments": []*SlackAttachment{},
PostPropsAttachments: []*SlackAttachment{},
},
}
assert.Equal(t, expectedPost, post)
@@ -36,7 +195,7 @@ func TestParseSlackAttachment(t *testing.T) {
expectedPost := &Post{
Type: PostTypeSlackAttachment,
Props: map[string]any{
"attachments": []*SlackAttachment{},
PostPropsAttachments: []*SlackAttachment{},
},
}
assert.Equal(t, expectedPost, post)

Просмотреть файл

@@ -106,7 +106,7 @@ func TestDMWithAttachments(t *testing.T) {
ChannelId: dmChannelID,
Type: model.PostTypeSlackAttachment,
Props: model.StringInterface{
"attachments": attachments,
model.PostPropsAttachments: attachments,
},
}

Просмотреть файл

@@ -196,7 +196,7 @@ func (f *Flow) handle(
}
func (f *Flow) processButtonPostActions(post *model.Post) {
attachments, ok := post.GetProp("attachments").([]*model.SlackAttachment)
attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment)
if !ok || len(attachments) == 0 {
return
}

Просмотреть файл

@@ -145,7 +145,7 @@ func (s Step) render(f *Flow, done bool, selectedButton int) (*model.Post, bool,
buttons := processButtons(s.buttons, f.state.AppState)
attachments, ok := post.GetProp("attachments").([]*model.SlackAttachment)
attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment)
if !ok || len(attachments) != 1 {
return nil, false, errors.New("expected 1 slack attachment")
}
@@ -224,6 +224,7 @@ func processDialog(in *model.Dialog, state State) model.Dialog {
func renderButton(b Button, stepName Name, i int, state State) *model.PostAction {
return &model.PostAction{
Type: model.PostActionTypeButton,
Name: formatState(b.Name, state),
Disabled: b.Disabled,
Style: string(b.Color),

Просмотреть файл

@@ -75,6 +75,7 @@ func (s *boolSetting) GetSlackAttachments(userID, settingHandler string, disable
currentValueMessage = fmt.Sprintf("Current value: %s", currentTextValue)
actionTrue := model.PostAction{
Type: model.PostActionTypeButton,
Name: "Yes",
Integration: &model.PostActionIntegration{
URL: settingHandler,
@@ -86,6 +87,7 @@ func (s *boolSetting) GetSlackAttachments(userID, settingHandler string, disable
}
actionFalse := model.PostAction{
Type: model.PostActionTypeButton,
Name: "No",
Integration: &model.PostActionIntegration{
URL: settingHandler,

Просмотреть файл

@@ -62,6 +62,7 @@ func (s *optionSetting) GetSlackAttachments(userID, settingHandler string, disab
currentValueMessage = fmt.Sprintf("Current value: %s", currentTextValue)
actionOptions := model.PostAction{
Type: model.PostActionTypeSelect,
Name: "Select an option:",
Integration: &model.PostActionIntegration{
URL: settingHandler + "?" + s.id + "=true",
@@ -69,7 +70,6 @@ func (s *optionSetting) GetSlackAttachments(userID, settingHandler string, disab
ContextIDKey: s.id,
},
},
Type: "select",
Options: stringsToOptions(s.options),
}

Просмотреть файл

@@ -297,7 +297,7 @@ func (p *PostService) ShouldProcessMessage(post *model.Post, options ...ShouldPr
return false, nil
}
if !messageProcessOptions.AllowWebhook && post.GetProp("from_webhook") == "true" {
if !messageProcessOptions.AllowWebhook && post.GetProp(model.PostPropsFromWebhook) == "true" {
return false, nil
}

Просмотреть файл

@@ -660,7 +660,7 @@ func TestShouldProcessMessage(t *testing.T) {
client := pluginapi.NewClient(api, &plugintest.Driver{})
shouldProcessMessage, err := client.Post.ShouldProcessMessage(
&model.Post{ChannelId: channelID, Props: model.StringInterface{"from_webhook": "true"}},
&model.Post{ChannelId: channelID, Props: model.StringInterface{model.PostPropsFromWebhook: "true"}},
pluginapi.AllowBots(),
)
@@ -677,7 +677,7 @@ func TestShouldProcessMessage(t *testing.T) {
client := pluginapi.NewClient(api, &plugintest.Driver{})
shouldProcessMessage, err := client.Post.ShouldProcessMessage(
&model.Post{ChannelId: channelID, Props: model.StringInterface{"from_webhook": "true"}},
&model.Post{ChannelId: channelID, Props: model.StringInterface{model.PostPropsFromWebhook: "true"}},
pluginapi.AllowBots(),
pluginapi.AllowWebhook(),
)
@@ -712,7 +712,7 @@ func TestShouldProcessMessage(t *testing.T) {
client := pluginapi.NewClient(api, &plugintest.Driver{})
shouldProcessMessage, err := client.Post.ShouldProcessMessage(
&model.Post{ChannelId: channelID, Props: model.StringInterface{"from_webhook": "false"}},
&model.Post{ChannelId: channelID, Props: model.StringInterface{model.PostPropsFromWebhook: "false"}},
pluginapi.AllowBots(),
)