package main import ( "bytes" "context" "encoding/json" "html" "io" "io/fs" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "net/http/httputil" "net/url" "os" "path" "path/filepath" "regexp" "strconv" "strings" "sync" "sync/atomic" "testing" "time" "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/abono" "amadan.net/rastrillo/abono/abonotest" ) const owner = "owner@example.com" // keyLiteral is a message key written as a string literal. var keyLiteral = regexp.MustCompile(`"((?:abono|example|rastrillo)\.[a-z][a-z_.]*[a-z])"`) // testCatalog maps every message key the example's pages, abono's // partials and the generated Go name to "T:", so a test reads // which sentence a page chose whatever English the shipped catalogs // hold. say(key) is what a page then shows for key. var testCatalog = sync.OnceValue(func() rastrillo.Catalog { cat := rastrillo.Catalog{} add := func(src []byte) { for _, m := range keyLiteral.FindAllSubmatch(src, -1) { cat[string(m[1])] = say(string(m[1])) } } for _, fsys := range []fs.FS{os.DirFS("."), abono.Templates()} { err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || (path.Ext(p) != ".html" && p != "abono_pages.go") { return err } src, err := fs.ReadFile(fsys, p) add(src) return err }) if err != nil { panic(err) } } for _, k := range []string{abono.StatusActive, abono.StatusRenews, abono.StatusCancelling, abono.StatusFailing, abono.StatusEnded, abono.StatusComp, abono.StatusCompUntil} { cat[k] = say(k) } return cat }) func say(key string) string { return "T:" + key } // inbox is the example's mailer in tests: it keeps every message, so a // test can follow the link the way the address's owner would. type inbox struct { mu sync.Mutex mail map[string][]string } func (b *inbox) Send(_ context.Context, to, _, body string) error { b.mu.Lock() defer b.mu.Unlock() if b.mail == nil { b.mail = map[string][]string{} } b.mail[strings.ToLower(to)] = append(b.mail[strings.ToLower(to)], body) return nil } var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=[^\s]+`) // link is the newest sign-in link sent to address. func (b *inbox) link(t testing.TB, address string) string { t.Helper() b.mu.Lock() defer b.mu.Unlock() msgs := b.mail[address] if len(msgs) == 0 { t.Fatalf("no mail for %s", address) } l := magicLink.FindString(msgs[len(msgs)-1]) if l == "" { t.Fatalf("no sign-in link in the mail to %s:\n%s", address, msgs[len(msgs)-1]) } return l } func (b *inbox) count(address string) int { b.mu.Lock() defer b.mu.Unlock() return len(b.mail[address]) } // testConfig is the example as tests run it: a fresh database, quiet // logs, the test catalog, mail into box, and the sandbox for Stripe. func testConfig(t testing.TB, origin string, box *inbox) Config { t.Helper() quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "example.db"), quiet) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) return Config{DB: d, Origin: origin, Owner: owner, InstanceKey: "example-test-instance-key", Mailer: box, Catalog: testCatalog(), Logger: quiet} } type testApp struct { *httptest.Server inbox *inbox } // newApp serves the whole example, as main does. The server's own // address is the origin, so a POST carrying it as Origin passes // csrf.Protect. adjust, when given, changes the configuration first. func newApp(t *testing.T, adjust ...func(*Config)) *testApp { t.Helper() ts := httptest.NewUnstartedServer(nil) app := &testApp{Server: ts, inbox: &inbox{}} cfg := testConfig(t, "http://"+ts.Listener.Addr().String(), app.inbox) for _, f := range adjust { f(&cfg) } opts, err := Options(cfg) if err != nil { t.Fatalf("Options: %v", err) } h, closeApp, err := rastrillo.Handler(opts) if err != nil { t.Fatalf("rastrillo.Handler: %v", err) } t.Cleanup(func() { closeApp() }) ts.Config.Handler = h ts.Start() t.Cleanup(ts.Close) return app } // client is one visitor: a cookie jar, and the Origin a browser's form // POST carries. It never follows a redirect on its own. type client struct { t *testing.T app *testApp c *http.Client } func newClient(t *testing.T, app *testApp) *client { t.Helper() jar, _ := cookiejar.New(nil) return &client{t: t, app: app, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} } // do sends a request to path (or an absolute address on the app) and // returns the status, the Location and the body. origin is the Origin // header, "" for none. func (cl *client) do(method, path string, form url.Values, origin string) (int, string, string) { cl.t.Helper() u := path if strings.HasPrefix(path, "/") { u = cl.app.URL + path } var body io.Reader if form != nil { body = strings.NewReader(form.Encode()) } req, err := http.NewRequest(method, u, body) if err != nil { cl.t.Fatal(err) } if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if origin != "" { req.Header.Set("Origin", origin) } resp, err := cl.c.Do(req) if err != nil { cl.t.Fatalf("%s %s: %v", method, path, err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp.StatusCode, resp.Header.Get("Location"), string(b) } func (cl *client) get(path string) (int, string) { cl.t.Helper() status, _, body := cl.do(http.MethodGet, path, nil, "") return status, body } // post is a same-origin form POST, as a browser sends one; it returns // the status, where it redirects and the body. func (cl *client) post(path string, form url.Values) (int, string, string) { cl.t.Helper() if form == nil { form = url.Values{} } return cl.do(http.MethodPost, path, form, cl.app.URL) } // askForLink submits address on the sign-in page. func (cl *client) askForLink(address string) { cl.t.Helper() status, loc, body := cl.post("/signin", url.Values{"address": {address}}) if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/signin?sent=1") { cl.t.Fatalf("POST /signin for %s: %d to %q\n%s", address, status, loc, body) } } // follow opens a sign-in link and presses its Sign in button: what the // address's owner does from their inbox. func (cl *client) follow(link string) { cl.t.Helper() status, page := cl.get(link) want(cl.t, "the link's confirm page", status, http.StatusOK, page, `action="/auth/verify"`, `name="token"`) u, err := url.Parse(link) if err != nil { cl.t.Fatal(err) } status, loc, body := cl.post("/auth/verify", url.Values{"token": {u.Query().Get("token")}}) if status != http.StatusSeeOther || strings.Contains(loc, "err=") { cl.t.Fatalf("redeem the link: %d to %q\n%s", status, loc, body) } } // signin signs the client in as address by its emailed link. func (cl *client) signin(address string) { cl.t.Helper() cl.askForLink(address) cl.follow(cl.app.inbox.link(cl.t, address)) } func want(t testing.TB, what string, got, wantStatus int, body string, parts ...string) { t.Helper() if got != wantStatus { t.Fatalf("%s: status %d, want %d\n%s", what, got, wantStatus, body) } for _, p := range parts { if !strings.Contains(body, p) { t.Errorf("%s: no %q in\n%s", what, p, body) } } } var payAction = regexp.MustCompile(`action="(/abono/sandbox/checkout/[^"/]+/pay)"`) // checkout starts Checkout for plan and pays on the sandbox's page, as // email when it is not "", and returns where the sandbox sends the reader. func (cl *client) checkout(plan, email string) string { cl.t.Helper() status, loc, body := cl.post("/subscribe/"+plan, nil) if status != http.StatusSeeOther || !strings.HasPrefix(loc, cl.app.URL+"/abono/sandbox/checkout/") { cl.t.Fatalf("POST /subscribe/%s: %d to %q\n%s", plan, status, loc, body) } status, page := cl.get(loc) m := payAction.FindStringSubmatch(page) if status != http.StatusOK || m == nil { cl.t.Fatalf("the sandbox checkout: %d, no pay form:\n%s", status, page) } form := url.Values{} if email != "" { form.Set("email", email) } status, back, body := cl.post(m[1], form) if status != http.StatusSeeOther || !strings.HasPrefix(back, cl.app.URL+"/subscribe/done?session_id=") { cl.t.Fatalf("pay: %d to %q\n%s", status, back, body) } return back } // TestSandboxSubscribe is the reader's whole flow over HTTP: sign in by // link, the plans, Checkout on the sandbox's page, Checkout's return, // and the account page showing the membership. func TestSandboxSubscribe(t *testing.T) { app := newApp(t) reader := newClient(t, app) reader.signin("reader@example.com") status, body := reader.get("/subscribe") want(t, "the plans", status, http.StatusOK, body, say("abono.pages.pricing_title"), `action="/subscribe/annual"`, `action="/subscribe/monthly"`) status, body = reader.get("/subscribe/annual") want(t, "the annual plan", status, http.StatusOK, body, `action="/subscribe/annual"`, `name="code"`, `href="/subscribe"`) status, body = reader.get("/account") want(t, "the account before paying", status, http.StatusOK, body, say("abono.pages.account_none"), `href="/subscribe"`) done := reader.checkout("annual", "") status, body = reader.get(done) want(t, "Checkout's return", status, http.StatusOK, body, say("abono.pages.done_paid"), `href="/account"`) status, body = reader.get("/account") want(t, "the account after paying", status, http.StatusOK, body, "annual", `rst-tone="positive"`, say(abono.StatusRenews), `action="/account/billing"`) // The billing button opens the sandbox's portal, for the customer it // names. m := regexp.MustCompile(`name="customer" value="([^"]+)"`).FindStringSubmatch(body) if m == nil { t.Fatalf("the billing button names no customer:\n%s", body) } st, loc, _ := reader.post("/account/billing", url.Values{"customer": {m[1]}}) if st != http.StatusSeeOther || !strings.HasPrefix(loc, app.URL+"/abono/sandbox/portal/") { t.Errorf("billing: %d to %q", st, loc) } // A promotion code Stripe does not know is said so, on the plan's page. st, _, body = reader.post("/subscribe/annual", url.Values{"code": {"NOPE"}}) want(t, "an unknown code", st, http.StatusUnprocessableEntity, body, say("abono.error.unknown_code")) st, _, body = reader.post("/subscribe/nonsense", nil) want(t, "an unknown plan", st, http.StatusNotFound, body, say("abono.error.unknown_plan")) } // TestHolderAccountNeedsItsInbox: paying signed out makes a user for the // address (the example's Holder), and only someone who can read that // address's mail gets into it. Asking for a link from another browser // sends it to the inbox, not to the asker; there is no sign-up to claim // the account with. func TestHolderAccountNeedsItsInbox(t *testing.T) { app := newApp(t) buyer := newClient(t, app) status, body := buyer.get(buyer.checkout("monthly", "walkin@example.com")) want(t, "Checkout's return, signed out", status, http.StatusOK, body, say("abono.pages.done_signin")) intruder := newClient(t, app) for _, path := range []string{"/signup"} { if st, _ := intruder.get(path); st != http.StatusNotFound { t.Errorf("GET %s: %d, want 404 (no sign-up)", path, st) } if st, _, _ := intruder.post(path, url.Values{"email": {"walkin@example.com"}, "password": {"a long enough password"}}); st != http.StatusNotFound && st != http.StatusMethodNotAllowed { t.Errorf("POST %s: %d, want 404 or 405 (no sign-up)", path, st) } } intruder.askForLink("walkin@example.com") st, loc, _ := intruder.post("/auth/verify", url.Values{"token": {"guessed"}}) if st != http.StatusSeeOther || !strings.Contains(loc, "err=expired") { t.Errorf("a guessed token: %d to %q, want the expired answer", st, loc) } if st, loc, _ := intruder.do(http.MethodGet, "/account", nil, ""); st != http.StatusSeeOther || !strings.HasPrefix(loc, "/signin") { t.Errorf("the intruder's /account: %d to %q, want sign-in", st, loc) } if st, loc, _ := intruder.post("/account/billing", nil); st != http.StatusSeeOther || loc != "/account?billing=signin" { t.Errorf("the intruder's billing: %d to %q", st, loc) } // The link went to the address. Whoever reads it is in, with the // membership. if n := app.inbox.count("walkin@example.com"); n != 1 { t.Fatalf("mail to the address: %d, want 1", n) } owner := newClient(t, app) owner.follow(app.inbox.link(t, "walkin@example.com")) status, body = owner.get("/account") want(t, "the account, signed in by link", status, http.StatusOK, body, "monthly", say(abono.StatusRenews)) } // TestOwnerGivesAndEndsAComp: the owner gives a reader a free // membership by address, the reader has it, and the owner ends it on its // own confirm page. Nobody else reaches the owner's pages. func TestOwnerGivesAndEndsAComp(t *testing.T) { app := newApp(t) reader := newClient(t, app) reader.signin("reader@example.com") boss := newClient(t, app) boss.signin(owner) for _, path := range []string{"/admin/members", "/admin/offers", "/admin/stripe", "/admin/members/comps/1/end"} { if status, _ := reader.get(path); status != http.StatusForbidden { t.Errorf("a reader GET %s: %d, want 403", path, status) } } if status, _, _ := reader.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"monthly"}}); status != http.StatusForbidden { t.Errorf("a reader POST comps: %d, want 403", status) } if status, _ := newClient(t, app).get("/admin/members"); status != http.StatusForbidden { t.Errorf("signed out GET /admin/members: %d, want 403", status) } status, body := boss.get("/admin/members") want(t, "the members page", status, http.StatusOK, body, say("abono.owner.members_empty"), say("abono.owner.free_title"), `action="/admin/members/sync"`, say("abono.owner.unrecognised_none")) st, _, body := boss.post("/admin/members/comps", url.Values{"member": {"nobody@example.com"}, "plan": {"monthly"}}) want(t, "a comp for an unknown address", st, http.StatusUnprocessableEntity, body, say("abono.owner.free_no_member"), `value="nobody@example.com"`) st, _, body = boss.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"gold"}}) want(t, "a comp of an unknown plan", st, http.StatusUnprocessableEntity, body, say("abono.error.free_plan")) st, loc, _ := boss.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"monthly"}, "note": {"a friend"}}) if st != http.StatusSeeOther || loc != "/admin/members?done=comp&plan=monthly" { t.Fatalf("give a comp: %d to %q", st, loc) } status, body = boss.get(loc) want(t, "the members page after a comp", status, http.StatusOK, body, say("abono.owner.free_given"), say("abono.owner.source_free"), say(abono.StatusComp), "reader@example.com", `href="/admin/members/comps/1/end"`) status, body = reader.get("/account") want(t, "the reader's account with a comp", status, http.StatusOK, body, say(abono.StatusComp)) status, body = boss.get("/admin/members/comps/1/end") want(t, "the confirm page", status, http.StatusOK, body, say("abono.owner.end_title"), say("abono.owner.end_body"), `action="/admin/members/comps/1/end"`, say("abono.button.end_free_confirm"), `href="/admin/members"`) st, loc, _ = boss.post("/admin/members/comps/1/end", nil) if st != http.StatusSeeOther || loc != "/admin/members?done=end" { t.Fatalf("end the comp: %d to %q", st, loc) } status, body = boss.get(loc) want(t, "the members page after ending", status, http.StatusOK, body, say("abono.owner.end_done"), say(abono.StatusEnded)) if strings.Contains(body, `href="/admin/members/comps/1/end"`) { t.Error("an ended comp still offers to be ended") } if status, _ := boss.get("/admin/members/comps/1/end"); status != http.StatusNotFound { t.Errorf("the confirm page of an ended comp: %d, want 404", status) } status, body = reader.get("/account") want(t, "the reader's account after", status, http.StatusOK, body, say(abono.StatusEnded)) } // TestOwnerSeesSyncsAndStripe: the owner's other pages, in the sandbox. func TestOwnerSeesSyncsAndStripe(t *testing.T) { app := newApp(t) reader := newClient(t, app) reader.signin("reader@example.com") reader.get(reader.checkout("annual", "")) boss := newClient(t, app) boss.signin(owner) status, body := boss.get("/admin/members") want(t, "the members page", status, http.StatusOK, body, say("abono.owner.source_paid"), say(abono.StatusRenews), "reader@example.com") st, loc, _ := boss.post("/admin/members/sync", nil) if st != http.StatusSeeOther || loc != "/admin/members?done=sync&n=1" { t.Fatalf("sync: %d to %q", st, loc) } status, body = boss.get(loc) want(t, "after a sync", status, http.StatusOK, body, say("abono.owner.sync_done_one")) status, body = boss.get("/admin/offers") want(t, "the offers page", status, http.StatusOK, body, "price_annual", "price_monthly", say("abono.owner.price_sold"), say("abono.owner.unrecognised_none")) status, body = boss.get("/admin/stripe") want(t, "the Stripe page", status, http.StatusOK, body, say("abono.owner.stripe_sandbox")) } // TestMembersShowUnrecognisedSubscriptions: a paying subscription on a // price no plan has is listed on the members page (spec 4.1), and // adopting it there puts it, with its holder, onto the plan. It runs // against abonotest's Stripe, where a test can put such a subscription. func TestMembersShowUnrecognisedSubscriptions(t *testing.T) { stripe := abonotest.New(t) stripe.AddPrice("price_annual", "annual", 6000, "eur", "year") stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month") app := newApp(t, func(c *Config) { c.Abono = stripe.Config }) stripe.SetWebhookURL(app.URL + abono.WebhookPath) now := stripe.Now() stripe.AddSubscription(abonotest.Subscription{ ID: "sub_foreign", Status: "active", Price: "price_foreign", Interval: "month", Email: "old@example.com", PeriodStart: now, PeriodEnd: now.Add(30 * 24 * time.Hour), }) boss := newClient(t, app) boss.signin(owner) st, loc, _ := boss.post("/admin/members/sync", nil) if st != http.StatusSeeOther { t.Fatalf("sync: %d to %q", st, loc) } status, body := boss.get("/admin/members") want(t, "the members page", status, http.StatusOK, body, say("abono.owner.unrecognised_title"), `data-abono-unrecognised="sub_foreign"`, "price_foreign", `name="from" value="members"`, say("abono.button.adopt")) if strings.Contains(body, say("abono.owner.unrecognised_none")) { t.Error("the members page says every subscription is recognised") } adopt := url.Values{"subscription": {"sub_foreign"}, "from": {"members"}} adopt.Set("plan", "gold") st, _, body = boss.post("/admin/offers/adopt", adopt) want(t, "adopting onto an unknown plan", st, http.StatusUnprocessableEntity, body, say("abono.error.unknown_plan"), `data-abono-unrecognised="sub_foreign"`) adopt.Set("plan", "monthly") st, loc, _ = boss.post("/admin/offers/adopt", adopt) if st != http.StatusSeeOther || loc != "/admin/members?done=adopt&plan=monthly" { t.Fatalf("adopt: %d to %q", st, loc) } status, body = boss.get(loc) want(t, "after adopting", status, http.StatusOK, body, say("abono.owner.adopted"), "old@example.com", "monthly", say("abono.owner.source_paid"), say("abono.owner.unrecognised_none")) if strings.Contains(body, `data-abono-unrecognised=`) { t.Error("the adopted subscription is still listed as unrecognised") } // From the offers page, the form goes back there. status, body = boss.get("/admin/offers") want(t, "the offers page", status, http.StatusOK, body, say("abono.owner.unrecognised_none")) } // TestMachinePathsSkipCSRF: Stripe's webhook and carlos's tick carry no // browser's Origin and are exempt, each checking its own credential; // every other POST without browser evidence is refused. func TestMachinePathsSkipCSRF(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", "tick-token") app := newApp(t) machine := newClient(t, app) // The webhook checks Stripe's signature: an unsigned delivery is 400, // not csrf's 403. status, _, body := machine.do(http.MethodPost, "/abono/webhook", url.Values{}, "") if status != http.StatusBadRequest { t.Errorf("unsigned webhook: %d, want 400\n%s", status, body) } req, _ := http.NewRequest(http.MethodPost, app.URL+"/jobs/abono-sync", nil) req.Header.Set("Authorization", "Bearer tick-token") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Errorf("the daily tick: %d, want 204", resp.StatusCode) } for _, path := range []string{"/subscribe/annual", "/account/billing", "/admin/members/sync", "/signin", "/auth/verify"} { if status, _, _ := machine.do(http.MethodPost, path, url.Values{}, ""); status != http.StatusForbidden { t.Errorf("POST %s with no Origin: %d, want 403", path, status) } if status, _, _ := machine.do(http.MethodPost, path, url.Values{}, "https://evil.example"); status != http.StatusForbidden { t.Errorf("POST %s from another origin: %d, want 403", path, status) } } } // TestDoneWhilePaymentIsProcessing is final review C2 (ruling R33): a // Checkout that completed while its payment is still on its way is not // "no payment was made". The return page says it is processing and offers // no second purchase. abonotest's Checkout always pays at once, so a proxy // in front of it reports the session's payment as still unpaid. func TestDoneWhilePaymentIsProcessing(t *testing.T) { stripe := abonotest.New(t) stripe.AddPrice("price_annual", "annual", 6000, "eur", "year") stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month") target, err := url.Parse(stripe.URL) if err != nil { t.Fatal(err) } proxy := httputil.NewSingleHostReverseProxy(target) proxy.ModifyResponse = func(resp *http.Response) error { if resp.Request.Method != http.MethodGet || !strings.HasPrefix(resp.Request.URL.Path, "/v1/checkout/sessions/") { return nil } var cs map[string]any if err := json.NewDecoder(resp.Body).Decode(&cs); err != nil { return err } resp.Body.Close() cs["payment_status"] = "unpaid" b, err := json.Marshal(cs) if err != nil { return err } resp.Body = io.NopCloser(bytes.NewReader(b)) resp.ContentLength = int64(len(b)) resp.Header.Set("Content-Length", strconv.Itoa(len(b))) return nil } front := httptest.NewServer(proxy) t.Cleanup(front.Close) app := newApp(t, func(c *Config) { c.Abono = func(ac abono.Config) abono.Config { ac = stripe.Config(ac) ac.StripeBase = front.URL return ac } }) stripe.SetWebhookURL(app.URL + abono.WebhookPath) reader := newClient(t, app) reader.signin("reader@example.com") status, loc, body := reader.post("/subscribe/annual", nil) if status != http.StatusSeeOther || !strings.HasPrefix(loc, stripe.URL+"/checkout/") { t.Fatalf("POST /subscribe/annual: %d to %q\n%s", status, loc, body) } status, body = reader.get(stripe.Pay(t, loc, "")) want(t, "Checkout's return while the payment processes", status, http.StatusOK, body, say("abono.pages.done_processing"), `href="/account"`) for _, wrong := range []string{say("abono.pages.done_unpaid_title"), say("abono.pages.done_unpaid_body"), say("abono.button.try_again")} { if strings.Contains(body, wrong) { t.Errorf("the processing page says %s", wrong) } } } // TestBillingStaysAfterSwitchingToAnUnknownPrice is final review I5 // (ruling R37): a reader switches, in Stripe's portal, to a price no plan // has. They hold nothing that grants, but they are still charged, so the // account page still has the billing button, for that subscription's // customer, and it opens the portal. A customer that is not theirs does // not. func TestBillingStaysAfterSwitchingToAnUnknownPrice(t *testing.T) { stripe := abonotest.New(t) stripe.AddPrice("price_annual", "annual", 6000, "eur", "year") stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month") stripe.AddPrice("price_foreign", "", 900, "eur", "month") app := newApp(t, func(c *Config) { c.Abono = stripe.Config }) stripe.SetWebhookURL(app.URL + abono.WebhookPath) reader := newClient(t, app) reader.signin("reader@example.com") status, loc, body := reader.post("/subscribe/annual", nil) if status != http.StatusSeeOther { t.Fatalf("POST /subscribe/annual: %d to %q\n%s", status, loc, body) } reader.get(stripe.Pay(t, loc, "")) now := stripe.Now() stripe.AddSubscription(abonotest.Subscription{ID: "sub_1", Customer: "cus_1", Email: "reader@example.com", Status: "active", Price: "price_foreign", PeriodStart: now, PeriodEnd: now.Add(30 * 24 * time.Hour)}) stripe.SendEvent("customer.subscription.updated", "sub_1") status, body = reader.get("/account") want(t, "the account on an unknown price", status, http.StatusOK, body, say("abono.pages.account_none"), `action="/account/billing"`, `name="customer" value="cus_1"`) st, loc, _ := reader.post("/account/billing", url.Values{"customer": {"cus_1"}}) if st != http.StatusSeeOther || !strings.HasPrefix(loc, stripe.URL+"/portal/cus_1") { t.Errorf("billing for cus_1: %d to %q", st, loc) } st, loc, _ = reader.post("/account/billing", url.Values{"customer": {"cus_2"}}) if st != http.StatusSeeOther || loc != "/account?billing=none" { t.Errorf("billing for a customer not the reader's: %d to %q", st, loc) } } // TestUnsupportedPriceIsShownNotSold is final review I7 (ruling R39) on // the generated pages: a sold price billed every twelve months is listed // for the owner, marked unsupported, and is not on the pricing page. func TestUnsupportedPriceIsShownNotSold(t *testing.T) { var d *db.DB app := newApp(t, func(c *Config) { d = c.DB }) // The sandbox read price_monthly at start-up as every month; make it // every twelve, as Stripe would report such a price. if err := d.G.Exec(`UPDATE abono_prices SET interval_count = 12 WHERE stripe_price_id = 'price_monthly'`).Error; err != nil { t.Fatal(err) } reader := newClient(t, app) status, body := reader.get("/subscribe") want(t, "the plans", status, http.StatusOK, body, `action="/subscribe/annual"`) if strings.Contains(body, `action="/subscribe/monthly"`) { t.Error("the pricing page sells a price billed every twelve months") } boss := newClient(t, app) boss.signin(owner) status, body = boss.get("/admin/offers") want(t, "the offers page", status, http.StatusOK, body, "price_monthly", say("abono.owner.price_unsupported")) if strings.Contains(body, say("abono.price.per_month")) { t.Error("the offers page calls a price billed every twelve months monthly") } if cards := abonoCards([]abono.Offer{{Plan: "dozen", Prices: []abono.Price{{ID: "p", Sold: true, Interval: "month", IntervalCount: 12, Currency: "eur"}}}}); len(cards) != 0 { t.Errorf("abonoCards made %d cards for an unsupported price", len(cards)) } } // TestStripePageShowsTheWebhookAddress is final review I9 (ruling R40), // with the shipped English rather than the test catalog: the approved // label "Webhook address" has no {url} in it, so the address itself is // shown beside it, or the instruction under it points at nothing. func TestStripePageShowsTheWebhookAddress(t *testing.T) { stripe := abonotest.New(t) stripe.AddPrice("price_annual", "annual", 6000, "eur", "year") stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month") app := newApp(t, func(c *Config) { c.Abono = stripe.Config c.Catalog = nil // abono.Catalog(), the approved English }) boss := newClient(t, app) boss.signin(owner) status, body := boss.get("/admin/stripe") approved := abono.Catalog() want(t, "the Stripe page", status, http.StatusOK, body, html.EscapeString(approved["abono.owner.stripe_test"]), html.EscapeString(approved["abono.owner.stripe_webhook"]), ""+app.URL+abono.WebhookPath+"", html.EscapeString(approved["abono.owner.stripe_webhook_help"])) } // TestOwnerAddsAndPromotesPrices is final review I10 (ruling R41): from // the offers page the owner adds an existing Stripe price to a plan, sold // or legacy, and makes a legacy price the sold one again. A new sold price // replaces the plan's sold price at its interval (ruling R38). func TestOwnerAddsAndPromotesPrices(t *testing.T) { app := newApp(t) boss := newClient(t, app) boss.signin(owner) reader := newClient(t, app) reader.signin("reader@example.com") status, body := boss.get("/admin/offers") want(t, "the offers page", status, http.StatusOK, body, say("abono.owner.add_price_title"), `action="/admin/offers/prices"`, say("abono.owner.add_price_id"), `name="price"`, `name="sold"`, say("abono.owner.price_sold"), say("abono.button.add_price")) if st, _, _ := reader.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_monthly_new"}, "sold": {"1"}}); st != http.StatusForbidden { t.Errorf("a reader adding a price: %d, want 403", st) } // A new monthly price on sale: the old one becomes legacy. st, loc, body := boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_monthly_new"}, "sold": {"1"}}) if st != http.StatusSeeOther || loc != "/admin/offers" { t.Fatalf("add a sold price: %d to %q\n%s", st, loc, body) } status, body = boss.get("/admin/offers") want(t, "after adding", status, http.StatusOK, body, "price_monthly_new", `data-abono-make-sold="price_monthly"`) if strings.Contains(body, `data-abono-make-sold="price_monthly_new"`) { t.Error("the price on sale offers to be put on sale") } if st, body := reader.get("/subscribe"); st != http.StatusOK || !strings.Contains(body, `action="/subscribe/monthly"`) { t.Errorf("monthly is not for sale after the replacement: %d\n%s", st, body) } // The old one back on sale, from its own button. st, loc, _ = boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_monthly"}, "sold": {"1"}}) if st != http.StatusSeeOther || loc != "/admin/offers" { t.Fatalf("make price_monthly the sold one: %d to %q", st, loc) } status, body = boss.get("/admin/offers") want(t, "after promoting", status, http.StatusOK, body, `data-abono-make-sold="price_monthly_new"`, say("abono.button.make_sold")) // A legacy price: listed, never sold. st, loc, _ = boss.post("/admin/offers/prices", url.Values{"plan": {"annual"}, "price": {"price_annual_2019"}}) if st != http.StatusSeeOther || loc != "/admin/offers" { t.Fatalf("add a legacy price: %d to %q", st, loc) } status, body = boss.get("/admin/offers") want(t, "after a legacy price", status, http.StatusOK, body, "price_annual_2019", `data-abono-make-sold="price_annual_2019"`) // What cannot be done says so, on the page. st, _, body = boss.post("/admin/offers/prices", url.Values{"plan": {"gold"}, "price": {"price_x"}, "sold": {"1"}}) want(t, "a price for an unknown plan", st, http.StatusUnprocessableEntity, body, say("abono.error.unknown_plan"), `value="price_x"`) st, _, body = boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {"price_annual"}, "sold": {"1"}}) want(t, "another plan's price", st, http.StatusUnprocessableEntity, body, say("abono.error.add_price_failed")) st, _, body = boss.post("/admin/offers/prices", url.Values{"plan": {"monthly"}, "price": {" "}}) want(t, "no price", st, http.StatusUnprocessableEntity, body, say("abono.error.add_price_failed")) } // TestSyncSaysWhenItOnlyPartlyWorked is final review M3 (ruling R43): // Reconcile keeps what it could sync when another subscription fails, so // "nothing was changed" is only true when nothing was. Stripe, through a // proxy, refuses to give out one subscription, then everything. func TestSyncSaysWhenItOnlyPartlyWorked(t *testing.T) { stripe := abonotest.New(t) stripe.AddPrice("price_annual", "annual", 6000, "eur", "year") stripe.AddPrice("price_monthly", "monthly", 600, "eur", "month") target, err := url.Parse(stripe.URL) if err != nil { t.Fatal(err) } var refuse atomic.Value // "", "one" or "all" refuse.Store("") proxy := httputil.NewSingleHostReverseProxy(target) front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch mode := refuse.Load().(string); { case mode == "one" && r.URL.Path == "/v1/subscriptions/sub_bad", mode == "all" && strings.HasPrefix(r.URL.Path, "/v1/subscriptions"): w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":{"type":"api_error","message":"down"}}`)) return } proxy.ServeHTTP(w, r) })) t.Cleanup(front.Close) app := newApp(t, func(c *Config) { c.Abono = func(ac abono.Config) abono.Config { ac = stripe.Config(ac) ac.StripeBase = front.URL return ac } }) now := stripe.Now() for _, id := range []string{"sub_good", "sub_bad"} { stripe.AddSubscription(abonotest.Subscription{ID: id, Status: "active", Price: "price_monthly", Email: id + "@example.com", PeriodStart: now, PeriodEnd: now.Add(30 * 24 * time.Hour)}) } boss := newClient(t, app) boss.signin(owner) refuse.Store("one") st, _, body := boss.post("/admin/members/sync", nil) want(t, "a sync that partly worked", st, http.StatusBadGateway, body, say("abono.error.sync_partial"), "sub_good@example.com") if strings.Contains(body, say("abono.error.sync_failed")) { t.Error("a sync that kept sub_good says nothing was changed") } refuse.Store("all") st, _, body = boss.post("/admin/members/sync", nil) want(t, "a sync that could not reach Stripe", st, http.StatusBadGateway, body, say("abono.error.sync_failed")) }