diff --git a/pkg/services/ngalert/notifier/channels/slack.go b/pkg/services/ngalert/notifier/channels/slack.go index 4b424a0f40a..57fee6e2541 100644 --- a/pkg/services/ngalert/notifier/channels/slack.go +++ b/pkg/services/ngalert/notifier/channels/slack.go @@ -8,9 +8,12 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net" "net/http" "net/url" + "os" + "path" "strings" "time" @@ -25,8 +28,34 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +const ( + // maxImagesPerThreadTs is the maximum number of images that can be posted as + // replies to the same thread_ts. It should prevent tokens from exceeding the + // rate limits for files.upload https://api.slack.com/docs/rate-limits#tier_t2 + maxImagesPerThreadTs = 5 + maxImagesPerThreadTsMessage = "There are more images than can be shown here. To see the panels for all firing and resolved alerts please check Grafana" +) + +var ( + slackClient = &http.Client{ + Timeout: time.Second * 30, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + Renegotiation: tls.RenegotiateFreelyAsClient, + }, + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + }, + } +) + var SlackAPIEndpoint = "https://slack.com/api/chat.postMessage" +type sendFunc func(ctx context.Context, req *http.Request, logger log.Logger) (string, error) + // SlackNotifier is responsible for sending // alert notification to Slack. type SlackNotifier struct { @@ -35,6 +64,7 @@ type SlackNotifier struct { tmpl *template.Template images ImageStore webhookSender notifications.WebhookSender + sendFn sendFunc settings slackSettings } @@ -53,6 +83,22 @@ type slackSettings struct { MentionGroups CommaSeparatedStrings `json:"mentionGroups,omitempty" yaml:"mentionGroups,omitempty"` } +// isIncomingWebhook returns true if the settings are for an incoming webhook. +func isIncomingWebhook(s slackSettings) bool { + return s.Token == "" +} + +// uploadURL returns the upload URL for Slack. +func uploadURL(s slackSettings) (string, error) { + u, err := url.Parse(s.URL) + if err != nil { + return "", fmt.Errorf("failed to parse URL: %w", err) + } + dir, _ := path.Split(u.Path) + u.Path = path.Join(dir, "files.upload") + return u.String(), nil +} + // SlackFactory creates a new NotificationChannel that sends notifications to Slack. func SlackFactory(fc FactoryConfig) (NotificationChannel, error) { ch, err := buildSlackNotifier(fc) @@ -80,6 +126,7 @@ func buildSlackNotifier(factoryConfig FactoryConfig) (*SlackNotifier, error) { if slackURL == "" { slackURL = settings.EndpointURL } + apiURL, err := url.Parse(slackURL) if err != nil { return nil, fmt.Errorf("invalid URL %q", slackURL) @@ -106,7 +153,6 @@ func buildSlackNotifier(factoryConfig FactoryConfig) (*SlackNotifier, error) { if settings.Title == "" { settings.Title = DefaultMessageTitleEmbed } - return &SlackNotifier{ Base: NewBase(&models.AlertNotification{ Uid: factoryConfig.Config.UID, @@ -119,6 +165,7 @@ func buildSlackNotifier(factoryConfig FactoryConfig) (*SlackNotifier, error) { images: factoryConfig.ImageStore, webhookSender: factoryConfig.NotificationService, + sendFn: sendSlackRequest, log: log.New("alerting.notifier.slack"), tmpl: factoryConfig.Template, }, nil @@ -133,6 +180,7 @@ type slackMessage struct { IconURL string `json:"icon_url,omitempty"` Attachments []attachment `json:"attachments"` Blocks []map[string]interface{} `json:"blocks,omitempty"` + ThreadTs string `json:"thread_ts,omitempty"` } // attachment is used to display a richly-formatted message block. @@ -153,36 +201,41 @@ type attachment struct { // Notify sends an alert notification to Slack. func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) { - sn.log.Debug("building slack message", "alerts", len(alerts)) - msg, err := sn.buildSlackMessage(ctx, alerts) + sn.log.Debug("Creating slack message", "alerts", len(alerts)) + + m, err := sn.createSlackMessage(ctx, alerts) if err != nil { - return false, fmt.Errorf("build slack message: %w", err) + sn.log.Error("Failed to create Slack message", "err", err) + return false, fmt.Errorf("failed to create Slack message: %w", err) } - b, err := json.Marshal(msg) + thread_ts, err := sn.sendSlackMessage(ctx, m) if err != nil { - return false, fmt.Errorf("marshal json: %w", err) + sn.log.Error("Failed to send Slack message", "err", err) + return false, fmt.Errorf("failed to send Slack message: %w", err) } - sn.log.Debug("sending Slack API request", "url", sn.settings.URL, "data", string(b)) - request, err := http.NewRequestWithContext(ctx, http.MethodPost, sn.settings.URL, bytes.NewReader(b)) - if err != nil { - return false, fmt.Errorf("failed to create HTTP request: %w", err) - } - - request.Header.Set("Content-Type", "application/json") - request.Header.Set("User-Agent", "Grafana") - if sn.settings.Token == "" { - if sn.settings.URL == SlackAPIEndpoint { - panic("Token should be set when using the Slack chat API") + // Do not upload images if using an incoming webhook as incoming webhooks cannot upload files + if !isIncomingWebhook(sn.settings) { + if err := withStoredImages(ctx, sn.log, sn.images, func(index int, image ngmodels.Image) error { + // If we have exceeded the maximum number of images for this thread_ts + // then tell the recipient and stop iterating subsequent images + if index >= maxImagesPerThreadTs { + if _, err := sn.sendSlackMessage(ctx, &slackMessage{ + Channel: sn.settings.Recipient, + Text: maxImagesPerThreadTsMessage, + ThreadTs: thread_ts, + }); err != nil { + sn.log.Error("Failed to send Slack message", "err", err) + } + return ErrImagesDone + } + comment := initialCommentForImage(alerts[index]) + return sn.uploadImage(ctx, image, sn.settings.Recipient, comment, thread_ts) + }, alerts...); err != nil { + // Do not return an error here as we might have exceeded the rate limit for uploading files + sn.log.Error("Failed to upload image", "err", err) } - } else { - sn.log.Debug("adding authorization header to HTTP request") - request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sn.settings.Token)) - } - - if err := sendSlackRequest(request, sn.log); err != nil { - return false, err } return true, nil @@ -190,74 +243,117 @@ func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo // sendSlackRequest sends a request to the Slack API. // Stubbable by tests. -var sendSlackRequest = func(request *http.Request, logger log.Logger) (retErr error) { - defer func() { - if retErr != nil { - logger.Warn("failed to send slack request", "error", retErr) - } - }() - - netTransport := &http.Transport{ - TLSClientConfig: &tls.Config{ - Renegotiation: tls.RenegotiateFreelyAsClient, - }, - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - }).DialContext, - TLSHandshakeTimeout: 5 * time.Second, - } - netClient := &http.Client{ - Timeout: time.Second * 30, - Transport: netTransport, - } - resp, err := netClient.Do(request) +var sendSlackRequest = func(ctx context.Context, req *http.Request, logger log.Logger) (string, error) { + resp, err := slackClient.Do(req) if err != nil { - return err + return "", fmt.Errorf("failed to send request: %w", err) } + defer func() { if err := resp.Body.Close(); err != nil { - logger.Warn("failed to close response body", "error", err) + logger.Warn("Failed to close response body", "err", err) } }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) + if resp.StatusCode < http.StatusOK { + logger.Error("Unexpected 1xx response", "status", resp.StatusCode) + return "", fmt.Errorf("unexpected 1xx status code: %d", resp.StatusCode) + } else if resp.StatusCode >= 300 && resp.StatusCode < 400 { + logger.Error("Unexpected 3xx response", "status", resp.StatusCode) + return "", fmt.Errorf("unexpected 3xx status code: %d", resp.StatusCode) + } else if resp.StatusCode >= http.StatusInternalServerError { + logger.Error("Unexpected 5xx response", "status", resp.StatusCode) + return "", fmt.Errorf("unexpected 5xx status code: %d", resp.StatusCode) } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - logger.Error("Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status, "body", string(body)) - return fmt.Errorf("request to Slack API failed with status code %d", resp.StatusCode) + content := resp.Header.Get("Content-Type") + // If the response is text/html it could be the response to an incoming webhook + if strings.HasPrefix(content, "text/html") { + return handleSlackIncomingWebhookResponse(resp, logger) + } else { + return handleSlackJSONResponse(resp, logger) + } +} + +func handleSlackIncomingWebhookResponse(resp *http.Response, logger log.Logger) (string, error) { + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Incoming webhooks return the string "ok" on success + if bytes.Equal(b, []byte("ok")) { + logger.Debug("The incoming webhook was successful") + return "", nil + } + + logger.Debug("Incoming webhook was unsuccessful", "status", resp.StatusCode, "body", string(b)) + + // There are a number of known errors that we can check. The documentation incoming webhooks + // errors can be found at https://api.slack.com/messaging/webhooks#handling_errors and + // https://api.slack.com/changelog/2016-05-17-changes-to-errors-for-incoming-webhooks + if bytes.Equal(b, []byte("user_not_found")) { + return "", errors.New("the user does not exist or is invalid") + } + + if bytes.Equal(b, []byte("channel_not_found")) { + return "", errors.New("the channel does not exist or is invalid") + } + + if bytes.Equal(b, []byte("channel_is_archived")) { + return "", errors.New("cannot send an incoming webhook for an archived channel") + } + + if bytes.Equal(b, []byte("posting_to_general_channel_denied")) { + return "", errors.New("cannot send an incoming webhook to the #general channel") + } + + if bytes.Equal(b, []byte("no_service")) { + return "", errors.New("the incoming webhook is either disabled, removed, or invalid") + } + + if bytes.Equal(b, []byte("no_text")) { + return "", errors.New("cannot send an incoming webhook without a message") + } + + return "", fmt.Errorf("failed incoming webhook: %s", string(b)) +} + +func handleSlackJSONResponse(resp *http.Response, logger log.Logger) (string, error) { + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if len(b) == 0 { + logger.Error("Expected JSON but got empty response") + return "", errors.New("unexpected empty response") } // Slack responds to some requests with a JSON document, that might contain an error. - rslt := struct { - Ok bool `json:"ok"` + result := struct { + OK bool `json:"ok"` + Ts string `json:"ts"` Err string `json:"error"` }{} - // Marshaling can fail if Slack's response body is plain text (e.g. "ok"). - if err := json.Unmarshal(body, &rslt); err != nil && json.Valid(body) { - logger.Error("Failed to unmarshal Slack API response", "url", request.URL.String(), "statusCode", resp.Status, - "body", string(body)) - return fmt.Errorf("failed to unmarshal Slack API response: %s", err) + if err := json.Unmarshal(b, &result); err != nil { + logger.Error("Failed to unmarshal response", "body", string(b), "err", err) + return "", fmt.Errorf("failed to unmarshal response: %w", err) } - if !rslt.Ok && rslt.Err != "" { - logger.Error("Sending Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status, - "error", rslt.Err) - return fmt.Errorf("failed to make Slack API request: %s", rslt.Err) + if !result.OK { + logger.Error("The request was unsuccessful", "body", string(b), "err", result.Err) + return "", fmt.Errorf("failed to send request: %s", result.Err) } - logger.Debug("sending Slack API request succeeded", "url", request.URL.String(), "statusCode", resp.Status) - return nil + logger.Debug("The request was successful") + return result.Ts, nil } -func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, alrts []*types.Alert) (*slackMessage, error) { - alerts := types.Alerts(alrts...) +func (sn *SlackNotifier) createSlackMessage(ctx context.Context, alerts []*types.Alert) (*slackMessage, error) { var tmplErr error - tmpl, _ := TmplText(ctx, sn.tmpl, alrts, sn.log, &tmplErr) + tmpl, _ := TmplText(ctx, sn.tmpl, alerts, sn.log, &tmplErr) ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) @@ -270,7 +366,7 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, alrts []*types.A // https://api.slack.com/messaging/composing/layouts#when-to-use-attachments Attachments: []attachment{ { - Color: getAlertStatusColor(alerts.Status()), + Color: getAlertStatusColor(types.Alerts(alerts...).Status()), Title: tmpl(sn.settings.Title), Fallback: tmpl(sn.settings.Title), Footer: "Grafana v" + setting.BuildVersion, @@ -283,10 +379,16 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, alrts []*types.A }, } - _ = withStoredImages(ctx, sn.log, sn.images, func(index int, image ngmodels.Image) error { - req.Attachments[0].ImageURL = image.URL - return ErrImagesDone - }, alrts...) + if isIncomingWebhook(sn.settings) { + // Incoming webhooks cannot upload files, instead share images via their URL + _ = withStoredImages(ctx, sn.log, sn.images, func(index int, image ngmodels.Image) error { + if image.URL != "" { + req.Attachments[0].ImageURL = image.URL + return ErrImagesDone + } + return nil + }, alerts...) + } if tmplErr != nil { sn.log.Warn("failed to template Slack message", "error", tmplErr.Error()) @@ -298,16 +400,19 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, alrts []*types.A mentionsBuilder.WriteString(" ") } } + mentionChannel := strings.TrimSpace(sn.settings.MentionChannel) if mentionChannel != "" { mentionsBuilder.WriteString(fmt.Sprintf("", mentionChannel, mentionChannel)) } + if len(sn.settings.MentionGroups) > 0 { appendSpace() for _, g := range sn.settings.MentionGroups { mentionsBuilder.WriteString(fmt.Sprintf("", tmpl(g))) } } + if len(sn.settings.MentionUsers) > 0 { appendSpace() for _, u := range sn.settings.MentionUsers { @@ -324,6 +429,159 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, alrts []*types.A return req, nil } +func (sn *SlackNotifier) sendSlackMessage(ctx context.Context, m *slackMessage) (string, error) { + b, err := json.Marshal(m) + if err != nil { + return "", fmt.Errorf("failed to marshal Slack message: %w", err) + } + + sn.log.Debug("sending Slack API request", "url", sn.settings.URL, "data", string(b)) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, sn.settings.URL, bytes.NewReader(b)) + if err != nil { + return "", fmt.Errorf("failed to create HTTP request: %w", err) + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "Grafana") + if sn.settings.Token == "" { + if sn.settings.URL == SlackAPIEndpoint { + panic("Token should be set when using the Slack chat API") + } + sn.log.Debug("Looks like we are using an incoming webhook, no Authorization header required") + } else { + sn.log.Debug("Looks like we are using the Slack API, have set the Bearer token for this request") + request.Header.Set("Authorization", "Bearer "+sn.settings.Token) + } + + thread_ts, err := sn.sendFn(ctx, request, sn.log) + if err != nil { + return "", err + } + + return thread_ts, nil +} + +// createImageMultipart returns the mutlipart/form-data request and headers for files.upload. +// It returns an error if the image does not exist or there was an error preparing the +// multipart form. +func (sn *SlackNotifier) createImageMultipart(image ngmodels.Image, channel, comment, thread_ts string) (http.Header, []byte, error) { + buf := bytes.Buffer{} + w := multipart.NewWriter(&buf) + defer func() { + if err := w.Close(); err != nil { + sn.log.Error("Failed to close multipart writer", "err", err) + } + }() + + f, err := os.Open(image.Path) + if err != nil { + return nil, nil, err + } + + fw, err := w.CreateFormFile("file", image.Path) + if err != nil { + return nil, nil, fmt.Errorf("failed to create form file: %w", err) + } + + if _, err := io.Copy(fw, f); err != nil { + return nil, nil, fmt.Errorf("failed to copy file to form: %w", err) + } + + if err := w.WriteField("channels", channel); err != nil { + return nil, nil, fmt.Errorf("failed to write channels to form: %w", err) + } + + if err := w.WriteField("initial_comment", comment); err != nil { + return nil, nil, fmt.Errorf("failed to write initial_comment to form: %w", err) + } + + if err := w.WriteField("thread_ts", thread_ts); err != nil { + return nil, nil, fmt.Errorf("failed to write thread_ts to form: %w", err) + } + + if err := w.Close(); err != nil { + return nil, nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + b := buf.Bytes() + headers := http.Header{} + headers.Set("Content-Type", w.FormDataContentType()) + return headers, b, nil +} + +func (sn *SlackNotifier) sendMultipart(ctx context.Context, headers http.Header, data io.Reader) error { + sn.log.Debug("Sending multipart request to files.upload") + + u, err := uploadURL(sn.settings) + if err != nil { + return fmt.Errorf("failed to get URL for files.upload: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, u, data) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + for k, v := range headers { + req.Header[k] = v + } + req.Header.Set("Authorization", "Bearer "+sn.settings.Token) + + if _, err := sn.sendFn(ctx, req, sn.log); err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + + return nil +} + +// uploadImage shares the image to the channel names or IDs. It returns an error if the file +// does not exist, or if there was an error either preparing or sending the multipart/form-data +// request. +func (sn *SlackNotifier) uploadImage(ctx context.Context, image ngmodels.Image, channel, comment, thread_ts string) error { + sn.log.Debug("Uploadimg image", "image", image.Token) + headers, data, err := sn.createImageMultipart(image, channel, comment, thread_ts) + if err != nil { + return fmt.Errorf("failed to create multipart form: %w", err) + } + + return sn.sendMultipart(ctx, headers, bytes.NewReader(data)) +} + func (sn *SlackNotifier) SendResolved() bool { return !sn.GetDisableResolveMessage() } + +// initialCommentForImage returns the initial comment for the image. +// Here is an example of the initial comment for an alert called +// AlertName with two labels: +// +// Resolved|Firing: AlertName, Labels: A=B, C=D +// +// where Resolved|Firing and Labels is in bold text. +func initialCommentForImage(alert *types.Alert) string { + sb := strings.Builder{} + + if alert.Resolved() { + sb.WriteString("*Resolved*:") + } else { + sb.WriteString("*Firing*:") + } + + sb.WriteString(" ") + sb.WriteString(alert.Name()) + sb.WriteString(", ") + + sb.WriteString("*Labels*: ") + + var n int + for k, v := range alert.Labels { + sb.WriteString(string(k)) + sb.WriteString(" = ") + sb.WriteString(string(v)) + if n < len(alert.Labels)-1 { + sb.WriteString(", ") + n += 1 + } + } + + return sb.String() +} diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index 5c450f9f248..98fa6278b8a 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -3,342 +3,499 @@ package channels import ( "context" "encoding/json" + "errors" "io" + "mime" + "mime/multipart" "net/http" "net/http/httptest" "net/url" + "os" "testing" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/types" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/setting" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" ) -func TestSlackNotifier(t *testing.T) { - tmpl := templateForTests(t) - - fakeImageStore := &fakeImageStore{ - Images: []*models.Image{ - { - Token: "test-with-url", - URL: "https://www.example.com/image.jpg", +func TestSlackIncomingWebhook(t *testing.T) { + tests := []struct { + name string + alerts []*types.Alert + expectedMessage *slackMessage + expectedError string + settings string + }{{ + name: "Message is sent", + settings: `{ + "icon_emoji": ":emoji:", + "recipient": "#test", + "url": "https://example.com/hooks/xxxx" + }`, + alerts: []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }}, + expectedMessage: &slackMessage{ + Channel: "#test", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "[FIRING:1] (val1)", + TitleLink: "http://localhost/alerting/list", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", + Fallback: "[FIRING:1] (val1)", + Fields: nil, + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", + Color: "#D63232", + }, }, }, - } + }, { + name: "Message is sent with image URL", + settings: `{ + "icon_emoji": ":emoji:", + "recipient": "#test", + "url": "https://example.com/hooks/xxxx" + }`, + alerts: []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "image-with-url"}, + }, + }}, + expectedMessage: &slackMessage{ + Channel: "#test", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "[FIRING:1] (val1)", + TitleLink: "http://localhost/alerting/list", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Fallback: "[FIRING:1] (val1)", + Fields: nil, + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", + Color: "#D63232", + ImageURL: "https://www.example.com/test.png", + }, + }, + }, + }, { + name: "Message is sent and image on local disk is ignored", + settings: `{ + "icon_emoji": ":emoji:", + "recipient": "#test", + "url": "https://example.com/hooks/xxxx" + }`, + alerts: []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "image-on-disk"}, + }, + }}, + expectedMessage: &slackMessage{ + Channel: "#test", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "[FIRING:1] (val1)", + TitleLink: "http://localhost/alerting/list", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Fallback: "[FIRING:1] (val1)", + Fields: nil, + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", + Color: "#D63232", + }, + }, + }, + }} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + notifier, recorder, err := setupSlackForTests(t, test.settings) + require.NoError(t, err) + + ctx := context.Background() + ctx = notify.WithGroupKey(ctx, "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + + ok, err := notifier.Notify(ctx, test.alerts...) + if test.expectedError != "" { + assert.EqualError(t, err, test.expectedError) + assert.False(t, ok) + } else { + assert.NoError(t, err) + assert.True(t, ok) + + // When sending a notification to an Incoming Webhook there should a single request. + // This is different from PostMessage where some content, such as images, are sent + // as replies to the original message + require.Len(t, recorder.requests, 1) + + // Get the request and check that it's sending to the URL of the Incoming Webhook + r := recorder.requests[0] + assert.Equal(t, notifier.settings.URL, r.URL.String()) + + // Check that the request contains the expected message + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + + message := slackMessage{} + require.NoError(t, json.Unmarshal(b, &message)) + for i, v := range message.Attachments { + // Need to update the ts as these cannot be set in the test definition + test.expectedMessage.Attachments[i].Ts = v.Ts + } + assert.Equal(t, *test.expectedMessage, message) + } + }) + } +} + +func TestSlackPostMessage(t *testing.T) { + tests := []struct { + name string + alerts []*types.Alert + expectedMessage *slackMessage + expectedReplies []interface{} // can contain either slackMessage or map[string]struct{} for multipart/form-data + expectedError string + settings string + }{{ + name: "Message is sent", + settings: `{ + "icon_emoji": ":emoji:", + "recipient": "#test", + "token": "1234" + }`, + alerts: []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }}, + expectedMessage: &slackMessage{ + Channel: "#test", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "[FIRING:1] (val1)", + TitleLink: "http://localhost/alerting/list", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Fallback: "[FIRING:1] (val1)", + Fields: nil, + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", + Color: "#D63232", + }, + }, + }, + }, { + name: "Message is sent with two firing alerts", + settings: `{ + "title": "{{ .Alerts.Firing | len }} firing, {{ .Alerts.Resolved | len }} resolved", + "icon_emoji": ":emoji:", + "recipient": "#test", + "token": "1234" + }`, + alerts: []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }}, + expectedMessage: &slackMessage{ + Channel: "#test", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "2 firing, 0 resolved", + TitleLink: "http://localhost/alerting/list", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", + Fallback: "2 firing, 0 resolved", + Fields: nil, + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", + Color: "#D63232", + }, + }, + }, + }, { + name: "Message is sent and image is uploaded", + settings: `{ + "icon_emoji": ":emoji:", + "recipient": "#test", + "token": "1234" + }`, + alerts: []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "image-on-disk"}, + }, + }}, + expectedMessage: &slackMessage{ + Channel: "#test", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "[FIRING:1] (val1)", + TitleLink: "http://localhost/alerting/list", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + Fallback: "[FIRING:1] (val1)", + Fields: nil, + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", + Color: "#D63232", + }, + }, + }, + expectedReplies: []interface{}{ + // check that the following parts are present in the multipart/form-data + map[string]struct{}{ + "file": {}, + "channels": {}, + "initial_comment": {}, + "thread_ts": {}, + }, + }, + }, { + name: "Message is sent to custom URL", + settings: `{ + "icon_emoji": ":emoji:", + "recipient": "#test", + "endpointUrl": "https://example.com/api", + "token": "1234" + }`, + alerts: []*types.Alert{{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }}, + expectedMessage: &slackMessage{ + Channel: "#test", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "[FIRING:1] (val1)", + TitleLink: "http://localhost/alerting/list", + Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", + Fallback: "[FIRING:1] (val1)", + Fields: nil, + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", + Color: "#D63232", + }, + }, + }, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + notifier, recorder, err := setupSlackForTests(t, test.settings) + require.NoError(t, err) + + ctx := context.Background() + ctx = notify.WithGroupKey(ctx, "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + + ok, err := notifier.Notify(ctx, test.alerts...) + if test.expectedError != "" { + assert.EqualError(t, err, test.expectedError) + assert.False(t, ok) + } else { + assert.NoError(t, err) + assert.True(t, ok) + + // When sending a notification via PostMessage some content, such as images, + // are sent as replies to the original message + require.Len(t, recorder.requests, len(test.expectedReplies)+1) + + // Get the request and check that it's sending to the URL + r := recorder.requests[0] + assert.Equal(t, notifier.settings.URL, r.URL.String()) + + // Check that the request contains the expected message + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + + message := slackMessage{} + require.NoError(t, json.Unmarshal(b, &message)) + for i, v := range message.Attachments { + // Need to update the ts as these cannot be set in the test definition + test.expectedMessage.Attachments[i].Ts = v.Ts + } + assert.Equal(t, *test.expectedMessage, message) + + // Check that the replies match expectations + for i := 1; i < len(recorder.requests); i++ { + r = recorder.requests[i] + assert.Equal(t, "https://slack.com/api/files.upload", r.URL.String()) + + media, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(t, err) + if media == "multipart/form-data" { + // Some replies are file uploads, so check the multipart form + checkMultipart(t, test.expectedReplies[i-1].(map[string]struct{}), r.Body, params["boundary"]) + } else { + b, err = io.ReadAll(r.Body) + require.NoError(t, err) + message = slackMessage{} + require.NoError(t, json.Unmarshal(b, &message)) + assert.Equal(t, test.expectedReplies[i-1], message) + } + } + } + }) + } +} + +// slackRequestRecorder is used in tests to record all requests. +type slackRequestRecorder struct { + requests []*http.Request +} + +func (s *slackRequestRecorder) fn(_ context.Context, r *http.Request, _ log.Logger) (string, error) { + s.requests = append(s.requests, r) + return "", nil +} + +// checkMulipart checks that each part is present, but not its contents +func checkMultipart(t *testing.T, expected map[string]struct{}, r io.Reader, boundary string) { + m := multipart.NewReader(r, boundary) + visited := make(map[string]struct{}) + for { + part, err := m.NextPart() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + visited[part.FormName()] = struct{}{} + } + assert.Equal(t, expected, visited) +} + +func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRequestRecorder, error) { + tmpl := templateForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) tmpl.ExternalURL = externalURL - cases := []struct { - name string - settings string - alerts []*types.Alert - expMsg *slackMessage - expInitError string - expMsgError error - expWebhookURL string - }{ - { - name: "Correct config with one alert", - settings: `{ - "token": "1234", - "recipient": "#testchannel", - "icon_emoji": ":emoji:" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: &slackMessage{ - Channel: "#testchannel", - Username: "Grafana", - IconEmoji: ":emoji:", - Attachments: []attachment{ - { - Title: "[FIRING:1] (val1)", - TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", - Fallback: "[FIRING:1] (val1)", - Fields: nil, - Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/static/assets/img/fav32.png", - Color: "#D63232", - Ts: 0, - }, - }, - }, - expMsgError: nil, - }, - { - name: "Correct config with webhook", - settings: `{ - "url": "https://webhook.com", - "recipient": "#testchannel", - "icon_emoji": ":emoji:" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1"}, - }, - }, - }, - expMsg: &slackMessage{ - Channel: "#testchannel", - Username: "Grafana", - IconEmoji: ":emoji:", - Attachments: []attachment{ - { - Title: "[FIRING:1] (val1)", - TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", - Fallback: "[FIRING:1] (val1)", - Fields: nil, - Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/static/assets/img/fav32.png", - Color: "#D63232", - Ts: 0, - }, - }, - }, - expMsgError: nil, - }, - { - name: "Image URL in alert appears in slack message", - settings: `{ - "token": "1234", - "recipient": "#testchannel", - "icon_emoji": ":emoji:" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-with-url"}, - }, - }, - }, - expMsg: &slackMessage{ - Channel: "#testchannel", - Username: "Grafana", - IconEmoji: ":emoji:", - Attachments: []attachment{ - { - Title: "[FIRING:1] (val1)", - TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", - Fallback: "[FIRING:1] (val1)", - Fields: nil, - Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/static/assets/img/fav32.png", - Color: "#D63232", - Ts: 0, - ImageURL: "https://www.example.com/image.jpg", - }, - }, - }, - expMsgError: nil, - }, - { - name: "Correct config with multiple alerts and template", - settings: `{ - "token": "1234", - "recipient": "#testchannel", - "icon_emoji": ":emoji:", - "title": "{{ .Alerts.Firing | len }} firing, {{ .Alerts.Resolved | len }} resolved" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1"}, - }, - }, - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, - Annotations: model.LabelSet{"ann1": "annv2"}, - }, - }, - }, - expMsg: &slackMessage{ - Channel: "#testchannel", - Username: "Grafana", - IconEmoji: ":emoji:", - Attachments: []attachment{ - { - Title: "2 firing, 0 resolved", - TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", - Fallback: "2 firing, 0 resolved", - Fields: nil, - Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/static/assets/img/fav32.png", - Color: "#D63232", - Ts: 0, - }, - }, - }, - expMsgError: nil, + f, err := os.Create(t.TempDir() + "test.png") + require.NoError(t, err) + t.Cleanup(func() { + if err := os.Remove(f.Name()); err != nil { + t.Logf("failed to delete test file: %s", err) + } + }) + + images := &fakeImageStore{ + Images: []*models.Image{{ + Token: "image-on-disk", + Path: f.Name(), }, { - name: "Missing token", - settings: `{ - "recipient": "#testchannel" - }`, - expInitError: `token must be specified when using the Slack chat API`, - }, { - name: "Missing recipient", - settings: `{ - "token": "1234" - }`, - expInitError: `recipient must be specified when using the Slack chat API`, - }, - { - name: "Custom endpoint url", - settings: `{ - "token": "1234", - "recipient": "#testchannel", - "endpointUrl": "https://slack-custom.com/api/", - "icon_emoji": ":emoji:" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1"}, - }, - }, - }, - expMsg: &slackMessage{ - Channel: "#testchannel", - Username: "Grafana", - IconEmoji: ":emoji:", - Attachments: []attachment{ - { - Title: "[FIRING:1] (val1)", - TitleLink: "http://localhost/alerting/list", - Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", - Fallback: "[FIRING:1] (val1)", - Fields: nil, - Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/static/assets/img/fav32.png", - Color: "#D63232", - Ts: 0, - }, - }, - }, - expMsgError: nil, - }, + Token: "image-with-url", + URL: "https://www.example.com/test.png", + }}, } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - settingsJSON, err := simplejson.NewJson([]byte(c.settings)) - require.NoError(t, err) - secureSettings := make(map[string][]byte) + settingsJSON, err := simplejson.NewJson([]byte(settings)) + require.NoError(t, err) - secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) - decryptFn := secretsService.GetDecryptedValue - notificationService := mockNotificationService() + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + notificationService := mockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ - Name: "slack_testing", - Type: "slack", - Settings: settingsJSON, - SecureSettings: secureSettings, - }, - ImageStore: fakeImageStore, - // TODO: allow changing the associated values for different tests. - NotificationService: notificationService, - DecryptFunc: decryptFn, - Template: tmpl, + c := FactoryConfig{ + Config: &NotificationChannelConfig{ + Name: "slack_testing", + Type: "slack", + Settings: settingsJSON, + SecureSettings: make(map[string][]byte), + }, + ImageStore: images, + NotificationService: notificationService, + DecryptFunc: secretsService.GetDecryptedValue, + Template: tmpl, + } + + sn, err := buildSlackNotifier(c) + if err != nil { + return nil, nil, err + } + + sr := &slackRequestRecorder{} + sn.sendFn = sr.fn + return sn, sr, nil +} + +func TestCreateSlackNotifierFromConfig(t *testing.T) { + tests := []struct { + name string + settings string + expectedError string + }{{ + name: "Missing token", + settings: `{ + "recipient": "#testchannel" + }`, + expectedError: "token must be specified when using the Slack chat API", + }, { + name: "Missing recipient", + settings: `{ + "token": "1234" + }`, + expectedError: "recipient must be specified when using the Slack chat API", + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + n, _, err := setupSlackForTests(t, test.settings) + if test.expectedError != "" { + assert.Nil(t, n) + assert.EqualError(t, err, test.expectedError) + } else { + assert.NotNil(t, n) + assert.Nil(t, err) } - - pn, err := buildSlackNotifier(fc) - if c.expInitError != "" { - require.Error(t, err) - require.Equal(t, c.expInitError, err.Error()) - return - } - require.NoError(t, err) - - body := "" - origSendSlackRequest := sendSlackRequest - t.Cleanup(func() { - sendSlackRequest = origSendSlackRequest - }) - sendSlackRequest = func(request *http.Request, log log.Logger) error { - t.Helper() - defer func() { - _ = request.Body.Close() - }() - - url := settingsJSON.Get("url").MustString() - if len(url) == 0 { - endpointUrl := settingsJSON.Get("endpointUrl").MustString(SlackAPIEndpoint) - require.Equal(t, endpointUrl, request.URL.String()) - } - - b, err := io.ReadAll(request.Body) - require.NoError(t, err) - body = string(b) - return nil - } - - ctx := notify.WithGroupKey(context.Background(), "alertname") - ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) - ok, err := pn.Notify(ctx, c.alerts...) - if c.expMsgError != nil { - require.Error(t, err) - require.False(t, ok) - require.Equal(t, c.expMsgError.Error(), err.Error()) - return - } - require.True(t, ok) - require.NoError(t, err) - - // Getting Ts from actual since that can't be predicted. - var obj slackMessage - require.NoError(t, json.Unmarshal([]byte(body), &obj)) - c.expMsg.Attachments[0].Ts = obj.Attachments[0].Ts - - expBody, err := json.Marshal(c.expMsg) - require.NoError(t, err) - - require.JSONEq(t, string(expBody), body) - - // If we should have sent to the webhook, the mock notification service - // will have a record of it. - require.Equal(t, c.expWebhookURL, notificationService.Webhook.Url) }) } } func TestSendSlackRequest(t *testing.T) { tests := []struct { - name string - slackResponse string - statusCode int - expectError bool + name string + response string + statusCode int + expectError bool }{ { name: "Example error", - slackResponse: `{ + response: `{ "ok": false, "error": "too_many_attachments" }`, @@ -352,7 +509,7 @@ func TestSendSlackRequest(t *testing.T) { }, { name: "Success case, normal response body", - slackResponse: `{ + response: `{ "ok": true, "channel": "C1H9RESGL", "ts": "1503435956.000247", @@ -376,26 +533,27 @@ func TestSendSlackRequest(t *testing.T) { expectError: false, }, { - name: "No response body", - statusCode: http.StatusOK, + name: "No response body", + statusCode: http.StatusOK, + expectError: true, }, { - name: "Success case, unexpected response body", - statusCode: http.StatusOK, - slackResponse: `{"test": true}`, - expectError: false, + name: "Success case, unexpected response body", + statusCode: http.StatusOK, + response: `{"test": true}`, + expectError: true, }, { - name: "Success case, ok: true", - statusCode: http.StatusOK, - slackResponse: `{"ok": true}`, - expectError: false, + name: "Success case, ok: true", + statusCode: http.StatusOK, + response: `{"ok": true}`, + expectError: false, }, { - name: "200 status code, error in body", - statusCode: http.StatusOK, - slackResponse: `{"ok": false, "error": "test error"}`, - expectError: true, + name: "200 status code, error in body", + statusCode: http.StatusOK, + response: `{"ok": false, "error": "test error"}`, + expectError: true, }, } @@ -403,14 +561,14 @@ func TestSendSlackRequest(t *testing.T) { t.Run(test.name, func(tt *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.statusCode) - _, err := w.Write([]byte(test.slackResponse)) + _, err := w.Write([]byte(test.response)) require.NoError(tt, err) })) defer server.Close() req, err := http.NewRequest(http.MethodGet, server.URL, nil) require.NoError(tt, err) - err = sendSlackRequest(req, log.New("test")) + _, err = sendSlackRequest(context.Background(), req, log.New("test")) if !test.expectError { require.NoError(tt, err) } else { diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index c4c557faca3..d743bb4123d 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -707,6 +707,10 @@ func TestNotificationChannels(t *testing.T) { amConfig := getAlertmanagerConfig(mockChannel.server.Addr) mockEmail := &mockEmailHandler{} + // Set up responses + mockChannel.responses["slack_recv1"] = `{"ok": true}` + mockChannel.responses["slack_recvX"] = `{"ok": true}` + // Overriding some URLs to send to the mock channel. os, opa, ot, opu, ogb, ol, oth := channels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL, channels.TelegramAPIURL, channels.PushoverEndpoint, channels.GetBoundary, @@ -990,6 +994,7 @@ type mockNotificationChannel struct { server *http.Server receivedNotifications map[string][]string + responses map[string]string notificationErrorCount int notificationsMtx sync.RWMutex } @@ -1007,6 +1012,7 @@ func newMockNotificationChannel(t *testing.T, grafanaListedAddr string) *mockNot Addr: listener.Addr().String(), }, receivedNotifications: make(map[string][]string), + responses: make(map[string]string), t: t, } @@ -1036,6 +1042,7 @@ func (nc *mockNotificationChannel) ServeHTTP(res http.ResponseWriter, req *http. nc.receivedNotifications[key] = append(nc.receivedNotifications[key], body) res.WriteHeader(http.StatusOK) + fmt.Fprint(res, nc.responses[paths[0]]) } func (nc *mockNotificationChannel) totalNotifications() int { @@ -2635,7 +2642,7 @@ var expNonEmailNotifications = map[string][]string{ // expNotificationErrors maps a receiver name with its expected error string. var expNotificationErrors = map[string]string{ - "slack_failed_recv": "request to Slack API failed with status code 500", + "slack_failed_recv": "failed to send Slack message: unexpected 5xx status code: 500", } // expInactiveReceivers is a set of receivers expected to be unused.