| 1 | package abono_test |
| 2 | |
| 3 | import ( |
| 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. |
| 29 | type clock struct { |
| 30 | mu sync.Mutex |
| 31 | t time.Time |
| 32 | } |
| 33 | |
| 34 | func (c *clock) Now() time.Time { |
| 35 | c.mu.Lock() |
| 36 | defer c.mu.Unlock() |
| 37 | return c.t |
| 38 | } |
| 39 | |
| 40 | func (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. |
| 49 | type browser struct { |
| 50 | t *testing.T |
| 51 | origin string |
| 52 | } |
| 53 | |
| 54 | func (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. |
| 80 | func 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. |
| 220 | func 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 | |
| 257 | func 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. |
| 270 | func 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. |
| 316 | func 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. |
| 390 | func 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 | |