carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package alerting
2
3import (
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.
16type fakeSNS struct {
17 in []*sns.PublishInput
18 err error
19}
20
21func (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
29const testTopicARN = "arn:aws:sns:eu-west-1:271376211898:carlos-flagship-ie-alerts"
30
31type posted struct {
32 url string
33 body []byte
34}
35
36func 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.
44func 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
54func 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.
91func 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.
110func 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.
127func 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
156func 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.
170func 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
191func 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.
214func 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