[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
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
5609489e86
Коммит
9b5d8d52bf
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
Ссылка в новой задаче
Block a user