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 }