package movementtest import ( "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" movement "amadan.net/carlos/movement/internal/movement" ) var confirmLink = regexp.MustCompile(`/confirm\?token=([0-9a-f]{64})`) // signUpAndTakeToken drives a real signup and returns the token from the // message that went out. The token exists nowhere else — only its hash is // stored — so reading the mail is the only way to get one, which is the // point. func signUpAndTakeToken(t *testing.T, h *testApp, email string) string { t.Helper() want := h.Mail.countTo(email) + 1 postSignup(t, h, submission(t, email, "curious")) body := h.Mail.waitForMailTo(t, email, want) m := confirmLink.FindStringSubmatch(body) if m == nil { t.Fatalf("no confirmation link in the message to %s:\n%s", email, body) } return m[1] } func postConfirm(t *testing.T, h *testApp, token string) *httptest.ResponseRecorder { t.Helper() form := url.Values{"token": {token}} req := httptest.NewRequest(http.MethodPost, "/confirm", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", testOrigin) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // The reason confirmation is a POST at all. Link-scanning gateways fetch // every URL in a message as it arrives, so a GET that confirmed would put // people on the list who never clicked — breaking the first half of the one // rule with the very mechanism meant to enforce it — and would then show the // actual human a dead link. func TestGetDoesNotConfirm(t *testing.T) { h := newApp(t) token := signUpAndTakeToken(t, h, "scanned@example.com") rec := get(t, h, "/confirm?token="+token) if rec.Code != http.StatusOK { t.Fatalf("GET /confirm: %d", rec.Code) } if loadSignup(t, h, "scanned@example.com").Confirmed { t.Fatal("a GET confirmed the address; a mail scanner would have joined for them") } // And the human's click still works afterwards. postConfirm(t, h, token) if !loadSignup(t, h, "scanned@example.com").Confirmed { t.Fatal("the real click failed after a scanner had fetched the link") } } func TestConfirmPutsTheAddressOnTheList(t *testing.T) { h := newApp(t) token := signUpAndTakeToken(t, h, "joining@example.com") rec := postConfirm(t, h, token) if rec.Code != http.StatusOK { t.Fatalf("POST /confirm: %d", rec.Code) } row := loadSignup(t, h, "joining@example.com") if !row.Confirmed { t.Fatal("the address is not on the list") } if row.ConfirmedAt == nil { t.Error("ConfirmedAt was not recorded") } if len(row.TokenHash) != 0 { t.Error("the token hash survived; the link is still live") } } // A burned token, an expired one and one that never existed are all the same // answer. There is nothing useful to tell apart and plenty to leak by trying. func TestSpentUnknownAndExpiredTokensAreIndistinguishable(t *testing.T) { h := newApp(t) token := signUpAndTakeToken(t, h, "once@example.com") postConfirm(t, h, token) spent := postConfirm(t, h, token).Body.String() unknown := postConfirm(t, h, strings.Repeat("a", 64)).Body.String() empty := postConfirm(t, h, "").Body.String() if spent != unknown || unknown != empty { t.Error("a spent, an unknown and an empty token must render the same page") } if !strings.Contains(spent, movement.T("confirm.dead")) { t.Error("a dead link should say so") } } func TestConfirmingTwiceDoesNotDoubleCount(t *testing.T) { h := newApp(t) token := signUpAndTakeToken(t, h, "twice@example.com") postConfirm(t, h, token) first := loadSignup(t, h, "twice@example.com") postConfirm(t, h, token) second := loadSignup(t, h, "twice@example.com") if !first.ConfirmedAt.Equal(*second.ConfirmedAt) { t.Error("a second click moved ConfirmedAt; the link is not single-use") } } // A pending row that is submitted again gets a fresh token, and the old one // stops working — otherwise every message ever sent to an address stays a // live credential for it. func TestResubmittingInvalidatesTheOldLink(t *testing.T) { h := newApp(t) const addr = "renewed@example.com" old := signUpAndTakeToken(t, h, addr) fresh := signUpAndTakeToken(t, h, addr) if old == fresh { t.Fatal("resubmitting reused the same token") } postConfirm(t, h, old) if loadSignup(t, h, addr).Confirmed { t.Error("the superseded link still confirmed the address") } postConfirm(t, h, fresh) if !loadSignup(t, h, addr).Confirmed { t.Error("the current link did not work") } }