| 1 | package movementtest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "testing" |
| 6 | "time" |
| 7 | |
| 8 | movement "amadan.net/carlos/movement/internal/movement" |
| 9 | ) |
| 10 | |
| 11 | // The budget is what actually stands between a stranger's inbox and a flood |
| 12 | // from our own verified sending identity. Proof of work is a speed bump — |
| 13 | // 2^18 SHA-256 is under a millisecond on a commodity GPU — so this is the |
| 14 | // defence, and it is persisted because the process hibernates and an |
| 15 | // in-memory counter would forget on the next wake. |
| 16 | func TestAnAddressCannotBeMailedPastItsAllowance(t *testing.T) { |
| 17 | h := newApp(t) |
| 18 | const addr = "flooded@example.com" |
| 19 | |
| 20 | for i := 0; i < movement.PerAddressPerDay+3; i++ { |
| 21 | postSignup(t, h, submission(t, addr, "curious")) |
| 22 | } |
| 23 | |
| 24 | // Give any in-flight sends a moment to land before counting. |
| 25 | time.Sleep(100 * time.Millisecond) |
| 26 | |
| 27 | if got := h.Mail.countTo(addr); got != movement.PerAddressPerDay { |
| 28 | t.Errorf("%d messages reached %s, want the budget of %d", |
| 29 | got, addr, movement.PerAddressPerDay) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // A suppressed send is invisible from the outside. If it were not, the budget |
| 34 | // itself would become the oracle the one rule exists to prevent. |
| 35 | func TestSuppressedSendRendersTheSamePage(t *testing.T) { |
| 36 | h := newApp(t) |
| 37 | const addr = "quiet@example.com" |
| 38 | |
| 39 | first := postSignup(t, h, submission(t, addr, "curious")).Body.String() |
| 40 | for i := 0; i < movement.PerAddressPerDay; i++ { |
| 41 | postSignup(t, h, submission(t, addr, "curious")) |
| 42 | } |
| 43 | suppressed := postSignup(t, h, submission(t, addr, "curious")).Body.String() |
| 44 | |
| 45 | if suppressed != first { |
| 46 | t.Error("a submission whose message was suppressed looked different from one that was sent") |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | // blocking never returns. mail.Sender.Send discards the context it is given |
| 51 | // and smtp.SendMail dials with no deadline, so this is not a hypothetical |
| 52 | // sender — it is what a hung SES connection looks like from in here. |
| 53 | type blocking struct{ entered chan struct{} } |
| 54 | |
| 55 | func (b *blocking) Send(ctx context.Context, to, subject, body string) error { |
| 56 | select { |
| 57 | case b.entered <- struct{}{}: |
| 58 | default: |
| 59 | } |
| 60 | <-ctx.Done() // held until the app's own deadline fires |
| 61 | return ctx.Err() |
| 62 | } |
| 63 | |
| 64 | // The send happens after the transaction commits, in a goroutine. Inside the |
| 65 | // transaction it would hold the single SQLite writer and stall every other |
| 66 | // signup and the scheduled tick behind it. |
| 67 | func TestAHungSenderDoesNotBlockTheNextSubmission(t *testing.T) { |
| 68 | hung := &blocking{entered: make(chan struct{}, 1)} |
| 69 | h := newAppWith(t, hung) |
| 70 | |
| 71 | postSignup(t, h, submission(t, "first@example.com", "curious")) |
| 72 | |
| 73 | select { |
| 74 | case <-hung.entered: |
| 75 | case <-time.After(2 * time.Second): |
| 76 | t.Fatal("the sender was never called") |
| 77 | } |
| 78 | |
| 79 | // With the first send still hanging, a second submission must complete. |
| 80 | done := make(chan struct{}) |
| 81 | go func() { |
| 82 | postSignup(t, h, submission(t, "second@example.com", "curious")) |
| 83 | close(done) |
| 84 | }() |
| 85 | |
| 86 | select { |
| 87 | case <-done: |
| 88 | case <-time.After(5 * time.Second): |
| 89 | t.Fatal("a hung send blocked the next submission; it is holding the writer") |
| 90 | } |
| 91 | |
| 92 | if got := countSignups(t, h, "second@example.com"); got != 1 { |
| 93 | t.Errorf("the second submission wrote %d rows, want 1", got) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | // The row is committed before the message is attempted, so a send that never |
| 98 | // succeeds still leaves something to re-submit against. |
| 99 | func TestRowSurvivesASendThatNeverCompletes(t *testing.T) { |
| 100 | hung := &blocking{entered: make(chan struct{}, 1)} |
| 101 | h := newAppWith(t, hung) |
| 102 | |
| 103 | postSignup(t, h, submission(t, "stranded@example.com", "curious")) |
| 104 | |
| 105 | if got := countSignups(t, h, "stranded@example.com"); got != 1 { |
| 106 | t.Fatal("the signup was rolled back when its message hung") |
| 107 | } |
| 108 | if row := loadSignup(t, h, "stranded@example.com"); len(row.TokenHash) == 0 { |
| 109 | t.Error("no token was stored, so no future link could ever work") |
| 110 | } |
| 111 | } |
| 112 | |