accounts/handlers_test.go (8027 bytes)
1 package main 2 3 import ( 4 "errors" 5 "io" 6 "net/http" 7 "net/http/cookiejar" 8 "net/http/httptest" 9 "net/url" 10 "regexp" 11 "strings" 12 "testing" 13 ) 14 15 type browser struct { 16 t *testing.T 17 client *http.Client 18 base string 19 } 20 21 // newBrowser starts the app on a test server and returns a client with a 22 // cookie jar. 23 func newBrowser(t *testing.T, e *testEnv) *browser { 24 srv := httptest.NewServer(e.app.routes()) 25 t.Cleanup(srv.Close) 26 e.app.cfg.BaseURL = srv.URL 27 jar, _ := cookiejar.New(nil) 28 return &browser{t: t, client: &http.Client{Jar: jar}, base: srv.URL} 29 } 30 31 func (b *browser) do(method, path string, form url.Values) (int, string) { 32 b.t.Helper() 33 var body io.Reader 34 if form != nil { 35 body = strings.NewReader(form.Encode()) 36 } 37 req, err := http.NewRequest(method, b.base+path, body) 38 if err != nil { 39 b.t.Fatal(err) 40 } 41 if form != nil { 42 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 43 } 44 resp, err := b.client.Do(req) 45 if err != nil { 46 b.t.Fatal(err) 47 } 48 defer resp.Body.Close() 49 text, _ := io.ReadAll(resp.Body) 50 return resp.StatusCode, string(text) 51 } 52 53 var linkToken = regexp.MustCompile(`/account/verify\?t=([A-Z2-7]+)`) 54 55 // signIn does the sign-in steps of a person in a browser and returns the 56 // settings page. 57 func (b *browser) signIn(e *testEnv, email string) string { 58 b.t.Helper() 59 n := len(e.mail.sent) 60 if code, page := b.do("POST", "/account/signin", url.Values{"email": {email}}); code != 200 || !strings.Contains(page, "Check your email") { 61 b.t.Fatalf("signin: %d\n%s", code, page) 62 } 63 if len(e.mail.sent) != n+1 { 64 b.t.Fatalf("sent %d sign-in messages, want 1", len(e.mail.sent)-n) 65 } 66 m := linkToken.FindStringSubmatch(e.mail.sent[n].Body) 67 if m == nil { 68 b.t.Fatalf("no link in:\n%s", e.mail.sent[n].Body) 69 } 70 // A GET request, as from a mail scanner, must not use the token. 71 if code, page := b.do("GET", "/account/verify?t="+m[1], nil); code != 200 || !strings.Contains(page, `value="`+m[1]+`"`) { 72 b.t.Fatalf("verify page: %d\n%s", code, page) 73 } 74 code, page := b.do("POST", "/account/verify", url.Values{"t": {m[1]}}) 75 if code != 200 || !strings.Contains(page, "Signed in as "+strings.ToLower(email)) { 76 b.t.Fatalf("verify: %d\n%s", code, page) 77 } 78 return page 79 } 80 81 var feedURL = regexp.MustCompile(`/account/feed/[A-Z2-7]+`) 82 83 func TestSignUpAndPrivateFeed(t *testing.T) { 84 e := newTestEnv(t, oldPosts) 85 b := newBrowser(t, e) 86 87 if code, page := b.do("GET", "/account/", nil); code != 200 || !strings.Contains(page, `action="/account/signin"`) { 88 t.Fatalf("signed-out page: %d\n%s", code, page) 89 } 90 page := b.signIn(e, "Reader@Example.com") 91 if e.mail.sent[0].To != "reader@example.com" { 92 t.Errorf("sign-in mail went to %q", e.mail.sent[0].To) 93 } 94 95 feed := feedURL.FindString(page) 96 if feed == "" { 97 t.Fatalf("no feed URL on the settings page:\n%s", page) 98 } 99 // The feed needs no cookie, because a feed reader app has none. 100 anon := &browser{t: t, client: http.DefaultClient, base: b.base} 101 code, doc := anon.do("GET", feed, nil) 102 if code != 200 || wellFormed([]byte(doc)) != nil { 103 t.Fatalf("feed: %d\n%s", code, doc) 104 } 105 for _, want := range []string{"<title>Old article</title>", "old-project.html", "Body of old-article &amp; more."} { 106 if !strings.Contains(doc, want) { 107 t.Errorf("feed has no %q:\n%s", want, doc) 108 } 109 } 110 111 // Mute "projects". The feed then leaves out the project post. 112 b.do("POST", "/account/settings", url.Values{"email_on": {"1"}, "tag": {"articles"}}) 113 if _, doc := anon.do("GET", feed, nil); strings.Contains(doc, "old-project.html") || !strings.Contains(doc, "old-article.html") { 114 t.Errorf("muted tag still in feed:\n%s", doc) 115 } 116 117 // A new feed URL revokes the old one. 118 _, page = b.do("POST", "/account/feed/rotate", nil) 119 newFeed := feedURL.FindString(page) 120 if newFeed == "" || newFeed == feed { 121 t.Fatalf("feed URL did not change: %q", newFeed) 122 } 123 if code, _ := anon.do("GET", feed, nil); code != 404 { 124 t.Errorf("old feed URL gave %d, want 404", code) 125 } 126 if code, _ := anon.do("GET", newFeed, nil); code != 200 { 127 t.Errorf("new feed URL gave %d, want 200", code) 128 } 129 130 // After sign-out, the settings page asks for an address again. 131 if _, page := b.do("POST", "/account/signout", nil); !strings.Contains(page, `action="/account/signin"`) { 132 t.Errorf("still signed in after sign-out:\n%s", page) 133 } 134 } 135 136 func TestSignInRejectsBadAddress(t *testing.T) { 137 e := newTestEnv(t, oldPosts) 138 b := newBrowser(t, e) 139 for _, bad := range []string{"", "no-at-sign", "Name <a@example.com>", "a@example.com\r\nBcc: x@example.com"} { 140 if code, _ := b.do("POST", "/account/signin", url.Values{"email": {bad}}); code != 400 { 141 t.Errorf("address %q gave %d, want 400", bad, code) 142 } 143 } 144 if len(e.mail.sent) != 0 { 145 t.Errorf("sent mail for a bad address: %+v", e.mail.sent) 146 } 147 } 148 149 func TestUsedLinkFails(t *testing.T) { 150 e := newTestEnv(t, oldPosts) 151 b := newBrowser(t, e) 152 b.signIn(e, "a@example.com") 153 tok := linkToken.FindStringSubmatch(e.mail.sent[0].Body)[1] 154 if code, page := b.do("POST", "/account/verify", url.Values{"t": {tok}}); code != 400 || !strings.Contains(page, "not valid") { 155 t.Errorf("second use: %d\n%s", code, page) 156 } 157 } 158 159 func TestUnsubscribe(t *testing.T) { 160 e := newTestEnv(t, oldPosts) 161 b := newBrowser(t, e) 162 u := e.signUp(t, "a@example.com") 163 164 // The link in the email body shows a button, and changes nothing. 165 if code, page := b.do("GET", "/account/unsubscribe?t="+u.UnsubToken, nil); code != 200 || !strings.Contains(page, "Stop the emails") { 166 t.Fatalf("unsubscribe page: %d\n%s", code, page) 167 } 168 if on := emailOn(t, e, u.ID); !on { 169 t.Fatal("GET request turned off email") 170 } 171 172 // RFC 8058 one-click: the mail client sends a POST to the header URL. 173 code, _ := b.do("POST", "/account/unsubscribe?t="+u.UnsubToken, url.Values{"List-Unsubscribe": {"One-Click"}}) 174 if code != 200 { 175 t.Fatalf("one-click unsubscribe gave %d", code) 176 } 177 if emailOn(t, e, u.ID) { 178 t.Fatal("email is still on after unsubscribe") 179 } 180 181 if code, _ := b.do("POST", "/account/unsubscribe", url.Values{"t": {"WRONG"}}); code != 400 { 182 t.Errorf("wrong token gave %d, want 400", code) 183 } 184 } 185 186 func emailOn(t *testing.T, e *testEnv, id int64) bool { 187 t.Helper() 188 var on bool 189 if err := e.app.db.QueryRow(`SELECT email_on FROM users WHERE id = ?`, id).Scan(&on); err != nil { 190 t.Fatal(err) 191 } 192 return on 193 } 194 195 func TestDeleteAccount(t *testing.T) { 196 e := newTestEnv(t, oldPosts) 197 b := newBrowser(t, e) 198 page := b.signIn(e, "a@example.com") 199 feed := feedURL.FindString(page) 200 u, err := userByFeedToken(e.app.db, strings.TrimPrefix(feed, "/account/feed/")) 201 if err != nil { 202 t.Fatal(err) 203 } 204 if err := saveSettings(e.app.db, u.ID, true, []string{"projects"}); err != nil { 205 t.Fatal(err) 206 } 207 if err := e.app.notify(); err != nil { 208 t.Fatal(err) 209 } 210 211 if code, page := b.do("POST", "/account/delete", nil); code != 200 || !strings.Contains(page, "Account deleted") { 212 t.Fatalf("delete: %d\n%s", code, page) 213 } 214 for _, table := range []string{"users", "sessions", "muted_tags", "deliveries"} { 215 var n int 216 if err := e.app.db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { 217 t.Fatal(err) 218 } 219 if n != 0 { 220 t.Errorf("%s has %d rows after delete", table, n) 221 } 222 } 223 if code, _ := b.do("GET", feed, nil); code != 404 { 224 t.Errorf("feed after delete gave %d, want 404", code) 225 } 226 if _, err := userByFeedToken(e.app.db, u.FeedToken); !errors.Is(err, errNotFound) { 227 t.Errorf("user still there: %v", err) 228 } 229 } 230 231 func TestStateChangesNeedSession(t *testing.T) { 232 e := newTestEnv(t, oldPosts) 233 u := e.signUp(t, "a@example.com") 234 b := newBrowser(t, e) 235 // No cookie: each POST goes to the sign-in page and changes nothing. 236 for _, path := range []string{"/account/delete", "/account/settings", "/account/feed/rotate"} { 237 if _, page := b.do("POST", path, url.Values{}); !strings.Contains(page, `action="/account/signin"`) { 238 t.Errorf("%s without a session:\n%s", path, page) 239 } 240 } 241 after, err := userByFeedToken(e.app.db, u.FeedToken) 242 if err != nil || !after.EmailOn { 243 t.Errorf("user changed: %+v, %v", after, err) 244 } 245 }