rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1package abono_test
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "html"
8 "io"
9 "log/slog"
10 "net/http"
11 "net/http/httptest"
12 "net/url"
13 "strings"
14 "sync"
15 "testing"
16 "time"
17
18 "gorm.io/gorm"
19
20 "amadan.net/rastrillo/rastrillo/csrf"
21 "amadan.net/rastrillo/rastrillo/migrate"
22 "amadan.net/rastrillo/rastrillo/sessions"
23
24 "amadan.net/rastrillo/abono"
25)
26
27// clock is a test clock that server goroutines may read while the test
28// moves it.
29type clock struct {
30 mu sync.Mutex
31 t time.Time
32}
33
34func (c *clock) Now() time.Time {
35 c.mu.Lock()
36 defer c.mu.Unlock()
37 return c.t
38}
39
40func (c *clock) Advance(d time.Duration) {
41 c.mu.Lock()
42 defer c.mu.Unlock()
43 c.t = c.t.Add(d)
44}
45
46// browser is a same-origin browser: it sends Origin on a POST, as every
47// current browser does on a form, and reports redirects rather than
48// following them.
49type browser struct {
50 t *testing.T
51 origin string
52}
53
54func (b browser) do(method, u string, form url.Values) (int, string, string) {
55 b.t.Helper()
56 req, err := http.NewRequest(method, u, strings.NewReader(form.Encode()))
57 if err != nil {
58 b.t.Fatal(err)
59 }
60 if method == http.MethodPost {
61 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
62 if b.origin != "" {
63 req.Header.Set("Origin", b.origin)
64 }
65 }
66 c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
67 resp, err := c.Do(req)
68 if err != nil {
69 b.t.Fatalf("%s %s: %v", method, u, err)
70 }
71 defer resp.Body.Close()
72 body, _ := io.ReadAll(resp.Body)
73 return resp.StatusCode, resp.Header.Get("Location"), string(body)
74}
75
76// TestSandboxRunsTheSameFlowWithoutStripe is abonotest's flow against a
77// Sandbox service: checkout, pay, cancel in the portal, the period ends.
78// The fake runs in-process; its pages are the app's, at SandboxPath, and
79// its events reach the service without HTTP.
80func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) {
81 ctx := context.Background()
82 clk := &clock{t: t0}
83 d := openDB(t)
84 if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
85 t.Fatalf("migrate.Apply: %v", err)
86 }
87 srv := httptest.NewUnstartedServer(nil) // on 127.0.0.1
88 origin := "http://" + srv.Listener.Addr().String()
89 rec := &recorder{subject: "ana"}
90 s, err := abono.New(abono.Config{
91 DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange, Now: clk.Now,
92 Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)),
93 })
94 if err != nil {
95 t.Fatalf("New: %v", err)
96 }
97 if !s.On() {
98 t.Fatal("a sandbox service is not On")
99 }
100 // No Stripe to read intervals from: the sandbox takes them from the
101 // price ids.
102 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}, {Plan: "monthly", Sold: "price_monthly"}}); err != nil {
103 t.Fatalf("SeedPlans: %v", err)
104 }
105 mux := http.NewServeMux()
106 mux.Handle(abono.SandboxPath, s.SandboxHandler())
107 mux.HandleFunc("POST "+abono.WebhookPath, s.ServeWebhook)
108 srv.Config.Handler = csrf.Protect(origin, csrf.Exempt(abono.WebhookPath))(mux)
109 srv.Start()
110 t.Cleanup(srv.Close)
111 b := browser{t: t, origin: origin}
112
113 u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", SuccessPath: "/done", CancelPath: "/join"})
114 if err != nil {
115 t.Fatalf("Checkout: %v", err)
116 }
117 if !strings.HasPrefix(u, origin+abono.SandboxPath+"checkout/") {
118 t.Fatalf("Checkout URL = %q, want the sandbox's page on this site", u)
119 }
120 code, _, page := b.do(http.MethodGet, u, nil)
121 if code != http.StatusOK {
122 t.Fatalf("checkout page: status %d", code)
123 }
124 // The approved English, not the keys (final review I8, ruling R40).
125 approved := abono.Catalog()
126 for _, key := range []string{"abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.pay", "abono.sandbox.decline", "abono.sandbox.email"} {
127 if text := html.EscapeString(approved[key]); approved[key] == "" || !strings.Contains(page, text) {
128 t.Errorf("the checkout page does not say %s (%q)", key, approved[key])
129 }
130 }
131 if strings.Contains(page, "abono.sandbox.") {
132 t.Errorf("the checkout page shows message keys:\n%s", page)
133 }
134
135 // The pages are browser forms: csrf.Protect turns away a POST from
136 // anywhere else.
137 if code, _, _ := (browser{t: t, origin: "http://evil.example"}).do(http.MethodPost, u+"/pay", nil); code != http.StatusForbidden {
138 t.Errorf("a cross-site Pay: status %d, want 403", code)
139 }
140
141 // Signed out, the tester types the address Checkout lacks. Nothing, or
142 // something that is not an address, gets the page back (422) and buys
143 // nothing.
144 if !strings.Contains(page, `type="email"`) || !strings.Contains(page, `name="email"`) {
145 t.Errorf("the signed-out checkout page has no email field:\n%s", page)
146 }
147 for _, bad := range []string{"", "ana", "ana@", "Ana <ana@example.com>"} {
148 code, loc, again := b.do(http.MethodPost, u+"/pay", url.Values{"email": {bad}})
149 if code != http.StatusUnprocessableEntity || loc != "" || !strings.Contains(again, `name="email"`) {
150 t.Errorf("Pay with %q: status %d to %q, want the page again with 422", bad, code, loc)
151 }
152 }
153 if hasPlan(t, s, "ana", "annual") {
154 t.Fatal("a refused address bought a membership")
155 }
156 if asked, changes := rec.take(); len(asked) != 0 || len(changes) != 0 {
157 t.Fatalf("a refused address reached the app: Holder %v, OnChange %v", asked, changes)
158 }
159
160 code, done, _ := b.do(http.MethodPost, u+"/pay", url.Values{"email": {"ana@example.com"}})
161 if code != http.StatusSeeOther || !strings.HasPrefix(done, origin+"/done?session_id=") {
162 t.Fatalf("Pay: %d to %q", code, done)
163 }
164 if !hasPlan(t, s, "ana", "annual") {
165 t.Fatal("HasPlan is false after paying in the sandbox")
166 }
167 asked, changes := rec.take()
168 eq(t, "Holder asked", asked, []string{"ana@example.com"})
169 eq(t, "OnChange", changes, []string{"ana"})
170 back, _ := url.Parse(done)
171 if f, err := s.Finish(ctx, back.Query().Get("session_id")); err != nil || !f.Paid || f.Subject != "ana" {
172 t.Errorf("Finish = %+v, %v", f, err)
173 }
174
175 pu, err := s.Portal(ctx, "ana", onlyCustomer(t, s, "ana"), "/account")
176 if err != nil {
177 t.Fatalf("Portal: %v", err)
178 }
179 if !strings.HasPrefix(pu, origin+abono.SandboxPath+"portal/") {
180 t.Fatalf("Portal URL = %q", pu)
181 }
182 if code, _, page = b.do(http.MethodGet, pu, nil); code != http.StatusOK || !strings.Contains(page, html.EscapeString(approved["abono.sandbox.portal_cancel"])) {
183 t.Fatalf("portal page: status %d\n%s", code, page)
184 }
185 hs, err := s.Holdings(ctx, "ana")
186 if err != nil || len(hs) != 1 {
187 t.Fatalf("Holdings = %+v, %v", hs, err)
188 }
189 if code, loc, _ := b.do(http.MethodPost, pu+"/cancel", url.Values{"subscription": {hs[0].SubscriptionID}}); code != http.StatusSeeOther || loc != pu {
190 t.Fatalf("Cancel: %d to %q", code, loc)
191 }
192 hs, _ = s.Holdings(ctx, "ana")
193 if got := abono.Describe(hs[0], clk.Now()).Key; got != abono.StatusCancelling {
194 t.Errorf("after cancelling: %s, want %s", got, abono.StatusCancelling)
195 }
196
197 // Time passes. The sandbox notices on its next page.
198 clk.Advance(366 * day)
199 if code, _, _ = b.do(http.MethodGet, pu, nil); code != http.StatusOK {
200 t.Errorf("portal page later: status %d", code)
201 }
202 if hasPlan(t, s, "ana", "annual") {
203 t.Error("HasPlan is true past the period end")
204 }
205 if hs, _ = s.Holdings(ctx, "ana"); hs[0].Status != "canceled" {
206 t.Errorf("past the period end the subscription is %s, want canceled", hs[0].Status)
207 }
208
209 // The sandbox's handler is its pages, not the fake's API, and the
210 // webhook takes nothing: there is no secret to sign with.
211 if code, _, _ = b.do(http.MethodGet, origin+abono.SandboxPath+"v1/subscriptions", nil); code != http.StatusNotFound {
212 t.Errorf("the fake's API on the site: status %d, want 404", code)
213 }
214 body := event("evt_1", "customer.subscription.created", map[string]any{"id": hs[0].SubscriptionID})
215 wantStatus(t, deliverWith(s, body, sign("whsec_any", body, clk.Now())), http.StatusBadRequest)
216}
217
218// A signed-in tester gives Checkout their address, so the page does not
219// ask for one, and what a form sends does not replace it.
220func TestSandboxDoesNotAskASignedInTesterForAnEmail(t *testing.T) {
221 ctx := context.Background()
222 d := openDB(t)
223 if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
224 t.Fatalf("migrate.Apply: %v", err)
225 }
226 const origin = "http://localhost:8080"
227 rec := &recorder{subject: "ana"}
228 s, err := abono.New(abono.Config{DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange,
229 Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))})
230 if err != nil {
231 t.Fatalf("New: %v", err)
232 }
233 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}}); err != nil {
234 t.Fatalf("SeedPlans: %v", err)
235 }
236 u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Email: "ana@example.com", SuccessPath: "/done"})
237 if err != nil {
238 t.Fatalf("Checkout: %v", err)
239 }
240 w := httptest.NewRecorder()
241 s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, strings.TrimPrefix(u, origin), nil))
242 if w.Code != http.StatusOK || strings.Contains(w.Body.String(), `name="email"`) {
243 t.Errorf("the page for someone with an address: status %d\n%s", w.Code, w.Body)
244 }
245 r := httptest.NewRequest(http.MethodPost, strings.TrimPrefix(u, origin)+"/pay", strings.NewReader(url.Values{"email": {"other@example.com"}}.Encode()))
246 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
247 w = httptest.NewRecorder()
248 s.SandboxHandler().ServeHTTP(w, r)
249 if w.Code != http.StatusSeeOther {
250 t.Fatalf("Pay: status %d", w.Code)
251 }
252 if asked, _ := rec.take(); len(asked) != 1 || asked[0] != "ana@example.com" {
253 t.Errorf("Holder asked %v, want Checkout's own address", asked)
254 }
255}
256
257func TestSandboxHandlerIsOffWithoutTheSandbox(t *testing.T) {
258 now := t0
259 s, _ := newService(t, &now, abono.Config{})
260 w := httptest.NewRecorder()
261 s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, abono.SandboxPath+"checkout/cs_1", nil))
262 if w.Code != http.StatusNotFound {
263 t.Errorf("status %d, want 404", w.Code)
264 }
265}
266
267// TestSandboxSurvivesARestart: the sandbox's fake starts empty each time
268// the app does, while the database keeps the last run's rows and events.
269// A payment after a restart is a new subscription, not the old sub_1.
270func TestSandboxSurvivesARestart(t *testing.T) {
271 ctx := context.Background()
272 d := openDB(t)
273 if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
274 t.Fatalf("migrate.Apply: %v", err)
275 }
276 const origin = "http://localhost:8080"
277 run := func(subject string) {
278 t.Helper()
279 rec := &recorder{subject: subject}
280 s, err := abono.New(abono.Config{DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange,
281 Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))})
282 if err != nil {
283 t.Fatalf("New: %v", err)
284 }
285 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}}); err != nil {
286 t.Fatalf("SeedPlans: %v", err)
287 }
288 u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Email: subject + "@example.com", SuccessPath: "/done"})
289 if err != nil {
290 t.Fatalf("Checkout: %v", err)
291 }
292 w := httptest.NewRecorder()
293 s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodPost, strings.TrimPrefix(u, origin)+"/pay", nil))
294 if w.Code != http.StatusSeeOther {
295 t.Fatalf("Pay: status %d", w.Code)
296 }
297 if !hasPlan(t, s, subject, "annual") {
298 t.Errorf("%s does not hold annual after paying", subject)
299 }
300 }
301 run("ana")
302 run("bo") // the app restarted: a new sandbox, the same database
303 var subs []string
304 if err := d.G.Raw(`SELECT subject FROM abono_subscriptions ORDER BY subject`).Scan(&subs).Error; err != nil {
305 t.Fatal(err)
306 }
307 if strings.Join(subs, ",") != "ana,bo" {
308 t.Errorf("subscriptions are held by %v, want ana and bo", subs)
309 }
310}
311
312// TestSandboxRetriesADeliveryThatFailed: when the app fails an event
313// (OnChange errors), the sandbox's page answers 500 rather than pretend,
314// and keeps the event; doing it again sends it, without a second
315// subscription.
316func TestSandboxRetriesADeliveryThatFailed(t *testing.T) {
317 ctx := context.Background()
318 d := openDB(t)
319 if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
320 t.Fatalf("migrate.Apply: %v", err)
321 }
322 const origin = "http://localhost:8080"
323 var mu sync.Mutex
324 fail := false
325 s, err := abono.New(abono.Config{
326 DB: d.G, Origin: origin, Sandbox: true, Now: func() time.Time { return t0 },
327 Holder: func(context.Context, *gorm.DB, string, string) (string, error) { return "ana", nil },
328 OnChange: func(context.Context, *gorm.DB, string) error {
329 mu.Lock()
330 defer mu.Unlock()
331 if fail {
332 fail = false
333 return errors.New("the app is down")
334 }
335 return nil
336 },
337 Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)),
338 })
339 if err != nil {
340 t.Fatalf("New: %v", err)
341 }
342 if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}}); err != nil {
343 t.Fatalf("SeedPlans: %v", err)
344 }
345 post := func(u string, form url.Values) int {
346 t.Helper()
347 r := httptest.NewRequest(http.MethodPost, strings.TrimPrefix(u, origin), strings.NewReader(form.Encode()))
348 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
349 w := httptest.NewRecorder()
350 s.SandboxHandler().ServeHTTP(w, r)
351 return w.Code
352 }
353
354 u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Email: "ana@example.com", SuccessPath: "/done"})
355 if err != nil {
356 t.Fatalf("Checkout: %v", err)
357 }
358 if code := post(u+"/pay", nil); code != http.StatusSeeOther {
359 t.Fatalf("Pay: status %d", code)
360 }
361 hs, _ := s.Holdings(ctx, "ana")
362 if len(hs) != 1 {
363 t.Fatalf("Holdings = %+v", hs)
364 }
365 pu, err := s.Portal(ctx, "ana", onlyCustomer(t, s, "ana"), "/account")
366 if err != nil {
367 t.Fatalf("Portal: %v", err)
368 }
369
370 mu.Lock()
371 fail = true
372 mu.Unlock()
373 form := url.Values{"subscription": {hs[0].SubscriptionID}}
374 if code := post(pu+"/cancel", form); code != http.StatusInternalServerError {
375 t.Fatalf("Cancel while the app fails: status %d, want 500", code)
376 }
377 if hs, _ = s.Holdings(ctx, "ana"); hs[0].CancelAtPeriodEnd {
378 t.Fatal("abono recorded the cancellation the app refused")
379 }
380 if code := post(pu+"/cancel", form); code != http.StatusSeeOther {
381 t.Fatalf("Cancel again: status %d, want 303", code)
382 }
383 hs, _ = s.Holdings(ctx, "ana")
384 if len(hs) != 1 || abono.Describe(hs[0], t0).Key != abono.StatusCancelling {
385 t.Errorf("after the retry: %+v, want one subscription, cancelling", hs)
386 }
387}
388
389// onlyCustomer is subject's one Stripe customer.
390func onlyCustomer(t *testing.T, s *abono.Service, subject string) string {
391 t.Helper()
392 cs, err := s.Customers(context.Background(), subject)
393 if err != nil || len(cs) != 1 {
394 t.Fatalf("Customers(%s) = %q, %v; want one", subject, cs, err)
395 }
396 return cs[0]
397}
398