rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "html"
8 "io"
9 "io/fs"
10 "log/slog"
11 "net/http"
12 "net/http/cookiejar"
13 "net/http/httptest"
14 "net/http/httputil"
15 "net/url"
16 "os"
17 "path"
18 "path/filepath"
19 "regexp"
20 "strconv"
21 "strings"
22 "sync"
23 "sync/atomic"
24 "testing"
25 "time"
26
27 "amadan.net/rastrillo/rastrillo"
28 "amadan.net/rastrillo/rastrillo/db"
29
30 "amadan.net/rastrillo/abono"
31 "amadan.net/rastrillo/abono/abonotest"
32)
33
34const owner = "owner@example.com"
35
36// keyLiteral is a message key written as a string literal.
37var keyLiteral = regexp.MustCompile(`"((?:abono|example|rastrillo)\.[a-z][a-z_.]*[a-z])"`)
38
39// testCatalog maps every message key the example's pages, abono's
40// partials and the generated Go name to "T:<key>", so a test reads
41// which sentence a page chose whatever English the shipped catalogs
42// hold. say(key) is what a page then shows for key.
43var testCatalog = sync.OnceValue(func() rastrillo.Catalog {
44 cat := rastrillo.Catalog{}
45 add := func(src []byte) {
46 for _, m := range keyLiteral.FindAllSubmatch(src, -1) {
47 cat[string(m[1])] = say(string(m[1]))
48 }
49 }
50 for _, fsys := range []fs.FS{os.DirFS("."), abono.Templates()} {
51 err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
52 if err != nil || d.IsDir() || (path.Ext(p) != ".html" && p != "abono_pages.go") {
53 return err
54 }
55 src, err := fs.ReadFile(fsys, p)
56 add(src)
57 return err
58 })
59 if err != nil {
60 panic(err)
61 }
62 }
63 for _, k := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing,
64 abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} {
65 cat[k] = say(k)
66 }
67 return cat
68})
69
70func say(key string) string { return "T:" + key }
71
72// inbox is the example's mailer in tests: it keeps every message, so a
73// test can follow the link the way the address's owner would.
74type inbox struct {
75 mu sync.Mutex
76 mail map[string][]string
77}
78
79func (b *inbox) Send(_ context.Context, to, _, body string) error {
80 b.mu.Lock()
81 defer b.mu.Unlock()
82 if b.mail == nil {
83 b.mail = map[string][]string{}
84 }
85 b.mail[strings.ToLower(to)] = append(b.mail[strings.ToLower(to)], body)
86 return nil
87}
88
89var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=[^\s]+`)
90
91// link is the newest sign-in link sent to address.
92func (b *inbox) link(t testing.TB, address string) string {
93 t.Helper()
94 b.mu.Lock()
95 defer b.mu.Unlock()
96 msgs := b.mail[address]
97 if len(msgs) == 0 {
98 t.Fatalf("no mail for %s", address)
99 }
100 l := magicLink.FindString(msgs[len(msgs)-1])
101 if l == "" {
102 t.Fatalf("no sign-in link in the mail to %s:\n%s", address, msgs[len(msgs)-1])
103 }
104 return l
105}
106
107func (b *inbox) count(address string) int {
108 b.mu.Lock()
109 defer b.mu.Unlock()
110 return len(b.mail[address])
111}
112
113// testConfig is the example as tests run it: a fresh database, quiet
114// logs, the test catalog, mail into box, and the sandbox for Stripe.
115func testConfig(t testing.TB, origin string, box *inbox) Config {
116 t.Helper()
117 quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
118 d, err := db.Open(filepath.Join(t.TempDir(), "example.db"), quiet)
119 if err != nil {
120 t.Fatalf("db.Open: %v", err)
121 }
122 t.Cleanup(func() { d.Close() })
123 return Config{DB: d, Origin: origin, Owner: owner, InstanceKey: "example-test-instance-key",
124 Mailer: box, Catalog: testCatalog(), Logger: quiet}
125}
126
127type testApp struct {
128 *httptest.Server
129 inbox *inbox
130}
131
132// newApp serves the whole example, as main does. The server's own
133// address is the origin, so a POST carrying it as Origin passes
134// csrf.Protect. adjust, when given, changes the configuration first.
135func newApp(t *testing.T, adjust ...func(*Config)) *testApp {
136 t.Helper()
137 ts := httptest.NewUnstartedServer(nil)
138 app := &testApp{Server: ts, inbox: &inbox{}}
139 cfg := testConfig(t, "http://"+ts.Listener.Addr().String(), app.inbox)
140 for _, f := range adjust {
141 f(&cfg)
142 }
143 opts, err := Options(cfg)
144 if err != nil {
145 t.Fatalf("Options: %v", err)
146 }
147 h, closeApp, err := rastrillo.Handler(opts)
148 if err != nil {
149 t.Fatalf("rastrillo.Handler: %v", err)
150 }
151 t.Cleanup(func() { closeApp() })
152 ts.Config.Handler = h
153 ts.Start()
154 t.Cleanup(ts.Close)
155 return app
156}
157
158// client is one visitor: a cookie jar, and the Origin a browser's form
159// POST carries. It never follows a redirect on its own.
160type client struct {
161 t *testing.T
162 app *testApp
163 c *http.Client
164}
165
166func newClient(t *testing.T, app *testApp) *client {
167 t.Helper()
168 jar, _ := cookiejar.New(nil)
169 return &client{t: t, app: app, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error {
170 return http.ErrUseLastResponse
171 }}}
172}
173
174// do sends a request to path (or an absolute address on the app) and
175// returns the status, the Location and the body. origin is the Origin
176// header, "" for none.
177func (cl *client) do(method, path string, form url.Values, origin string) (int, string, string) {
178 cl.t.Helper()
179 u := path
180 if strings.HasPrefix(path, "/") {
181 u = cl.app.URL + path
182 }
183 var body io.Reader
184 if form != nil {
185 body = strings.NewReader(form.Encode())
186 }
187 req, err := http.NewRequest(method, u, body)
188 if err != nil {
189 cl.t.Fatal(err)
190 }
191 if form != nil {
192 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
193 }
194 if origin != "" {
195 req.Header.Set("Origin", origin)
196 }
197 resp, err := cl.c.Do(req)
198 if err != nil {
199 cl.t.Fatalf("%s %s: %v", method, path, err)
200 }
201 defer resp.Body.Close()
202 b, _ := io.ReadAll(resp.Body)
203 return resp.StatusCode, resp.Header.Get("Location"), string(b)
204}
205
206func (cl *client) get(path string) (int, string) {
207 cl.t.Helper()
208 status, _, body := cl.do(http.MethodGet, path, nil, "")
209 return status, body
210}
211
212// post is a same-origin form POST, as a browser sends one; it returns
213// the status, where it redirects and the body.
214func (cl *client) post(path string, form url.Values) (int, string, string) {
215 cl.t.Helper()
216 if form == nil {
217 form = url.Values{}
218 }
219 return cl.do(http.MethodPost, path, form, cl.app.URL)
220}
221
222// askForLink submits address on the sign-in page.
223func (cl *client) askForLink(address string) {
224 cl.t.Helper()
225 status, loc, body := cl.post("/signin", url.Values{"address": {address}})
226 if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/signin?sent=1") {
227 cl.t.Fatalf("POST /signin for %s: %d to %q\n%s", address, status, loc, body)
228 }
229}
230
231// follow opens a sign-in link and presses its Sign in button: what the
232// address's owner does from their inbox.
233func (cl *client) follow(link string) {
234 cl.t.Helper()
235 status, page := cl.get(link)
236 want(cl.t, "the link's confirm page", status, http.StatusOK, page, `action="/auth/verify"`, `name="token"`)
237 u, err := url.Parse(link)
238 if err != nil {
239 cl.t.Fatal(err)
240 }
241 status, loc, body := cl.post("/auth/verify", url.Values{"token": {u.Query().Get("token")}})
242 if status != http.StatusSeeOther || strings.Contains(loc, "err=") {
243 cl.t.Fatalf("redeem the link: %d to %q\n%s", status, loc, body)
244 }
245}
246
247// signin signs the client in as address by its emailed link.
248func (cl *client) signin(address string) {
249 cl.t.Helper()
250 cl.askForLink(address)
251 cl.follow(cl.app.inbox.link(cl.t, address))
252}
253
254func want(t testing.TB, what string, got, wantStatus int, body string, parts ...string) {
255 t.Helper()
256 if got != wantStatus {
257 t.Fatalf("%s: status %d, want %d\n%s", what, got, wantStatus, body)
258 }
259 for _, p := range parts {
260 if !strings.Contains(body, p) {
261 t.Errorf("%s: no %q in\n%s", what, p, body)
262 }
263 }
264}
265
266var payAction = regexp.MustCompile(`action="(/abono/sandbox/checkout/[^"/]+/pay)"`)
267
268// checkout starts Checkout for plan and pays on the sandbox's page, as
269// email when it is not "", and returns where the sandbox sends the reader.
270func (cl *client) checkout(plan, email string) string {
271 cl.t.Helper()
272 status, loc, body := cl.post("/subscribe/"+plan, nil)
273 if status != http.StatusSeeOther || !strings.HasPrefix(loc, cl.app.URL+"/abono/sandbox/checkout/") {
274 cl.t.Fatalf("POST /subscribe/%s: %d to %q\n%s", plan, status, loc, body)
275 }
276 status, page := cl.get(loc)
277 m := payAction.FindStringSubmatch(page)
278 if status != http.StatusOK || m == nil {
279 cl.t.Fatalf("the sandbox checkout: %d, no pay form:\n%s", status, page)
280 }
281 form := url.Values{}
282 if email != "" {
283 form.Set("email", email)
284 }
285 status, back, body := cl.post(m[1], form)
286 if status != http.StatusSeeOther || !strings.HasPrefix(back, cl.app.URL+"/subscribe/done?session_id=") {
287 cl.t.Fatalf("pay: %d to %q\n%s", status, back, body)
288 }
289 return back
290}
291
292// TestSandboxSubscribe is the reader's whole flow over HTTP: sign in by
293// link, the plans, Checkout on the sandbox's page, Checkout's return,
294// and the account page showing the membership.
295func TestSandboxSubscribe(t *testing.T) {
296 app := newApp(t)
297 reader := newClient(t, app)
298 reader.signin("reader@example.com")
299
300 status, body := reader.get("/subscribe")
301 want(t, "the plans", status, http.StatusOK, body,
302 say("abono.pages.pricing_title"), `action="/subscribe/annual"`, `action="/subscribe/monthly"`)
303
304 status, body = reader.get("/subscribe/annual")
305 want(t, "the annual plan", status, http.StatusOK, body, `action="/subscribe/annual"`, `name="code"`, `href="/subscribe"`)
306
307 status, body = reader.get("/account")
308 want(t, "the account before paying", status, http.StatusOK, body, say("abono.pages.account_none"), `href="/subscribe"`)
309
310 done := reader.checkout("annual", "")
311 status, body = reader.get(done)
312 want(t, "Checkout's return", status, http.StatusOK, body, say("abono.pages.done_paid"), `href="/account"`)
313
314 status, body = reader.get("/account")
315 want(t, "the account after paying", status, http.StatusOK, body,
316 "<strong>annual</strong>", `rst-tone="positive"`, say(abono.StatusRenews), `action="/account/billing"`)
317
318 // The billing button opens the sandbox's portal, for the customer it
319 // names.
320 m := regexp.MustCompile(`name="customer" value="([^"]+)"`).FindStringSubmatch(body)
321 if m == nil {
322 t.Fatalf("the billing button names no customer:\n%s", body)
323 }
324 st, loc, _ := reader.post("/account/billing", url.Values{"customer": {m[1]}})
325 if st != http.StatusSeeOther || !strings.HasPrefix(loc, app.URL+"/abono/sandbox/portal/") {
326 t.Errorf("billing: %d to %q", st, loc)
327 }
328
329 // A promotion code Stripe does not know is said so, on the plan's page.
330 st, _, body = reader.post("/subscribe/annual", url.Values{"code": {"NOPE"}})
331 want(t, "an unknown code", st, http.StatusUnprocessableEntity, body, say("abono.error.unknown_code"))
332 st, _, body = reader.post("/subscribe/nonsense", nil)
333 want(t, "an unknown plan", st, http.StatusNotFound, body, say("abono.error.unknown_plan"))
334}
335
336// TestHolderAccountNeedsItsInbox: paying signed out makes a user for the
337// address (the example's Holder), and only someone who can read that
338// address's mail gets into it. Asking for a link from another browser
339// sends it to the inbox, not to the asker; there is no sign-up to claim
340// the account with.
341func TestHolderAccountNeedsItsInbox(t *testing.T) {
342 app := newApp(t)
343 buyer := newClient(t, app)
344 status, body := buyer.get(buyer.checkout("monthly", "walkin@example.com"))
345 want(t, "Checkout's return, signed out", status, http.StatusOK, body, say("abono.pages.done_signin"))
346
347 intruder := newClient(t, app)
348 for _, path := range []string{"/signup"} {
349 if st, _ := intruder.get(path); st != http.StatusNotFound {
350 t.Errorf("GET %s: %d, want 404 (no sign-up)", path, st)
351 }
352 if st, _, _ := intruder.post(path, url.Values{"email": {"walkin@example.com"}, "password": {"a long enough password"}}); st != http.StatusNotFound && st != http.StatusMethodNotAllowed {
353 t.Errorf("POST %s: %d, want 404 or 405 (no sign-up)", path, st)
354 }
355 }
356 intruder.askForLink("walkin@example.com")
357 st, loc, _ := intruder.post("/auth/verify", url.Values{"token": {"guessed"}})
358 if st != http.StatusSeeOther || !strings.Contains(loc, "err=expired") {
359 t.Errorf("a guessed token: %d to %q, want the expired answer", st, loc)
360 }
361 if st, loc, _ := intruder.do(http.MethodGet, "/account", nil, ""); st != http.StatusSeeOther || !strings.HasPrefix(loc, "/signin") {
362 t.Errorf("the intruder's /account: %d to %q, want sign-in", st, loc)
363 }
364 if st, loc, _ := intruder.post("/account/billing", nil); st != http.StatusSeeOther || loc != "/account?billing=signin" {
365 t.Errorf("the intruder's billing: %d to %q", st, loc)
366 }
367
368 // The link went to the address. Whoever reads it is in, with the
369 // membership.
370 if n := app.inbox.count("walkin@example.com"); n != 1 {
371 t.Fatalf("mail to the address: %d, want 1", n)
372 }
373 owner := newClient(t, app)
374 owner.follow(app.inbox.link(t, "walkin@example.com"))
375 status, body = owner.get("/account")
376 want(t, "the account, signed in by link", status, http.StatusOK, body, "<strong>monthly</strong>", say(abono.StatusRenews))
377}
378
379// TestOwnerGivesAndEndsAComp: the owner gives a reader a free
380// membership by address, the reader has it, and the owner ends it on its
381// own confirm page. Nobody else reaches the owner's pages.
382func TestOwnerGivesAndEndsAComp(t *testing.T) {
383 app := newApp(t)
384 reader := newClient(t, app)
385 reader.signin("reader@example.com")
386 boss := newClient(t, app)
387 boss.signin(owner)
388
389 for _, path := range []string{"/admin/members", "/admin/offers", "/admin/stripe", "/admin/members/comps/1/end"} {
390 if status, _ := reader.get(path); status != http.StatusForbidden {
391 t.Errorf("a reader GET %s: %d, want 403", path, status)
392 }
393 }
394 if status, _, _ := reader.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"monthly"}}); status != http.StatusForbidden {
395 t.Errorf("a reader POST comps: %d, want 403", status)
396 }
397 if status, _ := newClient(t, app).get("/admin/members"); status != http.StatusForbidden {
398 t.Errorf("signed out GET /admin/members: %d, want 403", status)
399 }
400
401 status, body := boss.get("/admin/members")
402 want(t, "the members page", status, http.StatusOK, body,
403 say("abono.owner.members_empty"), say("abono.owner.free_title"), `action="/admin/members/sync"`, say("abono.owner.unrecognised_none"))
404
405 st, _, body := boss.post("/admin/members/comps", url.Values{"member": {"nobody@example.com"}, "plan": {"monthly"}})
406 want(t, "a comp for an unknown address", st, http.StatusUnprocessableEntity, body, say("abono.owner.free_no_member"), `value="nobody@example.com"`)
407 st, _, body = boss.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"gold"}})
408 want(t, "a comp of an unknown plan", st, http.StatusUnprocessableEntity, body, say("abono.error.free_plan"))
409
410 st, loc, _ := boss.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"monthly"}, "note": {"a friend"}})
411 if st != http.StatusSeeOther || loc != "/admin/members?done=comp&plan=monthly" {
412 t.Fatalf("give a comp: %d to %q", st, loc)
413 }
414 status, body = boss.get(loc)
415 want(t, "the members page after a comp", status, http.StatusOK, body,
416 say("abono.owner.free_given"), say("abono.owner.source_free"), say(abono.StatusComp),
417 "<td>reader@example.com</td>", `href="/admin/members/comps/1/end"`)
418
419 status, body = reader.get("/account")
420 want(t, "the reader's account with a comp", status, http.StatusOK, body, say(abono.StatusComp))
421
422 status, body = boss.get("/admin/members/comps/1/end")
423 want(t, "the confirm page", status, http.StatusOK, body,
424 say("abono.owner.end_title"), say("abono.owner.end_body"), `action="/admin/members/comps/1/end"`,
425 say("abono.button.end_free_confirm"), `href="/admin/members"`)
426 st, loc, _ = boss.post("/admin/members/comps/1/end", nil)
427 if st != http.StatusSeeOther || loc != "/admin/members?done=end" {
428 t.Fatalf("end the comp: %d to %q", st, loc)
429 }
430 status, body = boss.get(loc)
431 want(t, "the members page after ending", status, http.StatusOK, body, say("abono.owner.end_done"), say(abono.StatusEnded))
432 if strings.Contains(body, `href="/admin/members/comps/1/end"`) {
433 t.Error("an ended comp still offers to be ended")
434 }
435 if status, _ := boss.get("/admin/members/comps/1/end"); status != http.StatusNotFound {
436 t.Errorf("the confirm page of an ended comp: %d, want 404", status)
437 }
438
439 status, body = reader.get("/account")
440 want(t, "the reader's account after", status, http.StatusOK, body, say(abono.StatusEnded))
441}
442
443// TestOwnerSeesSyncsAndStripe: the owner's other pages, in the sandbox.
444func TestOwnerSeesSyncsAndStripe(t *testing.T) {
445 app := newApp(t)
446 reader := newClient(t, app)
447 reader.signin("reader@example.com")
448 reader.get(reader.checkout("annual", ""))
449 boss := newClient(t, app)
450 boss.signin(owner)
451
452 status, body := boss.get("/admin/members")
453 want(t, "the members page", status, http.StatusOK, body,
454 say("abono.owner.source_paid"), say(abono.StatusRenews), "<td>reader@example.com</td>")
455 st, loc, _ := boss.post("/admin/members/sync", nil)
456 if st != http.StatusSeeOther || loc != "/admin/members?done=sync&n=1" {
457 t.Fatalf("sync: %d to %q", st, loc)
458 }
459 status, body = boss.get(loc)
460 want(t, "after a sync", status, http.StatusOK, body, say("abono.owner.sync_done_one"))
461
462 status, body = boss.get("/admin/offers")
463 want(t, "the offers page", status, http.StatusOK, body,
464 "price_annual", "price_monthly", say("abono.owner.price_sold"), say("abono.owner.unrecognised_none"))
465
466 status, body = boss.get("/admin/stripe")
467 want(t, "the Stripe page", status, http.StatusOK, body, say("abono.owner.stripe_sandbox"))
468}
469
470// TestMembersShowUnrecognisedSubscriptions: a paying subscription on a
471// price no plan has is listed on the members page (spec 4.1), and
472// adopting it there puts it, with its holder, onto the plan. It runs
473// against abonotest's Stripe, where a test can put such a subscription.
474func TestMembersShowUnrecognisedSubscriptions(t *testing.T) {
475 stripe := abonotest.New(t)
476 stripe.AddPrice("price_annual", "annual", 6000, "eur", "year")
477 stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month")
478 app := newApp(t, func(c *Config) { c.Abono = stripe.Config })
479 stripe.SetWebhookURL(app.URL + abono.WebhookPath)
480 now := stripe.Now()
481 stripe.AddSubscription(abonotest.Subscription{
482 ID: "sub_foreign", Status: "active", Price: "price_foreign", Interval: "month",
483 Email: "old@example.com", PeriodStart: now, PeriodEnd: now.Add(30 * 24 * time.Hour),
484 })
485
486 boss := newClient(t, app)
487 boss.signin(owner)
488 st, loc, _ := boss.post("/admin/members/sync", nil)
489 if st != http.StatusSeeOther {
490 t.Fatalf("sync: %d to %q", st, loc)
491 }
492 status, body := boss.get("/admin/members")
493 want(t, "the members page", status, http.StatusOK, body,
494 say("abono.owner.unrecognised_title"), `data-abono-unrecognised="sub_foreign"`, "price_foreign",
495 `name="from" value="members"`, say("abono.button.adopt"))
496 if strings.Contains(body, say("abono.owner.unrecognised_none")) {
497 t.Error("the members page says every subscription is recognised")
498 }
499
500 adopt := url.Values{"subscription": {"sub_foreign"}, "from": {"members"}}
501 adopt.Set("plan", "gold")
502 st, _, body = boss.post("/admin/offers/adopt", adopt)
503 want(t, "adopting onto an unknown plan", st, http.StatusUnprocessableEntity, body,
504 say("abono.error.unknown_plan"), `data-abono-unrecognised="sub_foreign"`)
505
506 adopt.Set("plan", "monthly")
507 st, loc, _ = boss.post("/admin/offers/adopt", adopt)
508 if st != http.StatusSeeOther || loc != "/admin/members?done=adopt&plan=monthly" {
509 t.Fatalf("adopt: %d to %q", st, loc)
510 }
511 status, body = boss.get(loc)
512 want(t, "after adopting", status, http.StatusOK, body,
513 say("abono.owner.adopted"), "<td>old@example.com</td>", "<td>monthly</td>", say("abono.owner.source_paid"),
514 say("abono.owner.unrecognised_none"))
515 if strings.Contains(body, `data-abono-unrecognised=`) {
516 t.Error("the adopted subscription is still listed as unrecognised")
517 }
518
519 // From the offers page, the form goes back there.
520 status, body = boss.get("/admin/offers")
521 want(t, "the offers page", status, http.StatusOK, body, say("abono.owner.unrecognised_none"))
522}
523
524// TestMachinePathsSkipCSRF: Stripe's webhook and carlos's tick carry no
525// browser's Origin and are exempt, each checking its own credential;
526// every other POST without browser evidence is refused.
527func TestMachinePathsSkipCSRF(t *testing.T) {
528 t.Setenv("CARLOS_ADMIN_TOKEN", "tick-token")
529 app := newApp(t)
530 machine := newClient(t, app)
531
532 // The webhook checks Stripe's signature: an unsigned delivery is 400,
533 // not csrf's 403.
534 status, _, body := machine.do(http.MethodPost, "/abono/webhook", url.Values{}, "")
535 if status != http.StatusBadRequest {
536 t.Errorf("unsigned webhook: %d, want 400\n%s", status, body)
537 }
538
539 req, _ := http.NewRequest(http.MethodPost, app.URL+"/jobs/abono-sync", nil)
540 req.Header.Set("Authorization", "Bearer tick-token")
541 resp, err := http.DefaultClient.Do(req)
542 if err != nil {
543 t.Fatal(err)
544 }
545 resp.Body.Close()
546 if resp.StatusCode != http.StatusNoContent {
547 t.Errorf("the daily tick: %d, want 204", resp.StatusCode)
548 }
549
550 for _, path := range []string{"/subscribe/annual", "/account/billing", "/admin/members/sync", "/signin", "/auth/verify"} {
551 if status, _, _ := machine.do(http.MethodPost, path, url.Values{}, ""); status != http.StatusForbidden {
552 t.Errorf("POST %s with no Origin: %d, want 403", path, status)
553 }
554 if status, _, _ := machine.do(http.MethodPost, path, url.Values{}, "https://evil.example"); status != http.StatusForbidden {
555 t.Errorf("POST %s from another origin: %d, want 403", path, status)
556 }
557 }
558}
559
560// TestDoneWhilePaymentIsProcessing is final review C2 (ruling R33): a
561// Checkout that completed while its payment is still on its way is not
562// "no payment was made". The return page says it is processing and offers
563// no second purchase. abonotest's Checkout always pays at once, so a proxy
564// in front of it reports the session's payment as still unpaid.
565func TestDoneWhilePaymentIsProcessing(t *testing.T) {
566 stripe := abonotest.New(t)
567 stripe.AddPrice("price_annual", "annual", 6000, "eur", "year")
568 stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month")
569 target, err := url.Parse(stripe.URL)
570 if err != nil {
571 t.Fatal(err)
572 }
573 proxy := httputil.NewSingleHostReverseProxy(target)
574 proxy.ModifyResponse = func(resp *http.Response) error {
575 if resp.Request.Method != http.MethodGet || !strings.HasPrefix(resp.Request.URL.Path, "/v1/checkout/sessions/") {
576 return nil
577 }
578 var cs map[string]any
579 if err := json.NewDecoder(resp.Body).Decode(&cs); err != nil {
580 return err
581 }
582 resp.Body.Close()
583 cs["payment_status"] = "unpaid"
584 b, err := json.Marshal(cs)
585 if err != nil {
586 return err
587 }
588 resp.Body = io.NopCloser(bytes.NewReader(b))
589 resp.ContentLength = int64(len(b))
590 resp.Header.Set("Content-Length", strconv.Itoa(len(b)))
591 return nil
592 }
593 front := httptest.NewServer(proxy)
594 t.Cleanup(front.Close)
595
596 app := newApp(t, func(c *Config) {
597 c.Abono = func(ac abono.Config) abono.Config {
598 ac = stripe.Config(ac)
599 ac.StripeBase = front.URL
600 return ac
601 }
602 })
603 stripe.SetWebhookURL(app.URL + abono.WebhookPath)
604 reader := newClient(t, app)
605 reader.signin("reader@example.com")
606 status, loc, body := reader.post("/subscribe/annual", nil)
607 if status != http.StatusSeeOther || !strings.HasPrefix(loc, stripe.URL+"/checkout/") {
608 t.Fatalf("POST /subscribe/annual: %d to %q\n%s", status, loc, body)
609 }
610 status, body = reader.get(stripe.Pay(t, loc, ""))
611 want(t, "Checkout's return while the payment processes", status, http.StatusOK, body,
612 say("abono.pages.done_processing"), `href="/account"`)
613 for _, wrong := range []string{say("abono.pages.done_unpaid_title"), say("abono.pages.done_unpaid_body"), say("abono.button.try_again")} {
614 if strings.Contains(body, wrong) {
615 t.Errorf("the processing page says %s", wrong)
616 }
617 }
618}
619
620// TestBillingStaysAfterSwitchingToAnUnknownPrice is final review I5
621// (ruling R37): a reader switches, in Stripe's portal, to a price no plan
622// has. They hold nothing that grants, but they are still charged, so the
623// account page still has the billing button, for that subscription's
624// customer, and it opens the portal. A customer that is not theirs does
625// not.
626func TestBillingStaysAfterSwitchingToAnUnknownPrice(t *testing.T) {
627 stripe := abonotest.New(t)
628 stripe.AddPrice("price_annual", "annual", 6000, "eur", "year")
629 stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month")
630 stripe.AddPrice("price_foreign", "", 900, "eur", "month")
631 app := newApp(t, func(c *Config) { c.Abono = stripe.Config })
632 stripe.SetWebhookURL(app.URL + abono.WebhookPath)
633 reader := newClient(t, app)
634 reader.signin("reader@example.com")
635 status, loc, body := reader.post("/subscribe/annual", nil)
636 if status != http.StatusSeeOther {
637 t.Fatalf("POST /subscribe/annual: %d to %q\n%s", status, loc, body)
638 }
639 reader.get(stripe.Pay(t, loc, ""))
640
641 now := stripe.Now()
642 stripe.AddSubscription(abonotest.Subscription{ID: "sub_1", Customer: "cus_1", Email: "reader@example.com",
643 Status: "active", Price: "price_foreign", PeriodStart: now, PeriodEnd: now.Add(30 * 24 * time.Hour)})
644 stripe.SendEvent("customer.subscription.updated", "sub_1")
645
646 status, body = reader.get("/account")
647 want(t, "the account on an unknown price", status, http.StatusOK, body,
648 say("abono.pages.account_none"), `action="/account/billing"`, `name="customer" value="cus_1"`)
649 st, loc, _ := reader.post("/account/billing", url.Values{"customer": {"cus_1"}})
650 if st != http.StatusSeeOther || !strings.HasPrefix(loc, stripe.URL+"/portal/cus_1") {
651 t.Errorf("billing for cus_1: %d to %q", st, loc)
652 }
653 st, loc, _ = reader.post("/account/billing", url.Values{"customer": {"cus_2"}})
654 if st != http.StatusSeeOther || loc != "/account?billing=none" {
655 t.Errorf("billing for a customer not the reader's: %d to %q", st, loc)
656 }
657}
658
659// TestUnsupportedPriceIsShownNotSold is final review I7 (ruling R39) on
660// the generated pages: a sold price billed every twelve months is listed
661// for the owner, marked unsupported, and is not on the pricing page.
662func TestUnsupportedPriceIsShownNotSold(t *testing.T) {
663 var d *db.DB
664 app := newApp(t, func(c *Config) { d = c.DB })
665 // The sandbox read price_monthly at start-up as every month; make it
666 // every twelve, as Stripe would report such a price.
667 if err := d.G.Exec(`UPDATE abono_prices SET interval_count = 12 WHERE stripe_price_id = 'price_monthly'`).Error; err != nil {
668 t.Fatal(err)
669 }
670 reader := newClient(t, app)
671 status, body := reader.get("/subscribe")
672 want(t, "the plans", status, http.StatusOK, body, `action="/subscribe/annual"`)
673 if strings.Contains(body, `action="/subscribe/monthly"`) {
674 t.Error("the pricing page sells a price billed every twelve months")
675 }
676 boss := newClient(t, app)
677 boss.signin(owner)
678 status, body = boss.get("/admin/offers")
679 want(t, "the offers page", status, http.StatusOK, body, "price_monthly", say("abono.owner.price_unsupported"))
680 if strings.Contains(body, say("abono.price.per_month")) {
681 t.Error("the offers page calls a price billed every twelve months monthly")
682 }
683 if cards := abonoCards([]abono.Offer{{Plan: "dozen", Prices: []abono.Price{{ID: "p", Sold: true, Interval: "month", IntervalCount: 12, Currency: "eur"}}}}); len(cards) != 0 {
684 t.Errorf("abonoCards made %d cards for an unsupported price", len(cards))
685 }
686}
687
688// TestStripePageShowsTheWebhookAddress is final review I9 (ruling R40),
689// with the shipped English rather than the test catalog: the approved
690// label "Webhook address" has no {url} in it, so the address itself is
691// shown beside it, or the instruction under it points at nothing.
692func TestStripePageShowsTheWebhookAddress(t *testing.T) {
693 stripe := abonotest.New(t)
694 stripe.AddPrice("price_annual", "annual", 6000, "eur", "year")
695 stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month")
696 app := newApp(t, func(c *Config) {
697 c.Abono = stripe.Config
698 c.Catalog = nil // abono.Catalog(), the approved English
699 })
700 boss := newClient(t, app)
701 boss.signin(owner)
702 status, body := boss.get("/admin/stripe")
703 approved := abono.Catalog()
704 want(t, "the Stripe page", status, http.StatusOK, body,
705 html.EscapeString(approved["abono.owner.stripe_test"]),
706 html.EscapeString(approved["abono.owner.stripe_webhook"]),
707 "<code>"+app.URL+abono.WebhookPath+"</code>",
708 html.EscapeString(approved["abono.owner.stripe_webhook_help"]))
709}
710
711// TestOwnerAddsAndPromotesPrices is final review I10 (ruling R41): from
712// the offers page the owner adds an existing Stripe price to a plan, sold
713// or legacy, and makes a legacy price the sold one again. A new sold price
714// replaces the plan's sold price at its interval (ruling R38).
715func TestOwnerAddsAndPromotesPrices(t *testing.T) {
716 app := newApp(t)
717 boss := newClient(t, app)
718 boss.signin(owner)
719 reader := newClient(t, app)
720 reader.signin("reader@example.com")
721
722 status, body := boss.get("/admin/offers")
723 want(t, "the offers page", status, http.StatusOK, body,
724 say("abono.owner.add_price_title"), `action="/admin/offers/prices"`, say("abono.owner.add_price_id"),
725 `name="price"`, `name="sold"`, say("abono.owner.price_sold"), say("abono.button.add_price"))
726 if st, _, _ := reader.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_monthly_new"}, "sold": {"1"}}); st != http.StatusForbidden {
727 t.Errorf("a reader adding a price: %d, want 403", st)
728 }
729
730 // A new monthly price on sale: the old one becomes legacy.
731 st, loc, body := boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_monthly_new"}, "sold": {"1"}})
732 if st != http.StatusSeeOther || loc != "/admin/offers" {
733 t.Fatalf("add a sold price: %d to %q\n%s", st, loc, body)
734 }
735 status, body = boss.get("/admin/offers")
736 want(t, "after adding", status, http.StatusOK, body, "price_monthly_new", `data-abono-make-sold="price_monthly"`)
737 if strings.Contains(body, `data-abono-make-sold="price_monthly_new"`) {
738 t.Error("the price on sale offers to be put on sale")
739 }
740 if st, body := reader.get("/subscribe"); st != http.StatusOK || !strings.Contains(body, `action="/subscribe/monthly"`) {
741 t.Errorf("monthly is not for sale after the replacement: %d\n%s", st, body)
742 }
743
744 // The old one back on sale, from its own button.
745 st, loc, _ = boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_monthly"}, "sold": {"1"}})
746 if st != http.StatusSeeOther || loc != "/admin/offers" {
747 t.Fatalf("make price_monthly the sold one: %d to %q", st, loc)
748 }
749 status, body = boss.get("/admin/offers")
750 want(t, "after promoting", status, http.StatusOK, body, `data-abono-make-sold="price_monthly_new"`, say("abono.button.make_sold"))
751
752 // A legacy price: listed, never sold.
753 st, loc, _ = boss.post("/admin/offers/prices", url.Values{"plan": {"annual"}, "price": {"price_annual_2019"}})
754 if st != http.StatusSeeOther || loc != "/admin/offers" {
755 t.Fatalf("add a legacy price: %d to %q", st, loc)
756 }
757 status, body = boss.get("/admin/offers")
758 want(t, "after a legacy price", status, http.StatusOK, body, "price_annual_2019", `data-abono-make-sold="price_annual_2019"`)
759
760 // What cannot be done says so, on the page.
761 st, _, body = boss.post("/admin/offers/prices", url.Values{"plan": {"gold"}, "price": {"price_x"}, "sold": {"1"}})
762 want(t, "a price for an unknown plan", st, http.StatusUnprocessableEntity, body, say("abono.error.unknown_plan"), `value="price_x"`)
763 st, _, body = boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_annual"}, "sold": {"1"}})
764 want(t, "another plan's price", st, http.StatusUnprocessableEntity, body, say("abono.error.add_price_failed"))
765 st, _, body = boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {" "}})
766 want(t, "no price", st, http.StatusUnprocessableEntity, body, say("abono.error.add_price_failed"))
767}
768
769// TestSyncSaysWhenItOnlyPartlyWorked is final review M3 (ruling R43):
770// Reconcile keeps what it could sync when another subscription fails, so
771// "nothing was changed" is only true when nothing was. Stripe, through a
772// proxy, refuses to give out one subscription, then everything.
773func TestSyncSaysWhenItOnlyPartlyWorked(t *testing.T) {
774 stripe := abonotest.New(t)
775 stripe.AddPrice("price_annual", "annual", 6000, "eur", "year")
776 stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month")
777 target, err := url.Parse(stripe.URL)
778 if err != nil {
779 t.Fatal(err)
780 }
781 var refuse atomic.Value // "", "one" or "all"
782 refuse.Store("")
783 proxy := httputil.NewSingleHostReverseProxy(target)
784 front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
785 switch mode := refuse.Load().(string); {
786 case mode == "one" && r.URL.Path == "/v1/subscriptions/sub_bad",
787 mode == "all" && strings.HasPrefix(r.URL.Path, "/v1/subscriptions"):
788 w.Header().Set("Content-Type", "application/json")
789 w.WriteHeader(http.StatusInternalServerError)
790 w.Write([]byte(`{"error":{"type":"api_error","message":"down"}}`))
791 return
792 }
793 proxy.ServeHTTP(w, r)
794 }))
795 t.Cleanup(front.Close)
796 app := newApp(t, func(c *Config) {
797 c.Abono = func(ac abono.Config) abono.Config {
798 ac = stripe.Config(ac)
799 ac.StripeBase = front.URL
800 return ac
801 }
802 })
803 now := stripe.Now()
804 for _, id := range []string{"sub_good", "sub_bad"} {
805 stripe.AddSubscription(abonotest.Subscription{ID: id, Status: "active", Price: "price_monthly",
806 Email: id + "@example.com", PeriodStart: now, PeriodEnd: now.Add(30 * 24 * time.Hour)})
807 }
808 boss := newClient(t, app)
809 boss.signin(owner)
810
811 refuse.Store("one")
812 st, _, body := boss.post("/admin/members/sync", nil)
813 want(t, "a sync that partly worked", st, http.StatusBadGateway, body, say("abono.error.sync_partial"), "sub_good@example.com")
814 if strings.Contains(body, say("abono.error.sync_failed")) {
815 t.Error("a sync that kept sub_good says nothing was changed")
816 }
817
818 refuse.Store("all")
819 st, _, body = boss.post("/admin/members/sync", nil)
820 want(t, "a sync that could not reach Stripe", st, http.StatusBadGateway, body, say("abono.error.sync_failed"))
821}
822