carlos / movement Public

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

Plain git — no account needed to clone.

Download

Download this file

1package movementtest
2
3import (
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.
16func 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.
35func 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.
53type blocking struct{ entered chan struct{} }
54
55func (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.
67func 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.
99func 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