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")
}
}