alertmanager/notify/email/email_test.go
Simon Pasquier 9b0ecaa0fe
notify/email: wrap all errors for easier debugging (#1953)
* notify/email: wrap all errors for easier debugging

In addition, this commit passes the current context to the TCP dialer
and it doesn't log any QUIT errors if the email delivery wasn't
successful.

Signed-off-by: Simon Pasquier <spasquie@redhat.com>

* Fix typo

Signed-off-by: Simon Pasquier <spasquie@redhat.com>
2019-07-10 11:24:51 +02:00

604 lines
16 KiB
Go

// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Some tests require a running mail catcher. We use MailDev for this purpose,
// it can work without or with authentication (LOGIN only). It exposes a REST
// API which we use to retrieve and check the sent emails.
//
// Those tests are only executed when specific environment variables are set,
// otherwise they are skipped. The tests must be run by the CI.
//
// To run the tests locally, you should start 2 MailDev containers:
//
// $ docker run --rm -p 1080:1080 -p 1025:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa -v
// $ docker run --rm -p 1081:1080 -p 1026:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa --incoming-user user --incoming-pass pass -v
//
// $ EMAIL_NO_AUTH_CONFIG=testdata/noauth.yml EMAIL_AUTH_CONFIG=testdata/auth.yml make
//
// See also https://github.com/djfarrelly/MailDev for more details.
package email
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/go-kit/kit/log"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
yaml "gopkg.in/yaml.v2"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
const (
emailNoAuthConfigVar = "EMAIL_NO_AUTH_CONFIG"
emailAuthConfigVar = "EMAIL_AUTH_CONFIG"
emailTo = "alerts@example.com"
emailFrom = "alertmanager@example.com"
)
// email represents an email returned by the MailDev REST API.
// See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md.
type email struct {
To []map[string]string
From []map[string]string
Subject string
HTML *string
Text *string
Headers map[string]string
}
// mailDev is a client for the MailDev server.
type mailDev struct {
*url.URL
}
func (m *mailDev) UnmarshalYAML(unmarshal func(interface{}) error) error {
var s string
if err := unmarshal(&s); err != nil {
return err
}
urlp, err := url.Parse(s)
if err != nil {
return err
}
m.URL = urlp
return nil
}
// getLastEmail returns the last received email.
func (m *mailDev) getLastEmail() (*email, error) {
code, b, err := m.doEmailRequest(http.MethodGet, "/email")
if err != nil {
return nil, err
}
if code != http.StatusOK {
return nil, fmt.Errorf("expected status OK, got %d", code)
}
var emails []email
err = yaml.Unmarshal(b, &emails)
if err != nil {
return nil, err
}
if len(emails) == 0 {
return nil, nil
}
return &emails[len(emails)-1], nil
}
// deleteAllEmails deletes all emails.
func (m *mailDev) deleteAllEmails() error {
_, _, err := m.doEmailRequest(http.MethodDelete, "/email/all")
return err
}
// doEmailRequest makes a request to the MailDev API.
func (m *mailDev) doEmailRequest(method string, path string) (int, []byte, error) {
req, err := http.NewRequest(method, fmt.Sprintf("%s://%s%s", m.Scheme, m.Host, path), nil)
if err != nil {
return 0, nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
req = req.WithContext(ctx)
defer cancel()
res, err := http.DefaultClient.Do(req)
if err != nil {
return 0, nil, err
}
defer res.Body.Close()
b, err := ioutil.ReadAll(res.Body)
if err != nil {
return 0, nil, err
}
return res.StatusCode, b, nil
}
// emailTestConfig is the configuration for the tests.
type emailTestConfig struct {
Smarthost string `yaml:"smarthost"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Server *mailDev `yaml:"server"`
}
func loadEmailTestConfiguration(f string) (emailTestConfig, error) {
c := emailTestConfig{}
b, err := ioutil.ReadFile(f)
if err != nil {
return c, err
}
err = yaml.UnmarshalStrict(b, &c)
if err != nil {
return c, err
}
return c, nil
}
func notifyEmail(cfg *config.EmailConfig, server *mailDev) (*email, bool, error) {
return notifyEmailWithContext(context.Background(), cfg, server)
}
// notifyEmailWithContext sends a notification with one firing alert and retrieves the
// email from the SMTP server if the notification has been successfully delivered.
func notifyEmailWithContext(ctx context.Context, cfg *config.EmailConfig, server *mailDev) (*email, bool, error) {
if cfg.RequireTLS == nil {
cfg.RequireTLS = new(bool)
}
if cfg.Headers == nil {
cfg.Headers = make(map[string]string)
}
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
err := server.deleteAllEmails()
if err != nil {
return nil, false, err
}
tmpl, err := template.FromGlobs()
if err != nil {
return nil, false, err
}
tmpl.ExternalURL, _ = url.Parse("http://am")
email := New(cfg, tmpl, log.NewNopLogger())
retry, err := email.Notify(ctx, firingAlert)
if err != nil {
return nil, retry, err
}
e, err := server.getLastEmail()
if err != nil {
return nil, retry, err
} else if e == nil {
return nil, retry, fmt.Errorf("email not found")
}
return e, retry, nil
}
// TestEmailNotifyWithErrors tries to send emails with buggy inputs.
func TestEmailNotifyWithErrors(t *testing.T) {
cfgFile := os.Getenv(emailNoAuthConfigVar)
if len(cfgFile) == 0 {
t.Skipf("%s not set", emailNoAuthConfigVar)
}
c, err := loadEmailTestConfiguration(cfgFile)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
title string
updateCfg func(*config.EmailConfig)
errMsg string
hasEmail bool
}{
{
title: "invalid address",
updateCfg: func(cfg *config.EmailConfig) {
cfg.Smarthost = "example.com"
},
errMsg: "split address:",
},
{
title: "invalid 'from' template",
updateCfg: func(cfg *config.EmailConfig) {
cfg.From = `{{ template "invalid" }}`
},
errMsg: "execute 'from' template:",
},
{
title: "invalid 'from' address",
updateCfg: func(cfg *config.EmailConfig) {
cfg.From = `xxx`
},
errMsg: "parse 'from' addresses:",
},
{
title: "invalid 'to' template",
updateCfg: func(cfg *config.EmailConfig) {
cfg.To = `{{ template "invalid" }}`
},
errMsg: "execute 'to' template:",
},
{
title: "invalid 'to' address",
updateCfg: func(cfg *config.EmailConfig) {
cfg.To = `xxx`
},
errMsg: "parse 'to' addresses:",
},
{
title: "invalid 'subject' template",
updateCfg: func(cfg *config.EmailConfig) {
cfg.Headers["subject"] = `{{ template "invalid" }}`
},
errMsg: `execute "subject" header template:`,
hasEmail: true,
},
{
title: "invalid 'text' template",
updateCfg: func(cfg *config.EmailConfig) {
cfg.Text = `{{ template "invalid" }}`
},
errMsg: `execute text template:`,
hasEmail: true,
},
{
title: "invalid 'html' template",
updateCfg: func(cfg *config.EmailConfig) {
cfg.HTML = `{{ template "invalid" }}`
},
errMsg: `execute html template:`,
hasEmail: true,
},
} {
tc := tc
t.Run(tc.title, func(t *testing.T) {
if len(tc.errMsg) == 0 {
t.Fatal("please define the expected error message")
return
}
emailCfg := &config.EmailConfig{
Smarthost: c.Smarthost,
To: emailTo,
From: emailFrom,
HTML: "HTML body",
Text: "Text body",
Headers: map[string]string{
"Subject": "{{ len .Alerts }} {{ .Status }} alert(s)",
},
}
if tc.updateCfg != nil {
tc.updateCfg(emailCfg)
}
_, retry, err := notifyEmail(emailCfg, c.Server)
require.Error(t, err)
require.Contains(t, err.Error(), tc.errMsg)
require.Equal(t, false, retry)
e, err := c.Server.getLastEmail()
require.NoError(t, err)
if tc.hasEmail {
require.NotNil(t, e)
} else {
require.Nil(t, e)
}
})
}
}
// TestEmailNotifyWithDoneContext tries to send an email with a context that is done.
func TestEmailNotifyWithDoneContext(t *testing.T) {
cfgFile := os.Getenv(emailNoAuthConfigVar)
if len(cfgFile) == 0 {
t.Skipf("%s not set", emailNoAuthConfigVar)
}
c, err := loadEmailTestConfiguration(cfgFile)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err = notifyEmailWithContext(
ctx,
&config.EmailConfig{
Smarthost: c.Smarthost,
To: emailTo,
From: emailFrom,
HTML: "HTML body",
Text: "Text body",
},
c.Server,
)
require.Error(t, err)
require.Contains(t, err.Error(), "establish connection to server")
}
// TestEmailNotifyWithoutAuthentication sends an email to an instance of
// MailDev configured with no authentication then it checks that the server has
// successfully processed the email.
func TestEmailNotifyWithoutAuthentication(t *testing.T) {
cfgFile := os.Getenv(emailNoAuthConfigVar)
if len(cfgFile) == 0 {
t.Skipf("%s not set", emailNoAuthConfigVar)
}
c, err := loadEmailTestConfiguration(cfgFile)
if err != nil {
t.Fatal(err)
}
_, _, err = notifyEmail(
&config.EmailConfig{
Smarthost: c.Smarthost,
To: emailTo,
From: emailFrom,
HTML: "HTML body",
Text: "Text body",
},
c.Server,
)
require.NoError(t, err)
}
// TestEmailNotifyWithSTARTTLS connects to the server, upgrades the connection
// to TLS, sends an email then it checks that the server has successfully
// processed the email.
// MailDev doesn't support STARTTLS and authentication at the same time so it
// is the only way to test successful STARTTLS.
func TestEmailNotifyWithSTARTTLS(t *testing.T) {
cfgFile := os.Getenv(emailNoAuthConfigVar)
if len(cfgFile) == 0 {
t.Skipf("%s not set", emailNoAuthConfigVar)
}
c, err := loadEmailTestConfiguration(cfgFile)
if err != nil {
t.Fatal(err)
}
trueVar := true
_, _, err = notifyEmail(
&config.EmailConfig{
Smarthost: c.Smarthost,
To: emailTo,
From: emailFrom,
HTML: "HTML body",
Text: "Text body",
RequireTLS: &trueVar,
// MailDev embeds a self-signed certificate which can't be retrieved.
TLSConfig: commoncfg.TLSConfig{InsecureSkipVerify: true},
},
c.Server,
)
require.NoError(t, err)
}
// TestEmailNotifyWithAuthentication sends emails to an instance of MailDev
// configured with authentication.
func TestEmailNotifyWithAuthentication(t *testing.T) {
cfgFile := os.Getenv(emailAuthConfigVar)
if len(cfgFile) == 0 {
t.Skipf("%s not set", emailAuthConfigVar)
}
c, err := loadEmailTestConfiguration(cfgFile)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
title string
updateCfg func(*config.EmailConfig)
errMsg string
retry bool
}{
{
title: "email with authentication",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthUsername = c.Username
cfg.AuthPassword = config.Secret(c.Password)
},
},
{
title: "HTML-only email",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthUsername = c.Username
cfg.AuthPassword = config.Secret(c.Password)
cfg.Text = ""
},
},
{
title: "text-only email",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthUsername = c.Username
cfg.AuthPassword = config.Secret(c.Password)
cfg.HTML = ""
},
},
{
title: "multiple To addresses",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthUsername = c.Username
cfg.AuthPassword = config.Secret(c.Password)
cfg.To = strings.Join([]string{emailTo, emailFrom}, ",")
},
},
{
title: "no more than one From address",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthUsername = c.Username
cfg.AuthPassword = config.Secret(c.Password)
cfg.From = strings.Join([]string{emailFrom, emailTo}, ",")
},
errMsg: "must be exactly one 'from' address",
retry: false,
},
{
title: "wrong credentials",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthUsername = c.Username
cfg.AuthPassword = config.Secret(c.Password + "wrong")
},
errMsg: "Invalid username or password",
retry: true,
},
{
title: "no credentials",
errMsg: "authentication Required",
retry: true,
},
{
title: "try to enable STARTTLS",
updateCfg: func(cfg *config.EmailConfig) {
cfg.RequireTLS = new(bool)
*cfg.RequireTLS = true
},
errMsg: "does not advertise the STARTTLS extension",
retry: true,
},
{
title: "invalid Hello string",
updateCfg: func(cfg *config.EmailConfig) {
cfg.AuthUsername = c.Username
cfg.AuthPassword = config.Secret(c.Password)
cfg.Hello = "invalid hello string"
},
errMsg: "501 Error",
retry: true,
},
} {
tc := tc
t.Run(tc.title, func(t *testing.T) {
emailCfg := &config.EmailConfig{
Smarthost: c.Smarthost,
To: emailTo,
From: emailFrom,
HTML: "HTML body",
Text: "Text body",
Headers: map[string]string{
"Subject": "{{ len .Alerts }} {{ .Status }} alert(s)",
},
}
if tc.updateCfg != nil {
tc.updateCfg(emailCfg)
}
e, retry, err := notifyEmail(emailCfg, c.Server)
if len(tc.errMsg) > 0 {
require.Error(t, err)
require.Contains(t, err.Error(), tc.errMsg)
require.Equal(t, tc.retry, retry)
return
}
require.NoError(t, err)
require.Equal(t, "1 firing alert(s)", e.Subject)
getAddresses := func(addresses []map[string]string) []string {
res := make([]string, 0, len(addresses))
for _, addr := range addresses {
res = append(res, addr["address"])
}
return res
}
to := getAddresses(e.To)
from := getAddresses(e.From)
require.Equal(t, strings.Split(emailCfg.To, ","), to)
require.Equal(t, strings.Split(emailCfg.From, ","), from)
if len(emailCfg.HTML) > 0 {
require.Equal(t, emailCfg.HTML, *e.HTML)
} else {
require.Nil(t, e.HTML)
}
if len(emailCfg.Text) > 0 {
require.Equal(t, emailCfg.Text, *e.Text)
} else {
require.Nil(t, e.Text)
}
})
}
}
func TestEmailConfigNoAuthMechs(t *testing.T) {
email := &Email{
conf: &config.EmailConfig{AuthUsername: "test"}, tmpl: &template.Template{}, logger: log.NewNopLogger(),
}
_, err := email.auth("")
require.Error(t, err)
require.Equal(t, err.Error(), "unknown auth mechanism: ")
}
func TestEmailConfigMissingAuthParam(t *testing.T) {
conf := &config.EmailConfig{AuthUsername: "test"}
email := &Email{
conf: conf, tmpl: &template.Template{}, logger: log.NewNopLogger(),
}
_, err := email.auth("CRAM-MD5")
require.Error(t, err)
require.Equal(t, err.Error(), "missing secret for CRAM-MD5 auth mechanism")
_, err = email.auth("PLAIN")
require.Error(t, err)
require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism")
_, err = email.auth("LOGIN")
require.Error(t, err)
require.Equal(t, err.Error(), "missing password for LOGIN auth mechanism")
_, err = email.auth("PLAIN LOGIN")
require.Error(t, err)
require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism; missing password for LOGIN auth mechanism")
}
func TestEmailNoUsernameStillOk(t *testing.T) {
email := &Email{
conf: &config.EmailConfig{}, tmpl: &template.Template{}, logger: log.NewNopLogger(),
}
a, err := email.auth("CRAM-MD5")
require.NoError(t, err)
require.Nil(t, a)
}