package movementtest import ( "html/template" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" "time" movement "amadan.net/carlos/movement/internal/movement" ) // powDifficulty is low so the suite stays fast. What these tests are about is // the handler's decisions, not the cost of a solve. const powDifficulty = 8 // submission builds a valid, solved form post. // // The challenge is minted with a timestamp ten seconds in the past rather // than by scraping a rendered page, because the handler refuses anything // submitted less than three seconds after its challenge was issued — and a // suite that slept three seconds per case would be a suite nobody runs. func submission(t *testing.T, email, stance string) url.Values { t.Helper() key := movement.ChallengeKey(testEnv().InstanceKey) c := movement.NewChallenge(key, time.Now().Add(-10*time.Second), powDifficulty) return solved(t, c, email, stance) } func solved(t *testing.T, c movement.Challenge, email, stance string) url.Values { t.Helper() counter := "" for i := 0; i < 1<<24; i++ { s := strconv.Itoa(i) if movement.VerifyPow(c.Nonce, email, s, c.Difficulty) { counter = s break } } if counter == "" { t.Fatal("no proof-of-work solution found") } return url.Values{ "stance": {stance}, "email": {email}, "name": {"Alex Río"}, "background": {"why I am interested"}, "nonce": {c.Nonce}, "issued_at": {strconv.FormatInt(c.IssuedAt, 10)}, "difficulty": {strconv.Itoa(c.Difficulty)}, "seal": {c.Seal}, "counter": {counter}, } } func postSignup(t *testing.T, h http.Handler, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", testOrigin) // csrf.Protect checks the origin, not a token rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // This is the second half of the one rule, and the test most likely to be // broken by a well-meaning copy change. If a returning visitor were told // "you're already signed up", this form would become a way for anybody to // check who has taken a public position on AI, one address at a time. func TestSignupRevealsNothingAboutAnAddressState(t *testing.T) { h := newApp(t) unknown := postSignup(t, h, submission(t, "unknown@example.com", "curious")) // Same address twice: the second submission meets a pending row. postSignup(t, h, submission(t, "pending@example.com", "curious")) pending := postSignup(t, h, submission(t, "pending@example.com", "curious")) // And one that is genuinely on the list. Submitting for an address that // is merely pending is not this case: the interesting path is the one // where the handler writes nothing and sends nothing, and has to look // exactly like the path where it does both. postSignup(t, h, submission(t, "confirmed@example.com", "curious")) confirmSignup(t, h, "confirmed@example.com") confirmed := postSignup(t, h, submission(t, "confirmed@example.com", "curious")) // A budget-suppressed submission renders the same page too, and it is a // separate code path from all three above. for i := 0; i < movement.PerAddressPerDay+1; i++ { postSignup(t, h, submission(t, "busy@example.com", "curious")) } suppressed := postSignup(t, h, submission(t, "busy@example.com", "curious")) for name, rec := range map[string]*httptest.ResponseRecorder{ "pending": pending, "confirmed": confirmed, "budget-suppressed": suppressed, } { if rec.Code != unknown.Code { t.Errorf("%s: status %d, unknown address got %d", name, rec.Code, unknown.Code) } if rec.Body.String() != unknown.Body.String() { t.Errorf("%s: response body differs from an unknown address's", name) } } } // Without this, one solved challenge is replayable for its whole two-hour // window, and every replay sends another message to whatever address it // names — a mail-bombing primitive aimed at a stranger's inbox, from our own // verified sending identity. func TestNonceCannotBeSpentTwice(t *testing.T) { h := newApp(t) form := submission(t, "replay@example.com", "curious") first := postSignup(t, h, form) second := postSignup(t, h, form) // byte-identical replay if second.Code != first.Code || second.Body.String() != first.Body.String() { t.Error("a replay must be indistinguishable from a first submission") } if got := countSignups(t, h, "replay@example.com"); got != 1 { t.Errorf("replay wrote %d rows, want 1", got) } } // A blanket upsert would let anybody who knows an address knock it off the // list, or re-file a named person under a stance they never chose. func TestConfirmedRowIsUntouchedBySignup(t *testing.T) { h := newApp(t) postSignup(t, h, submission(t, "settled@example.com", "curious")) confirmSignup(t, h, "settled@example.com") before := loadSignup(t, h, "settled@example.com") if !before.Confirmed { t.Fatal("setup failed: the row should be confirmed") } postSignup(t, h, submission(t, "settled@example.com", "never")) after := loadSignup(t, h, "settled@example.com") if !after.Confirmed { t.Error("a stranger's submission un-confirmed a confirmed address") } if after.Stance != before.Stance { t.Errorf("stance rewritten from %q to %q by someone who knew the address", before.Stance, after.Stance) } if after.MailCount != before.MailCount { t.Error("a confirmed row was mailed again") } } // A filled honeypot answers success and writes nothing. An error would teach // a crawler which field to leave alone next time; a confirmation page teaches // it nothing. func TestHoneypotLooksLikeSuccessAndStoresNothing(t *testing.T) { h := newApp(t) clean := postSignup(t, h, submission(t, "clean@example.com", "curious")) trapped := submission(t, "bot@example.com", "curious") trapped.Set("hp", "http://spam.example") rec := postSignup(t, h, trapped) if rec.Code != clean.Code || rec.Body.String() != clean.Body.String() { t.Error("a trapped submission must look exactly like an accepted one") } if got := countSignups(t, h, "bot@example.com"); got != 0 { t.Errorf("the honeypot stored %d rows, want 0", got) } } // Nothing is written when a challenge is minted. A row per page view would // turn any crawler into a stream of serialised writes against the single // SQLite writer that every signup needs. func TestStancePageWritesNothing(t *testing.T) { h := newApp(t) for i := 0; i < 5; i++ { if rec := get(t, h, "/uneasy"); rec.Code != http.StatusOK { t.Fatalf("GET /uneasy: %d", rec.Code) } } if got := countNonces(t, h); got != 0 { t.Errorf("rendering a stance page wrote %d nonce rows, want 0", got) } } func TestRejectionsAllRenderTheSamePage(t *testing.T) { h := newApp(t) baseline := postSignup(t, h, submission(t, "baseline@example.com", "curious")).Body.String() cases := map[string]func(url.Values){ "forged-seal": func(v url.Values) { v.Set("seal", strings.Repeat("0", 64)) }, "short-proof": func(v url.Values) { v.Set("counter", "0") }, "unknown-stance": func(v url.Values) { v.Set("stance", "enthusiastic") }, "tampered-nonce": func(v url.Values) { v.Set("nonce", "deadbeef") }, "lowered-work": func(v url.Values) { v.Set("difficulty", "1") }, } for name, mangle := range cases { // The address has to be well-formed, or the case takes the one // deliberately visible path — a malformed address is reported rather // than swallowed, and that is tested separately. form := submission(t, name+"@example.com", "curious") mangle(form) if got := postSignup(t, h, form).Body.String(); got != baseline { t.Errorf("%s: response differs from an accepted submission's", name) } } } // A submission arriving less than three seconds after its challenge was // issued is not a person who read three positions and wrote about themselves. func TestSubmittingTooFastIsRefused(t *testing.T) { h := newApp(t) key := movement.ChallengeKey(testEnv().InstanceKey) fresh := movement.NewChallenge(key, time.Now(), powDifficulty) postSignup(t, h, solved(t, fresh, "fast@example.com", "curious")) if got := countSignups(t, h, "fast@example.com"); got != 0 { t.Errorf("a sub-three-second submission wrote %d rows, want 0", got) } } func TestExpiredChallengeIsRefused(t *testing.T) { h := newApp(t) key := movement.ChallengeKey(testEnv().InstanceKey) stale := movement.NewChallenge(key, time.Now().Add(-3*time.Hour), powDifficulty) postSignup(t, h, solved(t, stale, "stale@example.com", "curious")) if got := countSignups(t, h, "stale@example.com"); got != 0 { t.Errorf("a three-hour-old challenge wrote %d rows, want 0", got) } } // A counter somebody can reset by filling the form in again is not a budget. func TestResubmittingDoesNotResetTheMailBudget(t *testing.T) { h := newApp(t) const addr = "repeat@example.com" for i := 0; i < movement.PerAddressPerDay; i++ { postSignup(t, h, submission(t, addr, "curious")) } spent := loadSignup(t, h, addr) if spent.MailCount != movement.PerAddressPerDay { t.Fatalf("setup: MailCount = %d, want %d", spent.MailCount, movement.PerAddressPerDay) } // One more, past the allowance. postSignup(t, h, submission(t, addr, "curious")) after := loadSignup(t, h, addr) if after.MailCount != movement.PerAddressPerDay { t.Errorf("MailCount = %d after exceeding the budget, want it held at %d", after.MailCount, movement.PerAddressPerDay) } } func TestMalformedAddressIsToldSo(t *testing.T) { h := newApp(t) sent := postSignup(t, h, submission(t, "fine@example.com", "curious")).Body.String() form := submission(t, "fine@example.com", "curious") form.Set("email", "not-an-address") rec := postSignup(t, h, form) // The one visible error, and it leaks nothing: a malformed string is a // property of what was just typed, not of any address's state on the list. if rec.Body.String() == sent { t.Error("a typo should be reported, not silently swallowed") } // Compared escaped, because that is what reaches the page. The copy has an // apostrophe in it, and html/template renders that as ' — a raw // comparison fails on correct output, which is a test bug dressed as a // code bug. var escaped strings.Builder template.HTMLEscape(&escaped, []byte(movement.T("form.bademail"))) if !strings.Contains(rec.Body.String(), escaped.String()) { t.Error("the malformed-address page should say what went wrong") } } func TestHeaderInjectionInAddressIsRefused(t *testing.T) { h := newApp(t) form := submission(t, "x@example.com", "curious") form.Set("email", "x@example.com\r\nBcc: victim@example.com") postSignup(t, h, form) if got := countSignups(t, h, "x@example.com"); got != 0 { t.Error("an address containing CRLF must never reach the database or an SMTP header") } }