| 1 | package movementtest |
| 2 | |
| 3 | import ( |
| 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. |
| 18 | const 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. |
| 26 | func 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 | |
| 33 | func 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 | |
| 59 | func 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. |
| 73 | func 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. |
| 113 | func 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. |
| 130 | func 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. |
| 158 | func 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. |
| 178 | func 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 | |
| 190 | func 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. |
| 215 | func 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 | |
| 227 | func 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. |
| 240 | func 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 | |
| 262 | func 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 ' — 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 | |
| 286 | func 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 | |