carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strconv"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/mail"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// The scheme has one executable definition, and both ends are held to it: a
20// body signed by the console verifies with the key the app is given, and
21// nothing about it can be changed on the way.
22func TestAMailEventSignatureVerifiesAndNothingElseDoes(t *testing.T) {
23 fx := newConsoleFixture(t)
24 key, err := fx.st.MailEventsSigningKey()
25 if err != nil {
26 t.Fatal(err)
27 }
28 again, _ := fx.st.MailEventsSigningKey()
29 if mailEventsPublicKey(again) != mailEventsPublicKey(key) {
30 t.Fatal("the signing key changed between reads — every app's delivered key would stop verifying")
31 }
32 pub := mailEventsPublicKey(key)
33 now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC)
34 body := []byte(`{"id":"n1","account":"beb","app":"jb-book","type":"Bounce"}`)
35 header := signMailEvent(key, now, body)
36
37 if err := VerifyMailEvent(pub, header, body, now.Add(time.Minute), 5*time.Minute); err != nil {
38 t.Fatalf("a genuine delivery did not verify: %v", err)
39 }
40 other, _ := newConsoleFixture(t).st.MailEventsSigningKey()
41 for name, c := range map[string]struct {
42 key, header string
43 body []byte
44 at time.Time
45 }{
46 "a changed body": {pub, header, []byte(strings.Replace(string(body), "jb-book", "jb-site", 1)), now},
47 "another deployment's key": {mailEventsPublicKey(other), header, body, now},
48 "a replay long after": {pub, header, body, now.Add(10 * time.Minute)},
49 "a timestamp from the future": {pub, header, body, now.Add(-10 * time.Minute)},
50 "a moved timestamp": {pub, strings.Replace(header, "t=", "t=1", 1), body, now},
51 "no signature at all": {pub, "t=1726390800", body, now},
52 } {
53 if err := VerifyMailEvent(c.key, c.header, c.body, c.at, 5*time.Minute); err == nil {
54 t.Errorf("%s verified", name)
55 }
56 }
57}
58
59// pushFixture is one app on one domain, asking to be told, with the push
60// pointed at a test server instead of the app's platform host.
61func pushFixture(t *testing.T, handler http.HandlerFunc) (*consoleFixture, *httptest.Server) {
62 t.Helper()
63 fx := newConsoleFixture(t)
64 ctx := context.Background()
65 now := time.Now()
66 for _, app := range []string{"jb-book", "jb-site"} {
67 if _, err := fx.st.CreateAppIn(OpsAccountID, app, "bill@tito.io", now); err != nil {
68 t.Fatal(err)
69 }
70 }
71 rec := &mail.DomainRecord{
72 Domain: "lgc.li", Kind: mail.KindCustom, Apps: []string{"jb-book", "jb-site"},
73 Regions: map[string]*mail.RegionState{"eu-west-1": {
74 IdentityARN: "arn:aws:ses:eu-west-1:1:identity/lgc.li", MailFrom: "mail-eu-west-1.lgc.li", Status: mail.StatusVerified,
75 }},
76 CreatedAt: now,
77 }
78 if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil {
79 t.Fatal(err)
80 }
81 for _, app := range []string{"jb-book", "jb-site"} {
82 if err := fx.st.SetMailEventHook(ctx, release.OpsAccount, app, "/mail/events", "bill@tito.io", now); err != nil {
83 t.Fatal(err)
84 }
85 }
86 srv := httptest.NewServer(handler)
87 t.Cleanup(srv.Close)
88 fx.srv.mailPushTarget = func(acct, app, path string) string { return srv.URL + path + "?app=" + app }
89 return fx, srv
90}
91
92func sentBy(app string, ev mail.Event) mail.Event {
93 ev.Caller = mail.UserName(release.OpsAccount, app)
94 return ev
95}
96
97func queued(t *testing.T, fx *consoleFixture, app string) int {
98 t.Helper()
99 var n int
100 if err := fx.st.db.QueryRow(`SELECT count(*) FROM mail_event_deliveries WHERE app = ?`, app).Scan(&n); err != nil {
101 t.Fatal(err)
102 }
103 return n
104}
105
106// Only an event SES attributed to the app itself is ever queued for it, and
107// only a type worth telling. A notification SNS delivers twice is queued
108// once.
109func TestOnlyTheSendersOwnOutcomesAreQueued(t *testing.T) {
110 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {})
111 ctx := context.Background()
112 now := time.Now()
113
114 bounce := sentBy("jb-book", bounceTo("ann@example.com", "m-ann", now))
115 fx.srv.recordAndEnforceMail(ctx, bounce, "sns-1")
116 fx.srv.recordAndEnforceMail(ctx, bounce, "sns-1")
117 if n := queued(t, fx, "jb-book"); n != 1 {
118 t.Errorf("jb-book queued %d, want 1 — a redelivered notification is one delivery", n)
119 }
120 if n := queued(t, fx, "jb-site"); n != 0 {
121 t.Errorf("jb-site was queued another app's recipient: %d", n)
122 }
123
124 // Attributed only by the shared domain: kept in both logs, pushed to
125 // neither.
126 fx.srv.recordAndEnforceMail(ctx, bounceTo("bob@example.com", "m-bob", now), "sns-2")
127 if n := queued(t, fx, "jb-book") + queued(t, fx, "jb-site"); n != 1 {
128 t.Errorf("a domain-attributed event was queued (%d rows in all)", n-1)
129 }
130
131 // A Send is not news to the app that sent it.
132 send := sentBy("jb-book", mail.Event{Type: mail.EventSend, Domain: "lgc.li", MessageID: "m-cat", At: now, Destination: []string{"cat@example.com"}})
133 fx.srv.recordAndEnforceMail(ctx, send, "sns-3")
134 if n := queued(t, fx, "jb-book"); n != 1 {
135 t.Errorf("a Send was queued")
136 }
137
138 // And an app that has not asked is not queued anything.
139 if _, err := fx.st.DeleteMailEventHook(ctx, release.OpsAccount, "jb-book", now); err != nil {
140 t.Fatal(err)
141 }
142 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("dan@example.com", "m-dan", now)), "sns-4")
143 if n := queued(t, fx, "jb-book"); n != 1 {
144 t.Errorf("an app with no hook was queued")
145 }
146}
147
148// A delivery lands at the app's path, signed with the key the app was
149// given, carrying who it was about and what the receiving server said.
150func TestADeliveryReachesTheAppSigned(t *testing.T) {
151 var mu sync.Mutex
152 var gotPath, gotSig string
153 var gotBody []byte
154 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {
155 mu.Lock()
156 defer mu.Unlock()
157 gotPath = r.URL.Path + "?" + r.URL.RawQuery
158 gotSig = r.Header.Get("Carlos-Signature")
159 gotBody, _ = io.ReadAll(r.Body)
160 w.WriteHeader(http.StatusNoContent)
161 })
162 ctx := context.Background()
163 now := time.Now()
164 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", now)), "sns-1")
165
166 if err := fx.srv.mailPushPass(ctx, time.Now); err != nil {
167 t.Fatal(err)
168 }
169 mu.Lock()
170 defer mu.Unlock()
171 if gotPath != "/mail/events?app=jb-book" {
172 t.Fatalf("delivered to %q", gotPath)
173 }
174 key, _ := fx.st.MailEventsSigningKey()
175 if err := VerifyMailEvent(mailEventsPublicKey(key), gotSig, gotBody, time.Now(), 5*time.Minute); err != nil {
176 t.Fatalf("the app could not verify what it was sent: %v", err)
177 }
178 var p MailEventPayload
179 if err := json.Unmarshal(gotBody, &p); err != nil {
180 t.Fatal(err)
181 }
182 if p.ID != "sns-1" || p.Account != release.OpsAccount || p.App != "jb-book" || p.Type != mail.EventBounce ||
183 p.MessageID != "m-ann" || p.Detail != "Permanent/NoEmail" {
184 t.Errorf("payload = %+v", p)
185 }
186 if len(p.Recipients) != 1 || p.Recipients[0].Address != "ann@example.com" || p.Recipients[0].Status != "5.1.1" {
187 t.Errorf("recipients = %+v", p.Recipients)
188 }
189 st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{})
190 if st.Delivered != 1 || st.Pending != 0 || st.LastDelivered.IsZero() {
191 t.Errorf("status after delivery = %+v", st)
192 }
193
194 // Delivered is delivered: the next pass sends nothing.
195 gotPath = ""
196 mu.Unlock()
197 fx.srv.mailPushPass(ctx, time.Now)
198 mu.Lock()
199 if gotPath != "" {
200 t.Error("a delivered event was sent again")
201 }
202}
203
204// A delivery that does not land is tried again on a widening schedule, and
205// given up on when the schedule is spent — never retried for ever, and never
206// kept past the log it came from.
207func TestAFailedDeliveryBacksOffAndIsGivenUpOn(t *testing.T) {
208 var mu sync.Mutex
209 hits := 0
210 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {
211 mu.Lock()
212 hits++
213 mu.Unlock()
214 w.WriteHeader(http.StatusServiceUnavailable)
215 })
216 ctx := context.Background()
217 now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC)
218 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", now)), "sns-1")
219 // Queued by the real clock; start the fake one there.
220 var created int64
221 fx.st.db.QueryRow(`SELECT created_at FROM mail_event_deliveries`).Scan(&created)
222 clock := time.Unix(created, 0)
223 tick := func() time.Time { return clock }
224
225 fx.srv.mailPushPass(ctx, tick)
226 st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{})
227 if st.Pending != 1 || st.LastStatus != 503 || !strings.Contains(st.LastError, "503") || !st.LastFailedAt.Equal(clock) {
228 t.Fatalf("after one failure: %+v — the failure should be dated when it happened, %s", st, clock.UTC())
229 }
230 // Not due again until the first backoff has passed.
231 clock = clock.Add(30 * time.Second)
232 fx.srv.mailPushPass(ctx, tick)
233 if hits != 1 {
234 t.Fatalf("retried before its backoff: %d attempts", hits)
235 }
236 for _, wait := range mailPushBackoff {
237 clock = clock.Add(wait)
238 fx.srv.mailPushPass(ctx, tick)
239 }
240 if hits != len(mailPushBackoff)+1 {
241 t.Errorf("attempts = %d, want %d", hits, len(mailPushBackoff)+1)
242 }
243 st, _ = fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{})
244 if st.Abandoned != 1 || st.Pending != 0 {
245 t.Errorf("after the schedule is spent: %+v", st)
246 }
247 clock = clock.Add(24 * time.Hour)
248 fx.srv.mailPushPass(ctx, tick)
249 if hits != len(mailPushBackoff)+1 {
250 t.Error("an abandoned delivery was tried again")
251 }
252
253 // And the row is gone once the retention window has passed.
254 clock = time.Unix(created, 0).Add(mailEventsRetention + time.Hour)
255 fx.srv.mailPushPass(ctx, tick)
256 if n := queued(t, fx, "jb-book"); n != 0 {
257 t.Errorf("a queued payload outlived the retention window")
258 }
259}
260
261// A redirect is where a delivery would become a POST of somebody's address
262// at a host the far end chooses. It is not followed.
263func TestADeliveryDoesNotFollowARedirect(t *testing.T) {
264 elsewhere := notReached(t)
265 defer elsewhere.Close()
266 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {
267 http.Redirect(w, r, elsewhere.URL+"/steal", http.StatusTemporaryRedirect)
268 })
269 ctx := context.Background()
270 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", time.Now())), "sns-1")
271 fx.srv.mailPushPass(ctx, time.Now)
272 st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{})
273 if st.Delivered != 0 || !strings.Contains(st.LastError, "redirect") {
274 t.Errorf("a redirect counted as delivered, or went unexplained: %+v", st)
275 }
276}
277
278// Turning the push off stops what is already queued, not only what comes
279// next.
280func TestTurningThePushOffStopsTheQueue(t *testing.T) {
281 hits := 0
282 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { hits++ })
283 ctx := context.Background()
284 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", time.Now())), "sns-1")
285 if _, err := fx.st.DeleteMailEventHook(ctx, release.OpsAccount, "jb-book", time.Now()); err != nil {
286 t.Fatal(err)
287 }
288 fx.srv.mailPushPass(ctx, time.Now)
289 if hits != 0 {
290 t.Error("a delivery went out after the push was turned off")
291 }
292}
293
294// The production target is the app's own platform host and nothing else.
295func TestThePushGoesOnlyToTheAppsOwnHost(t *testing.T) {
296 fx := newConsoleFixture(t)
297 fx.srv.cfg.AppsDomain = "OnCarlos.com"
298 got, err := fx.srv.mailPushURL("beb", "jb-book", "/mail/events")
299 if err != nil || got != "https://jb-book.beb.oncarlos.com/mail/events" {
300 t.Errorf("target = %q, %v", got, err)
301 }
302 fx.srv.cfg.AppsDomain = ""
303 if _, err := fx.srv.mailPushURL("beb", "jb-book", "/mail/events"); err == nil {
304 t.Error("a deployment with no apps domain should have nowhere to send, not a guess")
305 }
306}
307
308// One app's handler hanging must not be every app's problem. A pass sends to
309// apps side by side, and an app that does not answer loses its turn after
310// one timeout rather than spending the whole pass.
311func TestAHungAppDoesNotHoldUpAnotherApp(t *testing.T) {
312 defer func(d time.Duration) { mailPushTimeout = d }(mailPushTimeout)
313 mailPushTimeout = 300 * time.Millisecond
314 var mu sync.Mutex
315 hungHits, otherHits := 0, 0
316 unblock := make(chan struct{})
317 defer close(unblock)
318 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {
319 mu.Lock()
320 if r.URL.Query().Get("app") == "jb-book" {
321 hungHits++
322 mu.Unlock()
323 select {
324 case <-unblock:
325 case <-time.After(5 * time.Second):
326 }
327 return
328 }
329 otherHits++
330 mu.Unlock()
331 w.WriteHeader(http.StatusNoContent)
332 })
333 ctx := context.Background()
334 now := time.Now()
335 for i := 0; i < 5; i++ {
336 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-hung-"+string(rune('a'+i)), now)), "sns-hung-"+string(rune('a'+i)))
337 }
338 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-site", bounceTo("bob@example.com", "m-site", now)), "sns-site")
339
340 started := time.Now()
341 if err := fx.srv.mailPushPass(ctx, time.Now); err != nil {
342 t.Fatal(err)
343 }
344 if took := time.Since(started); took > 2*time.Second {
345 t.Errorf("the pass took %v — a hung app spent it", took)
346 }
347 mu.Lock()
348 defer mu.Unlock()
349 if otherHits != 1 {
350 t.Errorf("the other app was sent %d, want its one delivery", otherHits)
351 }
352 if hungHits != 1 {
353 t.Errorf("the hung app was tried %d times in one pass, want 1 — its turn ends at the first silence", hungHits)
354 }
355 st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{})
356 if st.Pending != 5 {
357 t.Errorf("the hung app's deliveries should all still be waiting: %+v", st)
358 }
359}
360
361// A backlog waits behind itself. An app with far more due than a pass sends
362// does not push another app's one delivery out of the pass.
363func TestABacklogIsItsOwnAppsToWaitBehind(t *testing.T) {
364 var mu sync.Mutex
365 sent := map[string]int{}
366 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {
367 mu.Lock()
368 sent[r.URL.Query().Get("app")]++
369 mu.Unlock()
370 w.WriteHeader(http.StatusNoContent)
371 })
372 ctx := context.Background()
373 now := time.Now()
374 for i := 0; i < 3*mailPushPerApp; i++ {
375 id := "m-busy-" + strconv.Itoa(i)
376 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", id, now)), "sns-"+id)
377 }
378 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-site", bounceTo("bob@example.com", "m-quiet", now)), "sns-quiet")
379 if err := fx.srv.mailPushPass(ctx, time.Now); err != nil {
380 t.Fatal(err)
381 }
382 mu.Lock()
383 defer mu.Unlock()
384 if sent["jb-site"] != 1 || sent["jb-book"] != mailPushPerApp {
385 t.Errorf("sent %v, want jb-site 1 and jb-book %d", sent, mailPushPerApp)
386 }
387}
388
389// Nothing is sent for an app that is not live, and a trashed app's queue is
390// given up on rather than kept waiting for a host it may no longer own.
391func TestNothingIsPushedForATrashedApp(t *testing.T) {
392 hits := 0
393 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { hits++ })
394 ctx := context.Background()
395 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-ann", time.Now())), "sns-1")
396 if err := fx.st.TrashAppRow(OpsAccountID, "jb-book", time.Now()); err != nil {
397 t.Fatal(err)
398 }
399 fx.srv.mailPushPass(ctx, time.Now)
400 if hits != 0 {
401 t.Error("a delivery went to a trashed app's host")
402 }
403 st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{})
404 if st.Abandoned != 1 || st.Pending != 0 {
405 t.Errorf("a trashed app's delivery should be given up on: %+v", st)
406 }
407 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("bob@example.com", "m-bob", time.Now())), "sns-2")
408 if n := queued(t, fx, "jb-book"); n != 1 {
409 t.Errorf("a trashed app was queued something new")
410 }
411}
412
413// The push is the app's, like its log: a rename takes it along, and a purge
414// leaves nothing for the next app of that name to inherit.
415func TestThePushFollowsTheAppAndNotItsName(t *testing.T) {
416 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {})
417 ctx := context.Background()
418 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-site", bounceTo("ann@example.com", "m-ann", time.Now())), "sns-1")
419
420 if err := fx.st.RenameAppIn(OpsAccountID, "jb-site", "jb-web"); err != nil {
421 t.Fatal(err)
422 }
423 if hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-web"); hook == nil {
424 t.Error("the renamed app lost its push")
425 }
426 if hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-site"); hook != nil {
427 t.Error("the old name kept a push for whoever claims it next")
428 }
429 if n := queued(t, fx, "jb-web") + queued(t, fx, "jb-site"); n != 0 {
430 t.Errorf("a rename carried %d queued deliveries whose bodies name the old app", n)
431 }
432
433 if err := fx.st.TrashAppRow(OpsAccountID, "jb-web", time.Now()); err != nil {
434 t.Fatal(err)
435 }
436 if err := fx.st.PurgeAppRow(OpsAccountID, "jb-web"); err != nil {
437 t.Fatal(err)
438 }
439 if hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-web"); hook != nil {
440 t.Error("a purged app's push survived for its name")
441 }
442 if n := queued(t, fx, "jb-web") + queued(t, fx, "jb-site"); n != 0 {
443 t.Errorf("a purged app's queue survived: %d rows", n)
444 }
445}
446
447// An app that answers, but not with a 2xx, ends its turn at the first
448// failure: offering it the next row only holds a slot others wait on.
449func TestAFailingAppEndsItsTurnAtTheFirstFailure(t *testing.T) {
450 var mu sync.Mutex
451 hits := 0
452 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {
453 mu.Lock()
454 hits++
455 mu.Unlock()
456 w.WriteHeader(http.StatusInternalServerError)
457 })
458 ctx := context.Background()
459 now := time.Now()
460 for i := 0; i < mailPushPerApp; i++ {
461 id := "m-" + strconv.Itoa(i)
462 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", id, now)), "sns-"+id)
463 }
464 fx.srv.mailPushPass(ctx, time.Now)
465 mu.Lock()
466 defer mu.Unlock()
467 if hits != 1 {
468 t.Errorf("a failing app was offered %d rows in one pass, want 1", hits)
469 }
470}
471
472// An app that accepts nothing for a day is suspended: what is waiting is
473// given up on, nothing new is queued, and turning the push on again lifts
474// it. Without this, a failing app's backlog grows for the thirty days the
475// queue is kept.
476func TestADayOfFailuresSuspendsThePush(t *testing.T) {
477 hits := 0
478 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {
479 hits++
480 w.WriteHeader(http.StatusNotFound)
481 })
482 ctx := context.Background()
483 send := func(id string, at time.Time) {
484 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", id, at)), "sns-"+id)
485 }
486 clock := time.Now()
487 tick := func() time.Time { return clock }
488 for i := 0; i < 20; i++ {
489 send("m-"+strconv.Itoa(i), clock)
490 }
491 fx.srv.mailPushPass(ctx, tick)
492 hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book")
493 if hook.FailingSince.IsZero() || hook.Suspended() {
494 t.Fatalf("after the first failure: %+v", hook)
495 }
496
497 // Most of a day of failing is not a day.
498 clock = clock.Add(23 * time.Hour)
499 fx.srv.mailPushPass(ctx, tick)
500 if hook, _ = fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book"); hook.Suspended() {
501 t.Fatal("suspended before a day had passed")
502 }
503
504 clock = clock.Add(2 * time.Hour)
505 fx.srv.mailPushPass(ctx, tick)
506 hook, _ = fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book")
507 if !hook.Suspended() || !strings.Contains(hook.SuspendedWhy, "404") {
508 t.Fatalf("a day of failures should suspend, saying what the app answered: %+v", hook)
509 }
510 st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", hook.SetAt)
511 if st.Pending != 0 || st.Abandoned != 20 {
512 t.Errorf("a suspension should give up on the whole queue: %+v", st)
513 }
514
515 // Suspended: nothing new is queued, and a pass sends nothing.
516 send("m-late", clock)
517 if n := queued(t, fx, "jb-book"); n != 20 {
518 t.Errorf("a suspended push queued more (%d rows)", n)
519 }
520 before := hits
521 clock = clock.Add(time.Hour)
522 fx.srv.mailPushPass(ctx, tick)
523 if hits != before {
524 t.Error("a suspended push was sent to")
525 }
526
527 // Turning it on again lifts the suspension and starts clean.
528 if err := fx.st.SetMailEventHook(ctx, release.OpsAccount, "jb-book", "/mail/events", "bill@tito.io", clock); err != nil {
529 t.Fatal(err)
530 }
531 hook, _ = fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book")
532 if hook.Suspended() || !hook.FailingSince.IsZero() {
533 t.Errorf("turning it on again should lift the suspension: %+v", hook)
534 }
535 send("m-after", clock)
536 if n := queued(t, fx, "jb-book"); n != 21 {
537 t.Errorf("after turning it on again, events should queue: %d rows", n)
538 }
539}
540
541// A run of failures belongs to a backlog, not to the app's history: an app
542// whose queue emptied — here by its only delivery running out of retries,
543// with nothing ever landing — is not suspended by its next miss days later.
544func TestAnOldFailureWithNothingWaitingDoesNotSuspend(t *testing.T) {
545 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })
546 ctx := context.Background()
547 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-1", time.Now())), "sns-1")
548 var created int64
549 fx.st.db.QueryRow(`SELECT created_at FROM mail_event_deliveries`).Scan(&created)
550 clock := time.Unix(created, 0)
551 tick := func() time.Time { return clock }
552 fx.srv.mailPushPass(ctx, tick)
553 for _, wait := range mailPushBackoff {
554 clock = clock.Add(wait)
555 fx.srv.mailPushPass(ctx, tick)
556 }
557 if st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", time.Time{}); st.Pending != 0 || st.Abandoned != 1 {
558 t.Fatalf("the one delivery should have run out of retries by now: %+v", st)
559 }
560 clock = clock.Add(3 * 24 * time.Hour)
561 fx.srv.mailPushPass(ctx, tick) // nothing waiting: the run ends here
562 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("bob@example.com", "m-2", clock)), "sns-2")
563 fx.st.db.Exec(`UPDATE mail_event_deliveries SET next_at = ?, created_at = ? WHERE notice = 'sns-2'`, clock.Unix(), clock.Unix())
564 fx.srv.mailPushPass(ctx, tick)
565 if hook, _ := fx.st.MailEventHookFor(ctx, release.OpsAccount, "jb-book"); hook.Suspended() {
566 t.Errorf("one miss after days of quiet suspended the push: %+v", hook)
567 }
568}
569
570// A pass seeks each app's oldest due rows through an index rather than
571// sorting everything due: its cost follows the number of apps asking, not
572// the size of anyone's backlog.
573func TestAPassDoesNotSortAnAppsBacklog(t *testing.T) {
574 fx := newConsoleFixture(t)
575 rows, err := fx.st.db.Query(`EXPLAIN QUERY PLAN `+mailPushDueSQL, "beb", "jb-book", time.Now().Unix(), mailPushPerApp)
576 if err != nil {
577 t.Fatal(err)
578 }
579 defer rows.Close()
580 var plan []string
581 for rows.Next() {
582 var id, parent, notused int
583 var detail string
584 rows.Scan(&id, &parent, &notused, &detail)
585 plan = append(plan, detail)
586 }
587 joined := strings.Join(plan, " | ")
588 if !strings.Contains(joined, "mail_event_deliveries_app_due") || strings.Contains(joined, "TEMP B-TREE") {
589 t.Errorf("the due read should seek the per-app index without sorting: %s", joined)
590 }
591
592 // And a large backlog costs a pass one app's worth of rows.
593 sent := 0
594 fx2, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { sent++; w.WriteHeader(http.StatusNoContent) })
595 ctx := context.Background()
596 now := time.Now()
597 for i := 0; i < 2000; i++ {
598 if err := fx2.st.EnqueueMailEventDelivery(ctx, release.OpsAccount, "jb-book", "bulk-"+strconv.Itoa(i), []byte(`{}`), now); err != nil {
599 t.Fatal(err)
600 }
601 }
602 fx2.srv.mailPushPass(ctx, time.Now)
603 if sent != mailPushPerApp {
604 t.Errorf("one pass sent %d, want %d", sent, mailPushPerApp)
605 }
606}
607
608// Turning the push off and on again, or lifting a suspension, starts the
609// status afresh: an old failure is not reported as the last attempt.
610func TestTheStatusForgetsFailuresFromBeforeThePushWasTurnedOn(t *testing.T) {
611 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })
612 ctx := context.Background()
613 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-book", bounceTo("ann@example.com", "m-1", time.Now())), "sns-1")
614 fx.srv.mailPushPass(ctx, time.Now)
615 if _, err := fx.st.DeleteMailEventHook(ctx, release.OpsAccount, "jb-book", time.Now()); err != nil {
616 t.Fatal(err)
617 }
618 later := time.Now().Add(time.Minute)
619 if err := fx.st.SetMailEventHook(ctx, release.OpsAccount, "jb-book", "/mail/events", "bill@tito.io", later); err != nil {
620 t.Fatal(err)
621 }
622 st, _ := fx.st.MailEventPushStatusFor(ctx, release.OpsAccount, "jb-book", later)
623 if st.LastError != "" || st.LastStatus != 0 {
624 t.Errorf("a failure from before the push was turned on again was reported: %+v", st)
625 }
626 var why, lastErr string
627 fx.st.db.QueryRow(`SELECT abandoned_why, last_error FROM mail_event_deliveries`).Scan(&why, &lastErr)
628 if why != "push turned off" || !strings.Contains(lastErr, "503") {
629 t.Errorf("giving up should record why without overwriting what the app answered: why=%q last_error=%q", why, lastErr)
630 }
631}
632
633// A move to another account drops the queue, like a rename: the bodies name
634// the old account.
635func TestAMoveDropsTheQueue(t *testing.T) {
636 fx, _ := pushFixture(t, func(w http.ResponseWriter, r *http.Request) {})
637 ctx := context.Background()
638 fx.srv.recordAndEnforceMail(ctx, sentBy("jb-site", bounceTo("ann@example.com", "m-1", time.Now())), "sns-1")
639 if err := fx.st.CreateAccount(99, "dest", "Destination", "bill@tito.io", time.Now()); err != nil {
640 t.Fatal(err)
641 }
642 if err := fx.st.MoveApp("jb-site", OpsAccountID, 99); err != nil {
643 t.Fatal(err)
644 }
645 var n int
646 fx.st.db.QueryRow(`SELECT count(*) FROM mail_event_deliveries WHERE app = 'jb-site'`).Scan(&n)
647 if n != 0 {
648 t.Errorf("a move carried %d queued deliveries naming the old account", n)
649 }
650 if hook, _ := fx.st.MailEventHookFor(ctx, "dest", "jb-site"); hook == nil {
651 t.Error("the push itself should move with the app")
652 }
653}
654
655// A prune that fails is not stamped, and does not cost the pass.
656func TestAFailedQueuePruneIsTriedAgain(t *testing.T) {
657 fx := newConsoleFixture(t)
658 ctx, cancel := context.WithCancel(context.Background())
659 cancel()
660 if err := fx.st.pruneMailEventDeliveries(ctx, time.Now()); err == nil {
661 t.Fatal("a prune with a cancelled context should fail")
662 }
663 if got := fx.st.mailPushPruned.Load(); got != 0 {
664 t.Errorf("the stamp was left at %d after a failed prune", got)
665 }
666}
667