package alerting import ( "bytes" "context" "encoding/json" "errors" "log" "strings" "testing" "github.com/aws/aws-sdk-go-v2/service/sns" ) // fakeSNS records every Publish and can be told to fail. type fakeSNS struct { in []*sns.PublishInput err error } func (f *fakeSNS) Publish(_ context.Context, in *sns.PublishInput, _ ...func(*sns.Options)) (*sns.PublishOutput, error) { f.in = append(f.in, in) if f.err != nil { return nil, f.err } return &sns.PublishOutput{}, nil } const testTopicARN = "arn:aws:sns:eu-west-1:271376211898:carlos-flagship-ie-alerts" type posted struct { url string body []byte } func poster(into *[]posted, err error) func(string, []byte) error { return func(url string, body []byte) error { *into = append(*into, posted{url, body}) return err } } // captureLog sends the standard logger to a buffer for the test. func captureLog(t *testing.T) *bytes.Buffer { t.Helper() var buf bytes.Buffer prev, flags := log.Writer(), log.Flags() log.SetOutput(&buf) log.SetFlags(0) t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) }) return &buf } func TestPageDeliversOnBothSinks(t *testing.T) { var posts []posted pub := &fakeSNS{} s := &Sinks{WebhookURL: "http://example.test/hook", Post: poster(&posts, nil), TopicARN: testTopicARN, Pub: pub} if err := s.Page(context.Background(), LevelIncident, "carlos move stuck: shop.example", "the body.\nsecond line."); err != nil { t.Fatal(err) } if len(posts) != 1 || posts[0].url != "http://example.test/hook" { t.Fatalf("webhook posts: %+v", posts) } var got map[string]string if err := json.Unmarshal(posts[0].body, &got); err != nil { t.Fatal(err) } want := map[string]string{"kind": "page", "level": "incident", "subject": "carlos move stuck: shop.example", "body": "the body.\nsecond line."} if len(got) != len(want) { t.Fatalf("webhook payload %s", posts[0].body) } for k, v := range want { if got[k] != v { t.Fatalf("webhook payload %s: %s = %q, want %q", posts[0].body, k, got[k], v) } } if len(pub.in) != 1 { t.Fatalf("publishes = %d, want 1", len(pub.in)) } in := pub.in[0] if *in.TopicArn != testTopicARN || *in.Subject != "carlos move stuck: shop.example" || *in.Message != "the body.\nsecond line." { t.Fatalf("publish: %q %q %q", *in.TopicArn, *in.Subject, *in.Message) } if a, ok := in.MessageAttributes["level"]; !ok || a.StringValue == nil || *a.StringValue != "incident" || *a.DataType != "String" { t.Fatalf("the level attribute: %+v", in.MessageAttributes) } } // SNS takes a subject of under 100 printable ASCII characters with no line // break; a page's subject is made to fit rather than refused. func TestPageSubjectFitsSNS(t *testing.T) { pub := &fakeSNS{} s := &Sinks{TopicARN: testTopicARN, Pub: pub} long := "carlos move stuck:\n" + strings.Repeat("x", 120) + "é" if err := s.Page(context.Background(), LevelWarning, long, "body"); err != nil { t.Fatal(err) } got := *pub.in[0].Subject if len(got) >= 100 || strings.ContainsAny(got, "\r\n") || !strings.HasPrefix(got, "carlos move stuck: x") { t.Fatalf("subject %q (%d)", got, len(got)) } for _, r := range got { if r < 0x20 || r > 0x7e { t.Fatalf("subject %q has %q", got, r) } } } // A failing sink is counted and returned, and never stops the other one. func TestPageFailureIsCountedAndReturned(t *testing.T) { var posts []posted pub := &fakeSNS{err: errors.New("AccessDenied")} var webhookFailed, snsFailed int s := &Sinks{WebhookURL: "http://example.test/hook", Post: poster(&posts, errors.New("boom")), TopicARN: testTopicARN, Pub: pub, WebhookFailed: func() { webhookFailed++ }, SNSFailed: func() { snsFailed++ }} err := s.Page(context.Background(), LevelIncident, "s", "b") if err == nil || !strings.Contains(err.Error(), "boom") || !strings.Contains(err.Error(), "AccessDenied") { t.Fatalf("err = %v, want both failures", err) } if len(posts) != 1 || len(pub.in) != 1 || webhookFailed != 1 || snsFailed != 1 { t.Fatalf("posts %d publishes %d failures %d/%d", len(posts), len(pub.in), webhookFailed, snsFailed) } } // With no sink configured a page is not an error: it says so once in the // journal and returns nil, as the alert delivery does. func TestPageWithNoSinksLogsOnce(t *testing.T) { for _, c := range []struct { name string sinks *Sinks }{{"nil", nil}, {"empty", &Sinks{}}, {"a webhook with no poster", &Sinks{WebhookURL: "http://example.test/hook"}}} { t.Run(c.name, func(t *testing.T) { buf := captureLog(t) Arm(c.sinks) t.Cleanup(func() { Arm(nil) }) for range 3 { if err := Page(context.Background(), LevelIncident, "s", "b"); err != nil { t.Fatal(err) } } if n := strings.Count(buf.String(), "no sink configured"); n != 1 { t.Fatalf("logged %d times, want once:\n%s", n, buf) } // Arming again starts a fresh notice. Arm(c.sinks) if err := Page(context.Background(), LevelIncident, "s", "b"); err != nil { t.Fatal(err) } if n := strings.Count(buf.String(), "no sink configured"); n != 2 { t.Fatalf("after re-arming: logged %d times, want twice:\n%s", n, buf) } }) } } func TestPageUsesTheArmedSinks(t *testing.T) { var posts []posted Arm(&Sinks{WebhookURL: "http://example.test/hook", Post: poster(&posts, nil)}) t.Cleanup(func() { Arm(nil) }) if err := Page(context.Background(), LevelWarning, "subject", "body"); err != nil { t.Fatal(err) } if len(posts) != 1 || !strings.Contains(string(posts[0].body), `"subject":"subject"`) { t.Fatalf("posts: %+v", posts) } } // The delivery the alert sinks share: a webhook post and an SNS publish, // each counted on failure. func TestSinksPostAndPublish(t *testing.T) { var posts []posted pub := &fakeSNS{} var webhookFailed int s := &Sinks{WebhookURL: "http://example.test/hook", Post: poster(&posts, errors.New("boom")), TopicARN: testTopicARN, Pub: pub, WebhookFailed: func() { webhookFailed++ }} if !s.HasWebhook() || !s.HasSNS() { t.Fatal("both sinks are configured") } if err := s.PostWebhook([]byte(`{}`)); err == nil || webhookFailed != 1 { t.Fatalf("post: %v, %d failures", err, webhookFailed) } if err := s.PublishSNS(context.Background(), "subj", "msg"); err != nil || len(pub.in) != 1 || *pub.in[0].Subject != "subj" || pub.in[0].MessageAttributes != nil { t.Fatalf("publish: %v %+v", err, pub.in) } var none *Sinks if none.HasWebhook() || none.HasSNS() { t.Fatal("a nil sink set has no sinks") } } func TestParseTopicARN(t *testing.T) { for _, c := range []struct { in, region string ok bool }{ {testTopicARN, "eu-west-1", true}, {" " + testTopicARN + " ", "eu-west-1", true}, {"", "", false}, {testTopicARN + ":sub-id", "", false}, {"arn:aws:sqs:eu-west-1:271376211898:q", "", false}, {"arn:aws:sns:eu-west-1:271376211898:topic.fifo", "", false}, } { region, err := ParseTopicARN(c.in) if (err == nil) != c.ok || region != c.region { t.Errorf("ParseTopicARN(%q) = %q, %v", c.in, region, err) } } } // FromEnv is the console's sinks (moves Plan 5 Task 7, delta M14): the // agent's two variables, the webhook through PostJSON and the topic's own // region for SNS. A topic that does not parse, or whose client cannot be // built, leaves SNS off and the webhook on; nothing set is no sink. func TestFromEnvBuildsTheConsoleSinks(t *testing.T) { env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } var asked []string client := func(_ context.Context, region string) (Publisher, error) { asked = append(asked, region) return &fakeSNS{}, nil } s := FromEnv(context.Background(), env(map[string]string{"CARLOS_ALERT_WEBHOOK": " http://example.test/hook ", "CARLOS_ALERT_SNS_TOPIC_ARN": testTopicARN}), client) if !s.HasWebhook() || s.WebhookURL != "http://example.test/hook" || !s.HasSNS() || s.TopicARN != testTopicARN { t.Fatalf("both sinks: %+v", s) } if len(asked) != 1 || asked[0] != "eu-west-1" { t.Fatalf("the SNS client was built for %v, want the topic's region", asked) } buf := captureLog(t) s = FromEnv(context.Background(), env(map[string]string{"CARLOS_ALERT_WEBHOOK": "http://example.test/hook", "CARLOS_ALERT_SNS_TOPIC_ARN": "arn:aws:sns:eu-west-1:1:x:sub"}), client) if !s.HasWebhook() || s.HasSNS() || !strings.Contains(buf.String(), "SNS sink off") { t.Fatalf("a bad topic: %+v (log %q)", s, buf.String()) } s = FromEnv(context.Background(), env(map[string]string{"CARLOS_ALERT_SNS_TOPIC_ARN": testTopicARN}), func(context.Context, string) (Publisher, error) { return nil, errors.New("no credentials") }) if s.HasWebhook() || s.HasSNS() { t.Fatalf("a client that cannot be built: %+v", s) } s = FromEnv(context.Background(), env(nil), client) if s == nil || s.HasWebhook() || s.HasSNS() { t.Fatalf("nothing set: %+v", s) } }