package abono_test import ( "bytes" "context" "errors" "html" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "gorm.io/gorm" "amadan.net/rastrillo/rastrillo/csrf" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/rastrillo/rastrillo/sessions" "amadan.net/rastrillo/abono" ) // clock is a test clock that server goroutines may read while the test // moves it. type clock struct { mu sync.Mutex t time.Time } func (c *clock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.t } func (c *clock) Advance(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.t = c.t.Add(d) } // browser is a same-origin browser: it sends Origin on a POST, as every // current browser does on a form, and reports redirects rather than // following them. type browser struct { t *testing.T origin string } func (b browser) do(method, u string, form url.Values) (int, string, string) { b.t.Helper() req, err := http.NewRequest(method, u, strings.NewReader(form.Encode())) if err != nil { b.t.Fatal(err) } if method == http.MethodPost { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if b.origin != "" { req.Header.Set("Origin", b.origin) } } c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := c.Do(req) if err != nil { b.t.Fatalf("%s %s: %v", method, u, err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return resp.StatusCode, resp.Header.Get("Location"), string(body) } // TestSandboxRunsTheSameFlowWithoutStripe is abonotest's flow against a // Sandbox service: checkout, pay, cancel in the portal, the period ends. // The fake runs in-process; its pages are the app's, at SandboxPath, and // its events reach the service without HTTP. func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) { ctx := context.Background() clk := &clock{t: t0} d := openDB(t) if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil { t.Fatalf("migrate.Apply: %v", err) } srv := httptest.NewUnstartedServer(nil) // on 127.0.0.1 origin := "http://" + srv.Listener.Addr().String() rec := &recorder{subject: "ana"} s, err := abono.New(abono.Config{ DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange, Now: clk.Now, Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), }) if err != nil { t.Fatalf("New: %v", err) } if !s.On() { t.Fatal("a sandbox service is not On") } // No Stripe to read intervals from: the sandbox takes them from the // price ids. if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}, {Plan: "monthly", Sold: "price_monthly"}}); err != nil { t.Fatalf("SeedPlans: %v", err) } mux := http.NewServeMux() mux.Handle(abono.SandboxPath, s.SandboxHandler()) mux.HandleFunc("POST "+abono.WebhookPath, s.ServeWebhook) srv.Config.Handler = csrf.Protect(origin, csrf.Exempt(abono.WebhookPath))(mux) srv.Start() t.Cleanup(srv.Close) b := browser{t: t, origin: origin} u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", SuccessPath: "/done", CancelPath: "/join"}) if err != nil { t.Fatalf("Checkout: %v", err) } if !strings.HasPrefix(u, origin+abono.SandboxPath+"checkout/") { t.Fatalf("Checkout URL = %q, want the sandbox's page on this site", u) } code, _, page := b.do(http.MethodGet, u, nil) if code != http.StatusOK { t.Fatalf("checkout page: status %d", code) } // The approved English, not the keys (final review I8, ruling R40). approved := abono.Catalog() for _, key := range []string{"abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.pay", "abono.sandbox.decline", "abono.sandbox.email"} { if text := html.EscapeString(approved[key]); approved[key] == "" || !strings.Contains(page, text) { t.Errorf("the checkout page does not say %s (%q)", key, approved[key]) } } if strings.Contains(page, "abono.sandbox.") { t.Errorf("the checkout page shows message keys:\n%s", page) } // The pages are browser forms: csrf.Protect turns away a POST from // anywhere else. if code, _, _ := (browser{t: t, origin: "http://evil.example"}).do(http.MethodPost, u+"/pay", nil); code != http.StatusForbidden { t.Errorf("a cross-site Pay: status %d, want 403", code) } // Signed out, the tester types the address Checkout lacks. Nothing, or // something that is not an address, gets the page back (422) and buys // nothing. if !strings.Contains(page, `type="email"`) || !strings.Contains(page, `name="email"`) { t.Errorf("the signed-out checkout page has no email field:\n%s", page) } for _, bad := range []string{"", "ana", "ana@", "Ana "} { code, loc, again := b.do(http.MethodPost, u+"/pay", url.Values{"email": {bad}}) if code != http.StatusUnprocessableEntity || loc != "" || !strings.Contains(again, `name="email"`) { t.Errorf("Pay with %q: status %d to %q, want the page again with 422", bad, code, loc) } } if hasPlan(t, s, "ana", "annual") { t.Fatal("a refused address bought a membership") } if asked, changes := rec.take(); len(asked) != 0 || len(changes) != 0 { t.Fatalf("a refused address reached the app: Holder %v, OnChange %v", asked, changes) } code, done, _ := b.do(http.MethodPost, u+"/pay", url.Values{"email": {"ana@example.com"}}) if code != http.StatusSeeOther || !strings.HasPrefix(done, origin+"/done?session_id=") { t.Fatalf("Pay: %d to %q", code, done) } if !hasPlan(t, s, "ana", "annual") { t.Fatal("HasPlan is false after paying in the sandbox") } asked, changes := rec.take() eq(t, "Holder asked", asked, []string{"ana@example.com"}) eq(t, "OnChange", changes, []string{"ana"}) back, _ := url.Parse(done) if f, err := s.Finish(ctx, back.Query().Get("session_id")); err != nil || !f.Paid || f.Subject != "ana" { t.Errorf("Finish = %+v, %v", f, err) } pu, err := s.Portal(ctx, "ana", onlyCustomer(t, s, "ana"), "/account") if err != nil { t.Fatalf("Portal: %v", err) } if !strings.HasPrefix(pu, origin+abono.SandboxPath+"portal/") { t.Fatalf("Portal URL = %q", pu) } if code, _, page = b.do(http.MethodGet, pu, nil); code != http.StatusOK || !strings.Contains(page, html.EscapeString(approved["abono.sandbox.portal_cancel"])) { t.Fatalf("portal page: status %d\n%s", code, page) } hs, err := s.Holdings(ctx, "ana") if err != nil || len(hs) != 1 { t.Fatalf("Holdings = %+v, %v", hs, err) } if code, loc, _ := b.do(http.MethodPost, pu+"/cancel", url.Values{"subscription": {hs[0].SubscriptionID}}); code != http.StatusSeeOther || loc != pu { t.Fatalf("Cancel: %d to %q", code, loc) } hs, _ = s.Holdings(ctx, "ana") if got := abono.Describe(hs[0], clk.Now()).Key; got != abono.StatusCancelling { t.Errorf("after cancelling: %s, want %s", got, abono.StatusCancelling) } // Time passes. The sandbox notices on its next page. clk.Advance(366 * day) if code, _, _ = b.do(http.MethodGet, pu, nil); code != http.StatusOK { t.Errorf("portal page later: status %d", code) } if hasPlan(t, s, "ana", "annual") { t.Error("HasPlan is true past the period end") } if hs, _ = s.Holdings(ctx, "ana"); hs[0].Status != "canceled" { t.Errorf("past the period end the subscription is %s, want canceled", hs[0].Status) } // The sandbox's handler is its pages, not the fake's API, and the // webhook takes nothing: there is no secret to sign with. if code, _, _ = b.do(http.MethodGet, origin+abono.SandboxPath+"v1/subscriptions", nil); code != http.StatusNotFound { t.Errorf("the fake's API on the site: status %d, want 404", code) } body := event("evt_1", "customer.subscription.created", map[string]any{"id": hs[0].SubscriptionID}) wantStatus(t, deliverWith(s, body, sign("whsec_any", body, clk.Now())), http.StatusBadRequest) } // A signed-in tester gives Checkout their address, so the page does not // ask for one, and what a form sends does not replace it. func TestSandboxDoesNotAskASignedInTesterForAnEmail(t *testing.T) { ctx := context.Background() d := openDB(t) if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil { t.Fatalf("migrate.Apply: %v", err) } const origin = "http://localhost:8080" rec := &recorder{subject: "ana"} s, err := abono.New(abono.Config{DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange, Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))}) if err != nil { t.Fatalf("New: %v", err) } if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}}); err != nil { t.Fatalf("SeedPlans: %v", err) } u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Email: "ana@example.com", SuccessPath: "/done"}) if err != nil { t.Fatalf("Checkout: %v", err) } w := httptest.NewRecorder() s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, strings.TrimPrefix(u, origin), nil)) if w.Code != http.StatusOK || strings.Contains(w.Body.String(), `name="email"`) { t.Errorf("the page for someone with an address: status %d\n%s", w.Code, w.Body) } r := httptest.NewRequest(http.MethodPost, strings.TrimPrefix(u, origin)+"/pay", strings.NewReader(url.Values{"email": {"other@example.com"}}.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w = httptest.NewRecorder() s.SandboxHandler().ServeHTTP(w, r) if w.Code != http.StatusSeeOther { t.Fatalf("Pay: status %d", w.Code) } if asked, _ := rec.take(); len(asked) != 1 || asked[0] != "ana@example.com" { t.Errorf("Holder asked %v, want Checkout's own address", asked) } } func TestSandboxHandlerIsOffWithoutTheSandbox(t *testing.T) { now := t0 s, _ := newService(t, &now, abono.Config{}) w := httptest.NewRecorder() s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, abono.SandboxPath+"checkout/cs_1", nil)) if w.Code != http.StatusNotFound { t.Errorf("status %d, want 404", w.Code) } } // TestSandboxSurvivesARestart: the sandbox's fake starts empty each time // the app does, while the database keeps the last run's rows and events. // A payment after a restart is a new subscription, not the old sub_1. func TestSandboxSurvivesARestart(t *testing.T) { ctx := context.Background() d := openDB(t) if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil { t.Fatalf("migrate.Apply: %v", err) } const origin = "http://localhost:8080" run := func(subject string) { t.Helper() rec := &recorder{subject: subject} s, err := abono.New(abono.Config{DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange, Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))}) if err != nil { t.Fatalf("New: %v", err) } if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}}); err != nil { t.Fatalf("SeedPlans: %v", err) } u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Email: subject + "@example.com", SuccessPath: "/done"}) if err != nil { t.Fatalf("Checkout: %v", err) } w := httptest.NewRecorder() s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodPost, strings.TrimPrefix(u, origin)+"/pay", nil)) if w.Code != http.StatusSeeOther { t.Fatalf("Pay: status %d", w.Code) } if !hasPlan(t, s, subject, "annual") { t.Errorf("%s does not hold annual after paying", subject) } } run("ana") run("bo") // the app restarted: a new sandbox, the same database var subs []string if err := d.G.Raw(`SELECT subject FROM abono_subscriptions ORDER BY subject`).Scan(&subs).Error; err != nil { t.Fatal(err) } if strings.Join(subs, ",") != "ana,bo" { t.Errorf("subscriptions are held by %v, want ana and bo", subs) } } // TestSandboxRetriesADeliveryThatFailed: when the app fails an event // (OnChange errors), the sandbox's page answers 500 rather than pretend, // and keeps the event; doing it again sends it, without a second // subscription. func TestSandboxRetriesADeliveryThatFailed(t *testing.T) { ctx := context.Background() d := openDB(t) if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil { t.Fatalf("migrate.Apply: %v", err) } const origin = "http://localhost:8080" var mu sync.Mutex fail := false s, err := abono.New(abono.Config{ DB: d.G, Origin: origin, Sandbox: true, Now: func() time.Time { return t0 }, Holder: func(context.Context, *gorm.DB, string, string) (string, error) { return "ana", nil }, OnChange: func(context.Context, *gorm.DB, string) error { mu.Lock() defer mu.Unlock() if fail { fail = false return errors.New("the app is down") } return nil }, Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), }) if err != nil { t.Fatalf("New: %v", err) } if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}}); err != nil { t.Fatalf("SeedPlans: %v", err) } post := func(u string, form url.Values) int { t.Helper() r := httptest.NewRequest(http.MethodPost, strings.TrimPrefix(u, origin), strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.SandboxHandler().ServeHTTP(w, r) return w.Code } u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Email: "ana@example.com", SuccessPath: "/done"}) if err != nil { t.Fatalf("Checkout: %v", err) } if code := post(u+"/pay", nil); code != http.StatusSeeOther { t.Fatalf("Pay: status %d", code) } hs, _ := s.Holdings(ctx, "ana") if len(hs) != 1 { t.Fatalf("Holdings = %+v", hs) } pu, err := s.Portal(ctx, "ana", onlyCustomer(t, s, "ana"), "/account") if err != nil { t.Fatalf("Portal: %v", err) } mu.Lock() fail = true mu.Unlock() form := url.Values{"subscription": {hs[0].SubscriptionID}} if code := post(pu+"/cancel", form); code != http.StatusInternalServerError { t.Fatalf("Cancel while the app fails: status %d, want 500", code) } if hs, _ = s.Holdings(ctx, "ana"); hs[0].CancelAtPeriodEnd { t.Fatal("abono recorded the cancellation the app refused") } if code := post(pu+"/cancel", form); code != http.StatusSeeOther { t.Fatalf("Cancel again: status %d, want 303", code) } hs, _ = s.Holdings(ctx, "ana") if len(hs) != 1 || abono.Describe(hs[0], t0).Key != abono.StatusCancelling { t.Errorf("after the retry: %+v, want one subscription, cancelling", hs) } } // onlyCustomer is subject's one Stripe customer. func onlyCustomer(t *testing.T, s *abono.Service, subject string) string { t.Helper() cs, err := s.Customers(context.Background(), subject) if err != nil || len(cs) != 1 { t.Fatalf("Customers(%s) = %q, %v; want one", subject, cs, err) } return cs[0] }