| 1 | package movementtest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "net/url" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | movement "amadan.net/carlos/movement/internal/movement" |
| 12 | ) |
| 13 | |
| 14 | var confirmLink = regexp.MustCompile(`/confirm\?token=([0-9a-f]{64})`) |
| 15 | |
| 16 | // signUpAndTakeToken drives a real signup and returns the token from the |
| 17 | // message that went out. The token exists nowhere else — only its hash is |
| 18 | // stored — so reading the mail is the only way to get one, which is the |
| 19 | // point. |
| 20 | func signUpAndTakeToken(t *testing.T, h *testApp, email string) string { |
| 21 | t.Helper() |
| 22 | want := h.Mail.countTo(email) + 1 |
| 23 | postSignup(t, h, submission(t, email, "curious")) |
| 24 | |
| 25 | body := h.Mail.waitForMailTo(t, email, want) |
| 26 | m := confirmLink.FindStringSubmatch(body) |
| 27 | if m == nil { |
| 28 | t.Fatalf("no confirmation link in the message to %s:\n%s", email, body) |
| 29 | } |
| 30 | return m[1] |
| 31 | } |
| 32 | |
| 33 | func postConfirm(t *testing.T, h *testApp, token string) *httptest.ResponseRecorder { |
| 34 | t.Helper() |
| 35 | form := url.Values{"token": {token}} |
| 36 | req := httptest.NewRequest(http.MethodPost, "/confirm", strings.NewReader(form.Encode())) |
| 37 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 38 | req.Header.Set("Origin", testOrigin) |
| 39 | rec := httptest.NewRecorder() |
| 40 | h.ServeHTTP(rec, req) |
| 41 | return rec |
| 42 | } |
| 43 | |
| 44 | // The reason confirmation is a POST at all. Link-scanning gateways fetch |
| 45 | // every URL in a message as it arrives, so a GET that confirmed would put |
| 46 | // people on the list who never clicked — breaking the first half of the one |
| 47 | // rule with the very mechanism meant to enforce it — and would then show the |
| 48 | // actual human a dead link. |
| 49 | func TestGetDoesNotConfirm(t *testing.T) { |
| 50 | h := newApp(t) |
| 51 | token := signUpAndTakeToken(t, h, "scanned@example.com") |
| 52 | |
| 53 | rec := get(t, h, "/confirm?token="+token) |
| 54 | if rec.Code != http.StatusOK { |
| 55 | t.Fatalf("GET /confirm: %d", rec.Code) |
| 56 | } |
| 57 | if loadSignup(t, h, "scanned@example.com").Confirmed { |
| 58 | t.Fatal("a GET confirmed the address; a mail scanner would have joined for them") |
| 59 | } |
| 60 | |
| 61 | // And the human's click still works afterwards. |
| 62 | postConfirm(t, h, token) |
| 63 | if !loadSignup(t, h, "scanned@example.com").Confirmed { |
| 64 | t.Fatal("the real click failed after a scanner had fetched the link") |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestConfirmPutsTheAddressOnTheList(t *testing.T) { |
| 69 | h := newApp(t) |
| 70 | token := signUpAndTakeToken(t, h, "joining@example.com") |
| 71 | |
| 72 | rec := postConfirm(t, h, token) |
| 73 | if rec.Code != http.StatusOK { |
| 74 | t.Fatalf("POST /confirm: %d", rec.Code) |
| 75 | } |
| 76 | |
| 77 | row := loadSignup(t, h, "joining@example.com") |
| 78 | if !row.Confirmed { |
| 79 | t.Fatal("the address is not on the list") |
| 80 | } |
| 81 | if row.ConfirmedAt == nil { |
| 82 | t.Error("ConfirmedAt was not recorded") |
| 83 | } |
| 84 | if len(row.TokenHash) != 0 { |
| 85 | t.Error("the token hash survived; the link is still live") |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // A burned token, an expired one and one that never existed are all the same |
| 90 | // answer. There is nothing useful to tell apart and plenty to leak by trying. |
| 91 | func TestSpentUnknownAndExpiredTokensAreIndistinguishable(t *testing.T) { |
| 92 | h := newApp(t) |
| 93 | token := signUpAndTakeToken(t, h, "once@example.com") |
| 94 | postConfirm(t, h, token) |
| 95 | |
| 96 | spent := postConfirm(t, h, token).Body.String() |
| 97 | unknown := postConfirm(t, h, strings.Repeat("a", 64)).Body.String() |
| 98 | empty := postConfirm(t, h, "").Body.String() |
| 99 | |
| 100 | if spent != unknown || unknown != empty { |
| 101 | t.Error("a spent, an unknown and an empty token must render the same page") |
| 102 | } |
| 103 | if !strings.Contains(spent, movement.T("confirm.dead")) { |
| 104 | t.Error("a dead link should say so") |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestConfirmingTwiceDoesNotDoubleCount(t *testing.T) { |
| 109 | h := newApp(t) |
| 110 | token := signUpAndTakeToken(t, h, "twice@example.com") |
| 111 | |
| 112 | postConfirm(t, h, token) |
| 113 | first := loadSignup(t, h, "twice@example.com") |
| 114 | postConfirm(t, h, token) |
| 115 | second := loadSignup(t, h, "twice@example.com") |
| 116 | |
| 117 | if !first.ConfirmedAt.Equal(*second.ConfirmedAt) { |
| 118 | t.Error("a second click moved ConfirmedAt; the link is not single-use") |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // A pending row that is submitted again gets a fresh token, and the old one |
| 123 | // stops working — otherwise every message ever sent to an address stays a |
| 124 | // live credential for it. |
| 125 | func TestResubmittingInvalidatesTheOldLink(t *testing.T) { |
| 126 | h := newApp(t) |
| 127 | const addr = "renewed@example.com" |
| 128 | |
| 129 | old := signUpAndTakeToken(t, h, addr) |
| 130 | fresh := signUpAndTakeToken(t, h, addr) |
| 131 | if old == fresh { |
| 132 | t.Fatal("resubmitting reused the same token") |
| 133 | } |
| 134 | |
| 135 | postConfirm(t, h, old) |
| 136 | if loadSignup(t, h, addr).Confirmed { |
| 137 | t.Error("the superseded link still confirmed the address") |
| 138 | } |
| 139 | |
| 140 | postConfirm(t, h, fresh) |
| 141 | if !loadSignup(t, h, addr).Confirmed { |
| 142 | t.Error("the current link did not work") |
| 143 | } |
| 144 | } |
| 145 | |