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 "html/template"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12
13 movement "amadan.net/carlos/movement/internal/movement"
14)
15
16// powDifficulty is low so the suite stays fast. What these tests are about is
17// the handler's decisions, not the cost of a solve.
18const powDifficulty = 8
19
20// submission builds a valid, solved form post.
21//
22// The challenge is minted with a timestamp ten seconds in the past rather
23// than by scraping a rendered page, because the handler refuses anything
24// submitted less than three seconds after its challenge was issued — and a
25// suite that slept three seconds per case would be a suite nobody runs.
26func submission(t *testing.T, email, stance string) url.Values {
27 t.Helper()
28 key := movement.ChallengeKey(testEnv().InstanceKey)
29 c := movement.NewChallenge(key, time.Now().Add(-10*time.Second), powDifficulty)
30 return solved(t, c, email, stance)
31}
32
33func solved(t *testing.T, c movement.Challenge, email, stance string) url.Values {
34 t.Helper()
35 counter := ""
36 for i := 0; i < 1<<24; i++ {
37 s := strconv.Itoa(i)
38 if movement.VerifyPow(c.Nonce, email, s, c.Difficulty) {
39 counter = s
40 break
41 }
42 }
43 if counter == "" {
44 t.Fatal("no proof-of-work solution found")
45 }
46 return url.Values{
47 "stance": {stance},
48 "email": {email},
49 "name": {"Alex Río"},
50 "background": {"why I am interested"},
51 "nonce": {c.Nonce},
52 "issued_at": {strconv.FormatInt(c.IssuedAt, 10)},
53 "difficulty": {strconv.Itoa(c.Difficulty)},
54 "seal": {c.Seal},
55 "counter": {counter},
56 }
57}
58
59func postSignup(t *testing.T, h http.Handler, form url.Values) *httptest.ResponseRecorder {
60 t.Helper()
61 req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
62 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
63 req.Header.Set("Origin", testOrigin) // csrf.Protect checks the origin, not a token
64 rec := httptest.NewRecorder()
65 h.ServeHTTP(rec, req)
66 return rec
67}
68
69// This is the second half of the one rule, and the test most likely to be
70// broken by a well-meaning copy change. If a returning visitor were told
71// "you're already signed up", this form would become a way for anybody to
72// check who has taken a public position on AI, one address at a time.
73func TestSignupRevealsNothingAboutAnAddressState(t *testing.T) {
74 h := newApp(t)
75
76 unknown := postSignup(t, h, submission(t, "unknown@example.com", "curious"))
77
78 // Same address twice: the second submission meets a pending row.
79 postSignup(t, h, submission(t, "pending@example.com", "curious"))
80 pending := postSignup(t, h, submission(t, "pending@example.com", "curious"))
81
82 // And one that is genuinely on the list. Submitting for an address that
83 // is merely pending is not this case: the interesting path is the one
84 // where the handler writes nothing and sends nothing, and has to look
85 // exactly like the path where it does both.
86 postSignup(t, h, submission(t, "confirmed@example.com", "curious"))
87 confirmSignup(t, h, "confirmed@example.com")
88 confirmed := postSignup(t, h, submission(t, "confirmed@example.com", "curious"))
89
90 // A budget-suppressed submission renders the same page too, and it is a
91 // separate code path from all three above.
92 for i := 0; i < movement.PerAddressPerDay+1; i++ {
93 postSignup(t, h, submission(t, "busy@example.com", "curious"))
94 }
95 suppressed := postSignup(t, h, submission(t, "busy@example.com", "curious"))
96
97 for name, rec := range map[string]*httptest.ResponseRecorder{
98 "pending": pending, "confirmed": confirmed, "budget-suppressed": suppressed,
99 } {
100 if rec.Code != unknown.Code {
101 t.Errorf("%s: status %d, unknown address got %d", name, rec.Code, unknown.Code)
102 }
103 if rec.Body.String() != unknown.Body.String() {
104 t.Errorf("%s: response body differs from an unknown address's", name)
105 }
106 }
107}
108
109// Without this, one solved challenge is replayable for its whole two-hour
110// window, and every replay sends another message to whatever address it
111// names — a mail-bombing primitive aimed at a stranger's inbox, from our own
112// verified sending identity.
113func TestNonceCannotBeSpentTwice(t *testing.T) {
114 h := newApp(t)
115 form := submission(t, "replay@example.com", "curious")
116
117 first := postSignup(t, h, form)
118 second := postSignup(t, h, form) // byte-identical replay
119
120 if second.Code != first.Code || second.Body.String() != first.Body.String() {
121 t.Error("a replay must be indistinguishable from a first submission")
122 }
123 if got := countSignups(t, h, "replay@example.com"); got != 1 {
124 t.Errorf("replay wrote %d rows, want 1", got)
125 }
126}
127
128// A blanket upsert would let anybody who knows an address knock it off the
129// list, or re-file a named person under a stance they never chose.
130func TestConfirmedRowIsUntouchedBySignup(t *testing.T) {
131 h := newApp(t)
132 postSignup(t, h, submission(t, "settled@example.com", "curious"))
133 confirmSignup(t, h, "settled@example.com")
134
135 before := loadSignup(t, h, "settled@example.com")
136 if !before.Confirmed {
137 t.Fatal("setup failed: the row should be confirmed")
138 }
139
140 postSignup(t, h, submission(t, "settled@example.com", "never"))
141
142 after := loadSignup(t, h, "settled@example.com")
143 if !after.Confirmed {
144 t.Error("a stranger's submission un-confirmed a confirmed address")
145 }
146 if after.Stance != before.Stance {
147 t.Errorf("stance rewritten from %q to %q by someone who knew the address",
148 before.Stance, after.Stance)
149 }
150 if after.MailCount != before.MailCount {
151 t.Error("a confirmed row was mailed again")
152 }
153}
154
155// A filled honeypot answers success and writes nothing. An error would teach
156// a crawler which field to leave alone next time; a confirmation page teaches
157// it nothing.
158func TestHoneypotLooksLikeSuccessAndStoresNothing(t *testing.T) {
159 h := newApp(t)
160
161 clean := postSignup(t, h, submission(t, "clean@example.com", "curious"))
162
163 trapped := submission(t, "bot@example.com", "curious")
164 trapped.Set("hp", "http://spam.example")
165 rec := postSignup(t, h, trapped)
166
167 if rec.Code != clean.Code || rec.Body.String() != clean.Body.String() {
168 t.Error("a trapped submission must look exactly like an accepted one")
169 }
170 if got := countSignups(t, h, "bot@example.com"); got != 0 {
171 t.Errorf("the honeypot stored %d rows, want 0", got)
172 }
173}
174
175// Nothing is written when a challenge is minted. A row per page view would
176// turn any crawler into a stream of serialised writes against the single
177// SQLite writer that every signup needs.
178func TestStancePageWritesNothing(t *testing.T) {
179 h := newApp(t)
180 for i := 0; i < 5; i++ {
181 if rec := get(t, h, "/uneasy"); rec.Code != http.StatusOK {
182 t.Fatalf("GET /uneasy: %d", rec.Code)
183 }
184 }
185 if got := countNonces(t, h); got != 0 {
186 t.Errorf("rendering a stance page wrote %d nonce rows, want 0", got)
187 }
188}
189
190func TestRejectionsAllRenderTheSamePage(t *testing.T) {
191 h := newApp(t)
192 baseline := postSignup(t, h, submission(t, "baseline@example.com", "curious")).Body.String()
193
194 cases := map[string]func(url.Values){
195 "forged-seal": func(v url.Values) { v.Set("seal", strings.Repeat("0", 64)) },
196 "short-proof": func(v url.Values) { v.Set("counter", "0") },
197 "unknown-stance": func(v url.Values) { v.Set("stance", "enthusiastic") },
198 "tampered-nonce": func(v url.Values) { v.Set("nonce", "deadbeef") },
199 "lowered-work": func(v url.Values) { v.Set("difficulty", "1") },
200 }
201 for name, mangle := range cases {
202 // The address has to be well-formed, or the case takes the one
203 // deliberately visible path — a malformed address is reported rather
204 // than swallowed, and that is tested separately.
205 form := submission(t, name+"@example.com", "curious")
206 mangle(form)
207 if got := postSignup(t, h, form).Body.String(); got != baseline {
208 t.Errorf("%s: response differs from an accepted submission's", name)
209 }
210 }
211}
212
213// A submission arriving less than three seconds after its challenge was
214// issued is not a person who read three positions and wrote about themselves.
215func TestSubmittingTooFastIsRefused(t *testing.T) {
216 h := newApp(t)
217 key := movement.ChallengeKey(testEnv().InstanceKey)
218 fresh := movement.NewChallenge(key, time.Now(), powDifficulty)
219
220 postSignup(t, h, solved(t, fresh, "fast@example.com", "curious"))
221
222 if got := countSignups(t, h, "fast@example.com"); got != 0 {
223 t.Errorf("a sub-three-second submission wrote %d rows, want 0", got)
224 }
225}
226
227func TestExpiredChallengeIsRefused(t *testing.T) {
228 h := newApp(t)
229 key := movement.ChallengeKey(testEnv().InstanceKey)
230 stale := movement.NewChallenge(key, time.Now().Add(-3*time.Hour), powDifficulty)
231
232 postSignup(t, h, solved(t, stale, "stale@example.com", "curious"))
233
234 if got := countSignups(t, h, "stale@example.com"); got != 0 {
235 t.Errorf("a three-hour-old challenge wrote %d rows, want 0", got)
236 }
237}
238
239// A counter somebody can reset by filling the form in again is not a budget.
240func TestResubmittingDoesNotResetTheMailBudget(t *testing.T) {
241 h := newApp(t)
242 const addr = "repeat@example.com"
243
244 for i := 0; i < movement.PerAddressPerDay; i++ {
245 postSignup(t, h, submission(t, addr, "curious"))
246 }
247 spent := loadSignup(t, h, addr)
248 if spent.MailCount != movement.PerAddressPerDay {
249 t.Fatalf("setup: MailCount = %d, want %d", spent.MailCount, movement.PerAddressPerDay)
250 }
251
252 // One more, past the allowance.
253 postSignup(t, h, submission(t, addr, "curious"))
254
255 after := loadSignup(t, h, addr)
256 if after.MailCount != movement.PerAddressPerDay {
257 t.Errorf("MailCount = %d after exceeding the budget, want it held at %d",
258 after.MailCount, movement.PerAddressPerDay)
259 }
260}
261
262func TestMalformedAddressIsToldSo(t *testing.T) {
263 h := newApp(t)
264 sent := postSignup(t, h, submission(t, "fine@example.com", "curious")).Body.String()
265
266 form := submission(t, "fine@example.com", "curious")
267 form.Set("email", "not-an-address")
268 rec := postSignup(t, h, form)
269
270 // The one visible error, and it leaks nothing: a malformed string is a
271 // property of what was just typed, not of any address's state on the list.
272 if rec.Body.String() == sent {
273 t.Error("a typo should be reported, not silently swallowed")
274 }
275 // Compared escaped, because that is what reaches the page. The copy has an
276 // apostrophe in it, and html/template renders that as &#39; — a raw
277 // comparison fails on correct output, which is a test bug dressed as a
278 // code bug.
279 var escaped strings.Builder
280 template.HTMLEscape(&escaped, []byte(movement.T("form.bademail")))
281 if !strings.Contains(rec.Body.String(), escaped.String()) {
282 t.Error("the malformed-address page should say what went wrong")
283 }
284}
285
286func TestHeaderInjectionInAddressIsRefused(t *testing.T) {
287 h := newApp(t)
288 form := submission(t, "x@example.com", "curious")
289 form.Set("email", "x@example.com\r\nBcc: victim@example.com")
290
291 postSignup(t, h, form)
292
293 if got := countSignups(t, h, "x@example.com"); got != 0 {
294 t.Error("an address containing CRLF must never reach the database or an SMTP header")
295 }
296}
297