| 1 | package alerting |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "log" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/aws/aws-sdk-go-v2/service/sns" |
| 13 | ) |
| 14 | |
| 15 | // fakeSNS records every Publish and can be told to fail. |
| 16 | type fakeSNS struct { |
| 17 | in []*sns.PublishInput |
| 18 | err error |
| 19 | } |
| 20 | |
| 21 | func (f *fakeSNS) Publish(_ context.Context, in *sns.PublishInput, _ ...func(*sns.Options)) (*sns.PublishOutput, error) { |
| 22 | f.in = append(f.in, in) |
| 23 | if f.err != nil { |
| 24 | return nil, f.err |
| 25 | } |
| 26 | return &sns.PublishOutput{}, nil |
| 27 | } |
| 28 | |
| 29 | const testTopicARN = "arn:aws:sns:eu-west-1:271376211898:carlos-flagship-ie-alerts" |
| 30 | |
| 31 | type posted struct { |
| 32 | url string |
| 33 | body []byte |
| 34 | } |
| 35 | |
| 36 | func poster(into *[]posted, err error) func(string, []byte) error { |
| 37 | return func(url string, body []byte) error { |
| 38 | *into = append(*into, posted{url, body}) |
| 39 | return err |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // captureLog sends the standard logger to a buffer for the test. |
| 44 | func captureLog(t *testing.T) *bytes.Buffer { |
| 45 | t.Helper() |
| 46 | var buf bytes.Buffer |
| 47 | prev, flags := log.Writer(), log.Flags() |
| 48 | log.SetOutput(&buf) |
| 49 | log.SetFlags(0) |
| 50 | t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) }) |
| 51 | return &buf |
| 52 | } |
| 53 | |
| 54 | func TestPageDeliversOnBothSinks(t *testing.T) { |
| 55 | var posts []posted |
| 56 | pub := &fakeSNS{} |
| 57 | s := &Sinks{WebhookURL: "http://example.test/hook", Post: poster(&posts, nil), TopicARN: testTopicARN, Pub: pub} |
| 58 | if err := s.Page(context.Background(), LevelIncident, "carlos move stuck: shop.example", "the body.\nsecond line."); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if len(posts) != 1 || posts[0].url != "http://example.test/hook" { |
| 62 | t.Fatalf("webhook posts: %+v", posts) |
| 63 | } |
| 64 | var got map[string]string |
| 65 | if err := json.Unmarshal(posts[0].body, &got); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | want := map[string]string{"kind": "page", "level": "incident", "subject": "carlos move stuck: shop.example", "body": "the body.\nsecond line."} |
| 69 | if len(got) != len(want) { |
| 70 | t.Fatalf("webhook payload %s", posts[0].body) |
| 71 | } |
| 72 | for k, v := range want { |
| 73 | if got[k] != v { |
| 74 | t.Fatalf("webhook payload %s: %s = %q, want %q", posts[0].body, k, got[k], v) |
| 75 | } |
| 76 | } |
| 77 | if len(pub.in) != 1 { |
| 78 | t.Fatalf("publishes = %d, want 1", len(pub.in)) |
| 79 | } |
| 80 | in := pub.in[0] |
| 81 | if *in.TopicArn != testTopicARN || *in.Subject != "carlos move stuck: shop.example" || *in.Message != "the body.\nsecond line." { |
| 82 | t.Fatalf("publish: %q %q %q", *in.TopicArn, *in.Subject, *in.Message) |
| 83 | } |
| 84 | if a, ok := in.MessageAttributes["level"]; !ok || a.StringValue == nil || *a.StringValue != "incident" || *a.DataType != "String" { |
| 85 | t.Fatalf("the level attribute: %+v", in.MessageAttributes) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // SNS takes a subject of under 100 printable ASCII characters with no line |
| 90 | // break; a page's subject is made to fit rather than refused. |
| 91 | func TestPageSubjectFitsSNS(t *testing.T) { |
| 92 | pub := &fakeSNS{} |
| 93 | s := &Sinks{TopicARN: testTopicARN, Pub: pub} |
| 94 | long := "carlos move stuck:\n" + strings.Repeat("x", 120) + "é" |
| 95 | if err := s.Page(context.Background(), LevelWarning, long, "body"); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | got := *pub.in[0].Subject |
| 99 | if len(got) >= 100 || strings.ContainsAny(got, "\r\n") || !strings.HasPrefix(got, "carlos move stuck: x") { |
| 100 | t.Fatalf("subject %q (%d)", got, len(got)) |
| 101 | } |
| 102 | for _, r := range got { |
| 103 | if r < 0x20 || r > 0x7e { |
| 104 | t.Fatalf("subject %q has %q", got, r) |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // A failing sink is counted and returned, and never stops the other one. |
| 110 | func TestPageFailureIsCountedAndReturned(t *testing.T) { |
| 111 | var posts []posted |
| 112 | pub := &fakeSNS{err: errors.New("AccessDenied")} |
| 113 | var webhookFailed, snsFailed int |
| 114 | s := &Sinks{WebhookURL: "http://example.test/hook", Post: poster(&posts, errors.New("boom")), TopicARN: testTopicARN, Pub: pub, |
| 115 | WebhookFailed: func() { webhookFailed++ }, SNSFailed: func() { snsFailed++ }} |
| 116 | err := s.Page(context.Background(), LevelIncident, "s", "b") |
| 117 | if err == nil || !strings.Contains(err.Error(), "boom") || !strings.Contains(err.Error(), "AccessDenied") { |
| 118 | t.Fatalf("err = %v, want both failures", err) |
| 119 | } |
| 120 | if len(posts) != 1 || len(pub.in) != 1 || webhookFailed != 1 || snsFailed != 1 { |
| 121 | t.Fatalf("posts %d publishes %d failures %d/%d", len(posts), len(pub.in), webhookFailed, snsFailed) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // With no sink configured a page is not an error: it says so once in the |
| 126 | // journal and returns nil, as the alert delivery does. |
| 127 | func TestPageWithNoSinksLogsOnce(t *testing.T) { |
| 128 | for _, c := range []struct { |
| 129 | name string |
| 130 | sinks *Sinks |
| 131 | }{{"nil", nil}, {"empty", &Sinks{}}, {"a webhook with no poster", &Sinks{WebhookURL: "http://example.test/hook"}}} { |
| 132 | t.Run(c.name, func(t *testing.T) { |
| 133 | buf := captureLog(t) |
| 134 | Arm(c.sinks) |
| 135 | t.Cleanup(func() { Arm(nil) }) |
| 136 | for range 3 { |
| 137 | if err := Page(context.Background(), LevelIncident, "s", "b"); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | } |
| 141 | if n := strings.Count(buf.String(), "no sink configured"); n != 1 { |
| 142 | t.Fatalf("logged %d times, want once:\n%s", n, buf) |
| 143 | } |
| 144 | // Arming again starts a fresh notice. |
| 145 | Arm(c.sinks) |
| 146 | if err := Page(context.Background(), LevelIncident, "s", "b"); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | if n := strings.Count(buf.String(), "no sink configured"); n != 2 { |
| 150 | t.Fatalf("after re-arming: logged %d times, want twice:\n%s", n, buf) |
| 151 | } |
| 152 | }) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func TestPageUsesTheArmedSinks(t *testing.T) { |
| 157 | var posts []posted |
| 158 | Arm(&Sinks{WebhookURL: "http://example.test/hook", Post: poster(&posts, nil)}) |
| 159 | t.Cleanup(func() { Arm(nil) }) |
| 160 | if err := Page(context.Background(), LevelWarning, "subject", "body"); err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | if len(posts) != 1 || !strings.Contains(string(posts[0].body), `"subject":"subject"`) { |
| 164 | t.Fatalf("posts: %+v", posts) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // The delivery the alert sinks share: a webhook post and an SNS publish, |
| 169 | // each counted on failure. |
| 170 | func TestSinksPostAndPublish(t *testing.T) { |
| 171 | var posts []posted |
| 172 | pub := &fakeSNS{} |
| 173 | var webhookFailed int |
| 174 | s := &Sinks{WebhookURL: "http://example.test/hook", Post: poster(&posts, errors.New("boom")), TopicARN: testTopicARN, Pub: pub, |
| 175 | WebhookFailed: func() { webhookFailed++ }} |
| 176 | if !s.HasWebhook() || !s.HasSNS() { |
| 177 | t.Fatal("both sinks are configured") |
| 178 | } |
| 179 | if err := s.PostWebhook([]byte(`{}`)); err == nil || webhookFailed != 1 { |
| 180 | t.Fatalf("post: %v, %d failures", err, webhookFailed) |
| 181 | } |
| 182 | if err := s.PublishSNS(context.Background(), "subj", "msg"); err != nil || len(pub.in) != 1 || *pub.in[0].Subject != "subj" || pub.in[0].MessageAttributes != nil { |
| 183 | t.Fatalf("publish: %v %+v", err, pub.in) |
| 184 | } |
| 185 | var none *Sinks |
| 186 | if none.HasWebhook() || none.HasSNS() { |
| 187 | t.Fatal("a nil sink set has no sinks") |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestParseTopicARN(t *testing.T) { |
| 192 | for _, c := range []struct { |
| 193 | in, region string |
| 194 | ok bool |
| 195 | }{ |
| 196 | {testTopicARN, "eu-west-1", true}, |
| 197 | {" " + testTopicARN + " ", "eu-west-1", true}, |
| 198 | {"", "", false}, |
| 199 | {testTopicARN + ":sub-id", "", false}, |
| 200 | {"arn:aws:sqs:eu-west-1:271376211898:q", "", false}, |
| 201 | {"arn:aws:sns:eu-west-1:271376211898:topic.fifo", "", false}, |
| 202 | } { |
| 203 | region, err := ParseTopicARN(c.in) |
| 204 | if (err == nil) != c.ok || region != c.region { |
| 205 | t.Errorf("ParseTopicARN(%q) = %q, %v", c.in, region, err) |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // FromEnv is the console's sinks (moves Plan 5 Task 7, delta M14): the |
| 211 | // agent's two variables, the webhook through PostJSON and the topic's own |
| 212 | // region for SNS. A topic that does not parse, or whose client cannot be |
| 213 | // built, leaves SNS off and the webhook on; nothing set is no sink. |
| 214 | func TestFromEnvBuildsTheConsoleSinks(t *testing.T) { |
| 215 | env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } |
| 216 | var asked []string |
| 217 | client := func(_ context.Context, region string) (Publisher, error) { |
| 218 | asked = append(asked, region) |
| 219 | return &fakeSNS{}, nil |
| 220 | } |
| 221 | s := FromEnv(context.Background(), env(map[string]string{"CARLOS_ALERT_WEBHOOK": " http://example.test/hook ", "CARLOS_ALERT_SNS_TOPIC_ARN": testTopicARN}), client) |
| 222 | if !s.HasWebhook() || s.WebhookURL != "http://example.test/hook" || !s.HasSNS() || s.TopicARN != testTopicARN { |
| 223 | t.Fatalf("both sinks: %+v", s) |
| 224 | } |
| 225 | if len(asked) != 1 || asked[0] != "eu-west-1" { |
| 226 | t.Fatalf("the SNS client was built for %v, want the topic's region", asked) |
| 227 | } |
| 228 | buf := captureLog(t) |
| 229 | 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) |
| 230 | if !s.HasWebhook() || s.HasSNS() || !strings.Contains(buf.String(), "SNS sink off") { |
| 231 | t.Fatalf("a bad topic: %+v (log %q)", s, buf.String()) |
| 232 | } |
| 233 | 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") }) |
| 234 | if s.HasWebhook() || s.HasSNS() { |
| 235 | t.Fatalf("a client that cannot be built: %+v", s) |
| 236 | } |
| 237 | s = FromEnv(context.Background(), env(nil), client) |
| 238 | if s == nil || s.HasWebhook() || s.HasSNS() { |
| 239 | t.Fatalf("nothing set: %+v", s) |
| 240 | } |
| 241 | } |
| 242 | |