Recently Written · git

recentlywritten

my writings (done recently)

git clone https://github.com/equwal/recentlywritten

Log | Files | Refs


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;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 }