Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

git clone https://github.com/equwal/sbm-sync

Log | Files | Refs


server_test.go (27502 bytes)

1 package main
2 
3 import (
4 	"crypto/hmac"
5 	"crypto/sha256"
6 	"encoding/hex"
7 	"errors"
8 	"fmt"
9 	"io"
10 	"net/http"
11 	"net/http/cookiejar"
12 	"net/http/httptest"
13 	"net/url"
14 	"os"
15 	"path/filepath"
16 	"regexp"
17 	"strings"
18 	"sync"
19 	"testing"
20 	"time"
21 )
22 
23 // fakeStripe records the calls to the Stripe API and answers them.
24 type fakeStripe struct {
25 	mu    sync.Mutex
26 	calls []string // "METHOD /path form"
27 }
28 
29 func (f *fakeStripe) ServeHTTP(w http.ResponseWriter, r *http.Request) {
30 	r.ParseForm()
31 	f.mu.Lock()
32 	f.calls = append(f.calls, r.Method+" "+r.URL.Path+" "+r.PostForm.Encode())
33 	f.mu.Unlock()
34 	switch {
35 	case r.URL.Path == "/v1/checkout/sessions":
36 		io.WriteString(w, `{"url": "https://checkout.stripe.test/s1"}`)
37 	case r.URL.Path == "/v1/billing_portal/sessions":
38 		io.WriteString(w, `{"url": "https://billing.stripe.test/p1"}`)
39 	case strings.HasPrefix(r.URL.Path, "/v1/customers/"):
40 		io.WriteString(w, `{"deleted": true}`)
41 	default:
42 		w.WriteHeader(http.StatusNotFound)
43 		io.WriteString(w, `{"error": {"message": "no such thing"}}`)
44 	}
45 }
46 
47 func (f *fakeStripe) called(prefix string) string {
48 	f.mu.Lock()
49 	defer f.mu.Unlock()
50 	for _, c := range f.calls {
51 		if strings.HasPrefix(c, prefix) {
52 			return c
53 		}
54 	}
55 	return ""
56 }
57 
58 type harness struct {
59 	t      *testing.T
60 	s      *server
61 	web    *httptest.Server
62 	stripe *fakeStripe
63 	mu     sync.Mutex
64 	mails  []string // "to\nsubject\nbody" of each email
65 }
66 
67 func newEnv(t *testing.T, billing bool) *harness {
68 	store, err := openStore(t.TempDir())
69 	if err != nil {
70 		t.Fatal(err)
71 	}
72 	s := &server{store: store, trial: 30 * 24 * time.Hour,
73 		prices: map[string]string{"month": "price_m", "year": "price_y"},
74 		labels: map[string]string{"month": "$3 a month", "year": "$30 a year"}}
75 	e := &harness{t: t, s: s}
76 	if billing {
77 		e.stripe = &fakeStripe{}
78 		api := httptest.NewServer(e.stripe)
79 		t.Cleanup(api.Close)
80 		s.stripe = &Stripe{Key: "sk_test_x", Webhook: "whsec_test", API: api.URL, Client: api.Client()}
81 	}
82 	e.web = httptest.NewServer(s.routes())
83 	t.Cleanup(e.web.Close)
84 	s.site, s.origin = e.web.URL, e.web.URL
85 	return e
86 }
87 
88 // browser gives a client that keeps cookies and does not follow redirects.
89 func (e *harness) browser() *http.Client {
90 	jar, _ := cookiejar.New(nil)
91 	return &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error {
92 		return http.ErrUseLastResponse
93 	}}
94 }
95 
96 func (e *harness) post(c *http.Client, path string, form url.Values) *http.Response {
97 	req, _ := http.NewRequest("POST", e.web.URL+path, strings.NewReader(form.Encode()))
98 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
99 	req.Header.Set("Origin", e.web.URL)
100 	resp, err := c.Do(req)
101 	if err != nil {
102 		e.t.Fatal(err)
103 	}
104 	return resp
105 }
106 
107 func (e *harness) signup(email string) *http.Client {
108 	c := e.browser()
109 	resp := e.post(c, "/signup", url.Values{"email": {email}, "password": {"password1"}})
110 	if resp.StatusCode != http.StatusSeeOther {
111 		e.t.Fatalf("signup: %s", resp.Status)
112 	}
113 	return c
114 }
115 
116 func (e *harness) token(email string) string {
117 	resp := e.post(http.DefaultClient, "/api/login", url.Values{"email": {email}, "password": {"password1"}})
118 	b, _ := io.ReadAll(resp.Body)
119 	if resp.StatusCode != http.StatusOK {
120 		e.t.Fatalf("api login: %s %s", resp.Status, b)
121 	}
122 	return strings.TrimSpace(string(b))
123 }
124 
125 // sync sends a file as a device does and gives the answer.
126 func (e *harness) sync(token, base, text string) (int, string, string) {
127 	req, _ := http.NewRequest("POST", e.web.URL+"/api/sync?base="+base, strings.NewReader(text))
128 	req.Header.Set("Authorization", "Bearer "+token)
129 	resp, err := http.DefaultClient.Do(req)
130 	if err != nil {
131 		e.t.Fatal(err)
132 	}
133 	defer resp.Body.Close()
134 	b, _ := io.ReadAll(resp.Body)
135 	return resp.StatusCode, string(b), resp.Header.Get("Sbm-Version")
136 }
137 
138 func (e *harness) body(c *http.Client, path string) string {
139 	resp, err := c.Get(e.web.URL + path)
140 	if err != nil {
141 		e.t.Fatal(err)
142 	}
143 	defer resp.Body.Close()
144 	b, _ := io.ReadAll(resp.Body)
145 	return string(b)
146 }
147 
148 // withMail turns on the email check. The emails of the server go to
149 // e.mails.
150 func (e *harness) withMail() {
151 	e.s.mail = func(to, subject, body string) error {
152 		e.mu.Lock()
153 		defer e.mu.Unlock()
154 		e.mails = append(e.mails, to+"\n"+subject+"\n"+body)
155 		return nil
156 	}
157 }
158 
159 func (e *harness) sent() int {
160 	e.mu.Lock()
161 	defer e.mu.Unlock()
162 	return len(e.mails)
163 }
164 
165 var checkLink = regexp.MustCompile(`http\S+/verify\?code=[0-9a-f]+`)
166 
167 // link gives the link in the last email.
168 func (e *harness) link() string {
169 	e.mu.Lock()
170 	defer e.mu.Unlock()
171 	if len(e.mails) == 0 {
172 		e.t.Fatal("no email went out")
173 	}
174 	return checkLink.FindString(e.mails[len(e.mails)-1])
175 }
176 
177 func (e *harness) open(link string) int {
178 	resp, err := http.Get(link)
179 	if err != nil {
180 		e.t.Fatal(err)
181 	}
182 	resp.Body.Close()
183 	return resp.StatusCode
184 }
185 
186 func TestTwoDevicesSync(t *testing.T) {
187 	e := newEnv(t, false)
188 	e.signup("Me@Example.org")
189 	a, b := e.token("me@example.org"), e.token(" me@example.org")
190 
191 	code, text, va := e.sync(a, "", "https://a.org\tA\t\n")
192 	if code != 200 || text != "https://a.org\tA\t\n" || len(va) != 64 {
193 		t.Fatalf("first sync: %d %q %q", code, text, va)
194 	}
195 	_, text, vb := e.sync(b, "", "https://b.org\tB\t\n")
196 	if text != "https://a.org\tA\t\nhttps://b.org\tB\t\n" {
197 		t.Fatalf("second device: %q", text)
198 	}
199 	// Device a removes its bookmark and adds another.
200 	_, text, _ = e.sync(a, va, "https://c.org\tC\t\n")
201 	if text != "https://b.org\tB\t\nhttps://c.org\tC\t\n" {
202 		t.Fatalf("remove and add: %q", text)
203 	}
204 	// Device b has no changes and gets the file of device a.
205 	_, text, _ = e.sync(b, vb, "https://a.org\tA\t\nhttps://b.org\tB\t\n")
206 	if text != "https://b.org\tB\t\nhttps://c.org\tC\t\n" {
207 		t.Fatalf("pull: %q", text)
208 	}
209 }
210 
211 func TestSyncRefusals(t *testing.T) {
212 	e := newEnv(t, false)
213 	e.signup("me@example.org")
214 	tok := e.token("me@example.org")
215 	if code, _, _ := e.sync("wrong", "", "x\n"); code != http.StatusUnauthorized {
216 		t.Errorf("bad token: %d", code)
217 	}
218 	if code, _, _ := e.sync(tok, "../../accounts.json", "x\n"); code != http.StatusBadRequest {
219 		t.Errorf("bad base: %d", code)
220 	}
221 	if code, _, _ := e.sync(tok, "", strings.Repeat("x", maxFile+1)); code != http.StatusRequestEntityTooLarge {
222 		t.Errorf("large file: %d", code)
223 	}
224 	// An unknown base is no error: nothing is removed.
225 	if code, text, _ := e.sync(tok, strings.Repeat("0", 64), "a\n"); code != 200 || text != "a\n" {
226 		t.Errorf("unknown base: %d %q", code, text)
227 	}
228 }
229 
230 func TestSignupAndLogin(t *testing.T) {
231 	e := newEnv(t, false)
232 	c := e.signup("me@example.org")
233 	if !strings.Contains(e.body(c, "/account"), "Signed in as me@example.org") {
234 		t.Error("the account page does not show the account")
235 	}
236 	if resp := e.post(e.browser(), "/signup", url.Values{"email": {"ME@example.org"}, "password": {"password2"}}); resp.StatusCode != http.StatusBadRequest {
237 		t.Errorf("second signup with the same email: %s", resp.Status)
238 	}
239 	for _, bad := range []url.Values{
240 		{"email": {"not an address"}, "password": {"password1"}},
241 		{"email": {"Me <x@example.org>"}, "password": {"password1"}},
242 		{"email": {"y@example.org"}, "password": {"short"}},
243 		{"email": {"y@example.org"}, "password": {strings.Repeat("p", 73)}},
244 	} {
245 		if resp := e.post(e.browser(), "/signup", bad); resp.StatusCode != http.StatusBadRequest {
246 			t.Errorf("signup %v: %s", bad, resp.Status)
247 		}
248 	}
249 	if resp := e.post(e.browser(), "/login", url.Values{"email": {"me@example.org"}, "password": {"wrong-password"}}); resp.StatusCode != http.StatusUnauthorized {
250 		t.Errorf("wrong password: %s", resp.Status)
251 	}
252 	e.post(c, "/logout", nil)
253 	if strings.Contains(e.body(c, "/"), "Your account") {
254 		t.Error("still signed in after sign out")
255 	}
256 }
257 
258 func TestOtherSiteCannotPostForms(t *testing.T) {
259 	e := newEnv(t, false)
260 	c := e.signup("me@example.org")
261 	req, _ := http.NewRequest("POST", e.web.URL+"/account/delete", strings.NewReader("password=password1"))
262 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
263 	req.Header.Set("Origin", "https://evil.example")
264 	resp, err := c.Do(req)
265 	if err != nil {
266 		t.Fatal(err)
267 	}
268 	if resp.StatusCode != http.StatusForbidden {
269 		t.Errorf("post from another site: %s", resp.Status)
270 	}
271 	if e.s.store.byTok(e.token("me@example.org")) == nil {
272 		t.Error("the account is gone")
273 	}
274 }
275 
276 func TestLoginLimit(t *testing.T) {
277 	e := newEnv(t, false)
278 	e.signup("me@example.org")
279 	var last int
280 	for range 21 {
281 		resp := e.post(http.DefaultClient, "/api/login", url.Values{"email": {"me@example.org"}, "password": {"guess"}})
282 		last = resp.StatusCode
283 	}
284 	if last != http.StatusTooManyRequests {
285 		t.Errorf("attempt 21: %d", last)
286 	}
287 }
288 
289 func TestDeleteAccount(t *testing.T) {
290 	e := newEnv(t, true)
291 	c := e.signup("me@example.org")
292 	tok := e.token("me@example.org")
293 	e.sync(tok, "", "a\n")
294 	a := e.s.store.byTok(tok)
295 	e.s.store.setCustomer(a, "cus_1")
296 
297 	if resp := e.post(c, "/account/delete", url.Values{"password": {"wrong-password"}}); resp.StatusCode != http.StatusUnauthorized {
298 		t.Fatalf("delete with a wrong password: %s", resp.Status)
299 	}
300 	if resp := e.post(c, "/account/delete", url.Values{"password": {"password1"}}); resp.StatusCode != http.StatusSeeOther {
301 		t.Fatalf("delete: %s", resp.Status)
302 	}
303 	if e.stripe.called("DELETE /v1/customers/cus_1") == "" {
304 		t.Error("the Stripe customer is not deleted")
305 	}
306 	if code, _, _ := e.sync(tok, "", "a\n"); code != http.StatusUnauthorized {
307 		t.Errorf("sync after delete: %d", code)
308 	}
309 	if _, err := os.Stat(filepath.Join(e.s.store.dir, "files", a.ID)); !os.IsNotExist(err) {
310 		t.Errorf("the files of the account are still there: %v", err)
311 	}
312 }
313 
314 func TestTrialAndSubscription(t *testing.T) {
315 	e := newEnv(t, true)
316 	c := e.signup("me@example.org")
317 	tok := e.token("me@example.org")
318 	if code, _, _ := e.sync(tok, "", "a\n"); code != 200 {
319 		t.Fatalf("sync in the trial: %d", code)
320 	}
321 	if !strings.Contains(e.body(c, "/account"), "Free trial: 30 days left.") {
322 		t.Error("the account page does not show the trial")
323 	}
324 	a := e.s.store.byTok(tok)
325 	a.Created = time.Now().Add(-31 * 24 * time.Hour)
326 	if code, text, _ := e.sync(tok, "", "a\n"); code != http.StatusPaymentRequired {
327 		t.Fatalf("sync after the trial: %d %q", code, text)
328 	}
329 
330 	resp := e.post(c, "/account/checkout", url.Values{"plan": {"year"}})
331 	if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "https://checkout.stripe.test/s1" {
332 		t.Fatalf("checkout: %s to %q", resp.Status, resp.Header.Get("Location"))
333 	}
334 	call := e.stripe.called("POST /v1/checkout/sessions")
335 	for _, want := range []string{"client_reference_id=" + a.ID, "price%5D=price_y", "customer_email=me%40example.org", "mode=subscription",
336 		"success_url=" + url.QueryEscape(e.web.URL+"/account")} {
337 		if !strings.Contains(call, want) {
338 			t.Errorf("checkout call %q lacks %q", call, want)
339 		}
340 	}
341 
342 	now := time.Now().Unix()
343 	e.event(now, "checkout.session.completed", fmt.Sprintf(`{"customer":"cus_9","client_reference_id":%q}`, a.ID))
344 	e.event(now+2, "customer.subscription.updated", `{"customer":"cus_9","status":"active"}`)
345 	e.event(now+1, "customer.subscription.created", `{"customer":"cus_9","status":"incomplete"}`) // late
346 	if code, _, _ := e.sync(tok, "", "a\n"); code != 200 {
347 		t.Fatalf("sync with a subscription: %d", code)
348 	}
349 	if page := e.body(c, "/account"); !strings.Contains(page, "Your subscription is active.") || strings.Contains(page, "Subscribe:") {
350 		t.Error("the account page does not show the subscription")
351 	}
352 	if resp := e.post(c, "/account/portal", nil); resp.Header.Get("Location") != "https://billing.stripe.test/p1" {
353 		t.Errorf("portal: %s", resp.Status)
354 	}
355 	e.event(now+3, "customer.subscription.deleted", `{"customer":"cus_9","status":"canceled"}`)
356 	if code, _, _ := e.sync(tok, "", "a\n"); code != http.StatusPaymentRequired {
357 		t.Errorf("sync after the end of the subscription: %d", code)
358 	}
359 }
360 
361 func TestTrialStartsWithBilling(t *testing.T) {
362 	e := newEnv(t, true)
363 	e.signup("me@example.org")
364 	tok := e.token("me@example.org")
365 	a := e.s.store.byTok(tok)
366 	a.Created = time.Now().Add(-100 * 24 * time.Hour) // from before billing
367 	e.s.since = time.Now().Add(-10 * 24 * time.Hour)
368 	if code, _, _ := e.sync(tok, "", "a\n"); code != 200 {
369 		t.Errorf("sync 10 days after billing started: %d", code)
370 	}
371 	e.s.since = time.Now().Add(-31 * 24 * time.Hour)
372 	if code, _, _ := e.sync(tok, "", "a\n"); code != http.StatusPaymentRequired {
373 		t.Errorf("sync 31 days after billing started: %d", code)
374 	}
375 }
376 
377 func TestSubscriptionFoundByMetadata(t *testing.T) {
378 	e := newEnv(t, true)
379 	e.signup("me@example.org")
380 	a := e.s.store.byTok(e.token("me@example.org"))
381 	e.event(time.Now().Unix(), "customer.subscription.created",
382 		fmt.Sprintf(`{"customer":"cus_5","status":"active","metadata":{"account":%q}}`, a.ID))
383 	if v := e.s.store.view(a); v.Customer != "cus_5" || v.Status != "active" {
384 		t.Errorf("account: customer %q, status %q", v.Customer, v.Status)
385 	}
386 }
387 
388 // event sends a signed webhook event as Stripe does.
389 func (e *harness) event(created int64, typ, object string) {
390 	body := fmt.Sprintf(`{"type":%q,"created":%d,"data":{"object":%s}}`, typ, created, object)
391 	req, _ := http.NewRequest("POST", e.web.URL+"/stripe", strings.NewReader(body))
392 	req.Header.Set("Stripe-Signature", sign([]byte(body), "whsec_test", time.Now().Unix()))
393 	resp, err := http.DefaultClient.Do(req)
394 	if err != nil {
395 		e.t.Fatal(err)
396 	}
397 	if resp.StatusCode != 200 {
398 		e.t.Fatalf("event %s: %s", typ, resp.Status)
399 	}
400 }
401 
402 func sign(body []byte, secret string, t int64) string {
403 	mac := hmac.New(sha256.New, []byte(secret))
404 	fmt.Fprintf(mac, "%d.%s", t, body)
405 	return fmt.Sprintf("t=%d,v1=%s", t, hex.EncodeToString(mac.Sum(nil)))
406 }
407 
408 func TestVerify(t *testing.T) {
409 	body := []byte(`{"type":"x"}`)
410 	now := time.Unix(1_800_000_000, 0)
411 	good := sign(body, "whsec_a", now.Unix())
412 	if err := verify(body, good, "whsec_a", now); err != nil {
413 		t.Errorf("good signature: %v", err)
414 	}
415 	if err := verify(body, "t=1,v1=00,"+strings.TrimPrefix(good, "t=1800000000,"), "whsec_a", now); err == nil {
416 		t.Error("a signature for another time passes")
417 	}
418 	if err := verify(body, "v1=00,"+good, "whsec_a", now); err != nil {
419 		t.Errorf("one good signature among more: %v", err)
420 	}
421 	for name, c := range map[string]struct {
422 		body   string
423 		header string
424 		secret string
425 		now    time.Time
426 	}{
427 		"changed body":  {`{"type":"y"}`, good, "whsec_a", now},
428 		"other secret":  {string(body), good, "whsec_b", now},
429 		"old":           {string(body), good, "whsec_a", now.Add(6 * time.Minute)},
430 		"no header":     {string(body), "", "whsec_a", now},
431 		"no secret set": {string(body), good, "", now},
432 	} {
433 		if err := verify([]byte(c.body), c.header, c.secret, c.now); err == nil {
434 			t.Errorf("%s: passes", name)
435 		}
436 	}
437 	e := newEnv(t, true)
438 	req, _ := http.NewRequest("POST", e.web.URL+"/stripe", strings.NewReader(`{}`))
439 	req.Header.Set("Stripe-Signature", "t=1,v1=00")
440 	if resp, _ := http.DefaultClient.Do(req); resp.StatusCode != http.StatusBadRequest {
441 		t.Errorf("webhook with a bad signature: %s", resp.Status)
442 	}
443 }
444 
445 func TestMoney(t *testing.T) {
446 	for cents, want := range map[int64]string{300: "$3", 3000: "$30", 299: "$2.99", 5: "$0.05"} {
447 		if got := money(cents, "usd"); got != want {
448 			t.Errorf("money(%d) = %q, want %q", cents, got, want)
449 		}
450 	}
451 	if got := money(500, "eur"); got != "5 EUR" {
452 		t.Errorf("euro: %q", got)
453 	}
454 }
455 
456 func TestStoreSurvivesRestart(t *testing.T) {
457 	dir := t.TempDir()
458 	s, _ := openStore(dir)
459 	a, err := s.signup("me@example.org", "password1", "")
460 	if err != nil {
461 		t.Fatal(err)
462 	}
463 	tok, _ := s.signIn(a)
464 	s.sync(a, "", "a\n")
465 	s2, err := openStore(dir)
466 	if err != nil {
467 		t.Fatal(err)
468 	}
469 	b := s2.byTok(tok)
470 	if b == nil || b.Email != "me@example.org" {
471 		t.Fatal("the account or its token is lost")
472 	}
473 	if text, _, _ := s2.sync(b, "", ""); text != "a\n" {
474 		t.Errorf("the file is lost: %q", text)
475 	}
476 }
477 
478 func TestOldVersionsGo(t *testing.T) {
479 	s, _ := openStore(t.TempDir())
480 	a, _ := s.signup("me@example.org", "password1", "")
481 	base := ""
482 	for i := range maxVersions + 10 {
483 		_, v, err := s.sync(a, base, fmt.Sprintf("line %d\n", i))
484 		if err != nil {
485 			t.Fatal(err)
486 		}
487 		base = v
488 		time.Sleep(2 * time.Millisecond) // distinct times of change
489 	}
490 	entries, _ := os.ReadDir(filepath.Join(s.dir, "files", a.ID))
491 	n := 0
492 	for _, e := range entries {
493 		if hashName.MatchString(e.Name()) {
494 			n++
495 		}
496 	}
497 	if n != maxVersions {
498 		t.Errorf("%d versions kept, want %d", n, maxVersions)
499 	}
500 }
501 
502 func TestPaymentLinks(t *testing.T) {
503 	e := newEnv(t, false)
504 	c := e.signup("me@example.org")
505 	if home := e.body(c, "/"); strings.Contains(home, "Pre-order") || strings.Contains(home, "supporter") {
506 		t.Error("the home page offers something without the settings")
507 	}
508 	// The link for large teams needs the link for small teams.
509 	e.s.teamsLarge = "https://buy.stripe.com/large"
510 	if strings.Contains(e.body(c, "/"), e.s.teamsLarge) {
511 		t.Error("the home page links only to large teams")
512 	}
513 	e.s.teams, e.s.support = "https://buy.stripe.com/small", "https://buy.stripe.com/support"
514 	home := e.body(c, "/")
515 	for _, want := range []string{
516 		`Pre-order the founding plan: <a href="https://buy.stripe.com/small">for up to 10 people</a>`,
517 		`<a href="https://buy.stripe.com/large">for 11 people or more</a>`,
518 		`<a href="https://buy.stripe.com/support">become a supporter</a>`,
519 	} {
520 		if !strings.Contains(home, want) {
521 			t.Errorf("the home page lacks %q", want)
522 		}
523 	}
524 	if !strings.Contains(e.body(c, "/account"), `<a href="https://buy.stripe.com/support">become a supporter</a>`) {
525 		t.Error("the account page has no supporter link")
526 	}
527 	e.s.manage = "https://billing.stripe.com/p/login/x"
528 	if !strings.Contains(e.body(c, "/account"), `<a href="https://billing.stripe.com/p/login/x">manage or cancel</a>`) {
529 		t.Error("the account page does not tell supporters where to cancel")
530 	}
531 }
532 
533 func TestTerms(t *testing.T) {
534 	e := newEnv(t, true)
535 	e.s.contact, e.s.support, e.s.manage = "me@example.org", "https://buy.stripe.com/support", "https://billing.stripe.com/p/login/x"
536 	page := e.body(http.DefaultClient, "/terms")
537 	for _, want := range []string{"Terms of sale", "not Google", "$30 a year", "supporter subscription",
538 		`<a href="https://billing.stripe.com/p/login/x">`, "within 30 days", `To cancel by email, write to <a href="mailto:me@example.org">`} {
539 		if !strings.Contains(page, want) {
540 			t.Errorf("the terms lack %q", want)
541 		}
542 	}
543 	if !strings.Contains(e.body(http.DefaultClient, "/"), `<a href="/terms">Terms of sale</a>`) {
544 		t.Error("the home page does not link to the terms")
545 	}
546 }
547 
548 // The preview image comes from the address of the server, so that it moves
549 // with SBM_URL.
550 func TestPreviewImage(t *testing.T) {
551 	e := newEnv(t, false)
552 	want := `<meta property="og:image" content="` + e.s.site + `/og.png">`
553 	if home := e.body(http.DefaultClient, "/"); !strings.Contains(home, want) {
554 		t.Errorf("the home page lacks %q", want)
555 	}
556 }
557 
558 // The home page states what the server does with the data, and that the
559 // operator can read it. A visitor must see this before signing up.
560 func TestHomePrivacy(t *testing.T) {
561 	e := newEnv(t, false)
562 	home := e.body(http.DefaultClient, "/")
563 	for _, want := range []string{"No ads, no analytics and no trackers", "The operator of the server", `<a href="/privacy">privacy policy</a>`} {
564 		if !strings.Contains(home, want) {
565 			t.Errorf("the home page lacks %q", want)
566 		}
567 	}
568 }
569 
570 // Each page links the other sites and apps of the author, but not sbm Sync
571 // itself.
572 func TestFooterLinksTheOtherProjects(t *testing.T) {
573 	e := newEnv(t, false)
574 	for _, path := range []string{"/", "/privacy", "/terms", "/login"} {
575 		page := e.body(http.DefaultClient, path)
576 		for _, want := range []string{`href="https://subread.space/"`, `href="https://booksimulator.com/"`, `href="https://github.com/equwal/sbm-android/releases/latest"`, `href="https://recentlywritten.com/projects.html"`} {
577 			if !strings.Contains(page, want) {
578 				t.Errorf("%s lacks %s", path, want)
579 			}
580 		}
581 		if strings.Contains(page, `href="https://sbmsync.com/"`) {
582 			t.Errorf("%s links sbm Sync itself", path)
583 		}
584 	}
585 }
586 
587 // api sends a request with the token of a device, as the add-on does, and
588 // gives the status and the body of the answer.
589 func (e *harness) api(method, token, path string, form url.Values) (int, string) {
590 	req, _ := http.NewRequest(method, e.web.URL+path, strings.NewReader(form.Encode()))
591 	req.Header.Set("Authorization", "Bearer "+token)
592 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
593 	resp, err := http.DefaultClient.Do(req)
594 	if err != nil {
595 		e.t.Fatal(err)
596 	}
597 	defer resp.Body.Close()
598 	b, _ := io.ReadAll(resp.Body)
599 	return resp.StatusCode, string(b)
600 }
601 
602 func TestAccountAPIWithoutBilling(t *testing.T) {
603 	e := newEnv(t, false)
604 	e.signup("me@example.org")
605 	tok := e.token("me@example.org")
606 	if code, _ := e.api("GET", "wrong", "/api/account", nil); code != http.StatusUnauthorized {
607 		t.Errorf("bad token: %d", code)
608 	}
609 	start := `{"email":"me@example.org","state":"Sync is on.","plans":[],"portal":false,"terms":"` + e.web.URL + `/terms"`
610 	if code, body := e.api("GET", tok, "/api/account", nil); code != http.StatusOK || body != start+"}\n" {
611 		t.Errorf("account: %d %s", code, body)
612 	}
613 	// The link for large teams needs the link for small teams, and the page
614 	// that manages a supporter subscription needs the supporter link, as on
615 	// the web pages.
616 	e.s.manage, e.s.teamsLarge = "https://billing.stripe.com/p/login/x", "https://buy.stripe.com/large"
617 	if _, body := e.api("GET", tok, "/api/account", nil); body != start+"}\n" {
618 		t.Errorf("account with links that need other links: %s", body)
619 	}
620 	e.s.support = "https://buy.stripe.com/support"
621 	want := start + `,"support":"https://buy.stripe.com/support","manage":"https://billing.stripe.com/p/login/x"}` + "\n"
622 	if _, body := e.api("GET", tok, "/api/account", nil); body != want {
623 		t.Errorf("account with a supporter link: %s", body)
624 	}
625 	e.s.teams = "https://buy.stripe.com/small"
626 	_, body := e.api("GET", tok, "/api/account", nil)
627 	if !strings.HasSuffix(body, `"teams":"https://buy.stripe.com/small","teams_large":"https://buy.stripe.com/large"}`+"\n") {
628 		t.Errorf("account with team links: %s", body)
629 	}
630 	for _, path := range []string{"/api/checkout", "/api/portal"} {
631 		if code, _ := e.api("POST", tok, path, url.Values{"plan": {"year"}}); code != http.StatusNotFound {
632 			t.Errorf("%s without billing: %d", path, code)
633 		}
634 	}
635 }
636 
637 func TestBillingAPI(t *testing.T) {
638 	e := newEnv(t, true)
639 	e.signup("me@example.org")
640 	tok := e.token("me@example.org")
641 	a := e.s.store.byTok(tok)
642 	terms := `"terms":"` + e.web.URL + `/terms"`
643 	want := `{"email":"me@example.org","state":"Free trial: 30 days left.",` +
644 		`"plans":[{"id":"year","label":"$30 a year"},{"id":"month","label":"$3 a month"}],"portal":false,` + terms + "}\n"
645 	if _, body := e.api("GET", tok, "/api/account", nil); body != want {
646 		t.Errorf("account in the trial: %s", body)
647 	}
648 	if code, _ := e.api("POST", "wrong", "/api/checkout", url.Values{"plan": {"year"}}); code != http.StatusUnauthorized {
649 		t.Errorf("checkout with a bad token: %d", code)
650 	}
651 	if code, _ := e.api("POST", tok, "/api/checkout", url.Values{"plan": {"week"}}); code != http.StatusBadRequest {
652 		t.Errorf("checkout for no such plan: %d", code)
653 	}
654 	if code, _ := e.api("POST", tok, "/api/portal", nil); code != http.StatusConflict {
655 		t.Errorf("portal before a subscription: %d", code)
656 	}
657 
658 	code, body := e.api("POST", tok, "/api/checkout", url.Values{"plan": {"month"}})
659 	if code != http.StatusOK || body != "https://checkout.stripe.test/s1\n" {
660 		t.Fatalf("checkout: %d %q", code, body)
661 	}
662 	// Stripe sends the customer to a page that says to go back to the add-on:
663 	// the website does not know the add-on's sign-in.
664 	call := e.stripe.called("POST /v1/checkout/sessions")
665 	for _, want := range []string{"client_reference_id=" + a.ID, "price%5D=price_m",
666 		"success_url=" + url.QueryEscape(e.web.URL+"/done"), "cancel_url=" + url.QueryEscape(e.web.URL+"/done")} {
667 		if !strings.Contains(call, want) {
668 			t.Errorf("checkout call %q lacks %q", call, want)
669 		}
670 	}
671 	if !strings.Contains(e.body(http.DefaultClient, "/done"), "You can close this tab.") {
672 		t.Error("the page after Stripe does not say to close it")
673 	}
674 
675 	now := time.Now().Unix()
676 	e.event(now, "checkout.session.completed", fmt.Sprintf(`{"customer":"cus_9","client_reference_id":%q}`, a.ID))
677 	e.event(now+1, "customer.subscription.created", `{"customer":"cus_9","status":"active"}`)
678 	want = `{"email":"me@example.org","state":"Your subscription is active.","plans":[],"portal":true,` + terms + "}\n"
679 	if _, body := e.api("GET", tok, "/api/account", nil); body != want {
680 		t.Errorf("account with a subscription: %s", body)
681 	}
682 	// A second subscription would charge the customer twice.
683 	if code, _ := e.api("POST", tok, "/api/checkout", url.Values{"plan": {"year"}}); code != http.StatusConflict {
684 		t.Errorf("second checkout: %d", code)
685 	}
686 	code, body = e.api("POST", tok, "/api/portal", nil)
687 	if code != http.StatusOK || body != "https://billing.stripe.test/p1\n" {
688 		t.Fatalf("portal: %d %q", code, body)
689 	}
690 	call = e.stripe.called("POST /v1/billing_portal/sessions")
691 	if !strings.Contains(call, "customer=cus_9") || !strings.Contains(call, "return_url="+url.QueryEscape(e.web.URL+"/done")) {
692 		t.Errorf("portal call: %q", call)
693 	}
694 }
695 
696 func TestEmailCheck(t *testing.T) {
697 	e := newEnv(t, false)
698 	e.withMail()
699 	c := e.signup("Me@Example.org")
700 	tok := e.token("me@example.org")
701 	if e.sent() != 1 || !strings.HasPrefix(e.mails[0], "me@example.org\nConfirm your email address") {
702 		t.Fatalf("emails: %q", e.mails)
703 	}
704 	first := e.link()
705 	if code, text, _ := e.sync(tok, "", "a\n"); code != http.StatusForbidden || !strings.Contains(text, "confirm your email address") {
706 		t.Fatalf("sync before the check: %d %q", code, text)
707 	}
708 	if !strings.Contains(e.body(c, "/account"), "Confirm your email address.") {
709 		t.Error("the account page does not ask for the check")
710 	}
711 	// A new email replaces the link of the first one.
712 	if resp := e.post(c, "/account/verify", nil); resp.StatusCode != http.StatusSeeOther || e.sent() != 2 {
713 		t.Fatalf("send again: %s, %d emails", resp.Status, e.sent())
714 	}
715 	if code := e.open(first); code != http.StatusBadRequest {
716 		t.Errorf("the replaced link: %d", code)
717 	}
718 	if code := e.open(e.link()); code != http.StatusOK {
719 		t.Fatalf("the new link: %d", code)
720 	}
721 	if code, _, _ := e.sync(tok, "", "a\n"); code != http.StatusOK {
722 		t.Errorf("sync after the check: %d", code)
723 	}
724 	if strings.Contains(e.body(c, "/account"), "Confirm your email address.") {
725 		t.Error("the account page still asks for the check")
726 	}
727 	if code := e.open(e.link()); code != http.StatusBadRequest {
728 		t.Errorf("the link works twice: %d", code)
729 	}
730 	e.post(c, "/account/verify", nil)
731 	if e.sent() != 2 {
732 		t.Error("an email went out for a confirmed address")
733 	}
734 }
735 
736 func TestEmailCheckLimit(t *testing.T) {
737 	e := newEnv(t, false)
738 	e.withMail()
739 	c := e.signup("me@example.org")
740 	for range 5 {
741 		e.post(c, "/account/verify", nil)
742 	}
743 	if resp := e.post(c, "/account/verify", nil); resp.StatusCode != http.StatusTooManyRequests {
744 		t.Errorf("the seventh email in an hour: %s", resp.Status)
745 	}
746 	if e.sent() != 6 {
747 		t.Errorf("%d emails went out, want 6", e.sent())
748 	}
749 }
750 
751 func TestEmailThatDoesNotGoOut(t *testing.T) {
752 	e := newEnv(t, false)
753 	e.s.mail = func(string, string, string) error { return errors.New("no SMTP server") }
754 	c := e.signup("me@example.org")
755 	resp := e.post(c, "/account/verify", nil)
756 	b, _ := io.ReadAll(resp.Body)
757 	if resp.StatusCode != http.StatusBadGateway || !strings.Contains(string(b), "The email did not go out.") {
758 		t.Errorf("send again: %s", resp.Status)
759 	}
760 }