rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1package abono
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11
12 "amadan.net/rastrillo/abono/internal/fakestripe"
13 "amadan.net/rastrillo/abono/internal/stripeapi"
14)
15
16// SandboxPath is where an app mounts SandboxHandler. The sandbox's
17// stand-in Checkout and Portal pages are under it, on the app's own
18// origin, so the app's csrf.Protect passes their forms like any other:
19// it exempts nothing here.
20const SandboxPath = "/abono/sandbox/"
21
22// sandboxAPI is the address the sandbox's Stripe client calls. Nothing
23// leaves the process: the client's transport hands each request to the
24// fake directly.
25const sandboxAPI = "http://abono-sandbox.invalid"
26
27// startSandbox runs a fake Stripe inside the service: Checkout and Portal
28// return its pages under SandboxPath, its events go straight to receive
29// with no HTTP and no signature, and it answers any price id, guessing the
30// interval from the id (price_annual is yearly, anything unmarked
31// monthly).
32func (s *Service) startSandbox() {
33 f := fakestripe.New(fakestripe.Options{
34 Now: s.now,
35 IDTag: runTag(),
36 PageBase: strings.TrimRight(s.cfg.Origin, "/") + strings.TrimSuffix(SandboxPath, "/"),
37 Lookup: text,
38 AnyPrice: true,
39 Deliver: func(ctx context.Context, body []byte) error {
40 if status, msg := s.receive(ctx, body); status != http.StatusOK {
41 return fmt.Errorf("abono: sandbox event: %d %s", status, msg)
42 }
43 return nil
44 },
45 })
46 s.sandbox = f
47 s.stripe = &stripeapi.Client{Key: "sk_sandbox", Base: sandboxAPI, HTTP: &http.Client{Transport: inProcess{f.Handler()}}}
48 s.cfg.Logger.Info("abono: sandbox on: payments are pretend", "pages", strings.TrimRight(s.cfg.Origin, "/")+SandboxPath)
49}
50
51// runTag is a short tag unique to this run, for the sandbox's ids: its
52// fake forgets everything when the app restarts, and the database does
53// not.
54func runTag() string {
55 b := make([]byte, 4)
56 _, _ = rand.Read(b)
57 return hex.EncodeToString(b)
58}
59
60// SandboxHandler serves the sandbox's pages. The app mounts it at
61// SandboxPath, behind its usual csrf.Protect. Without Config.Sandbox it
62// answers 404 to everything, so an app may mount it unconditionally.
63func (s *Service) SandboxHandler() http.Handler {
64 if s == nil || s.sandbox == nil {
65 return http.NotFoundHandler()
66 }
67 pages := http.StripPrefix(strings.TrimSuffix(SandboxPath, "/"), s.sandbox.Handler())
68 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69 // The pages only: the fake's API is the service's, in-process.
70 if !strings.HasPrefix(r.URL.Path, SandboxPath) || strings.HasPrefix(r.URL.Path, SandboxPath+"v1/") {
71 http.NotFound(w, r)
72 return
73 }
74 pages.ServeHTTP(w, r)
75 })
76}
77
78// inProcess is a transport that serves each request with h, in the
79// caller's goroutine.
80type inProcess struct{ h http.Handler }
81
82func (t inProcess) RoundTrip(req *http.Request) (*http.Response, error) {
83 w := httptest.NewRecorder()
84 t.h.ServeHTTP(w, req)
85 return w.Result(), nil
86}
87
88// text is the sandbox pages' string for key from abono's shipped English
89// (locales/en.toml, the approved copy), its {name} placeholders filled
90// from alternating name/value args; the key itself when the catalog has
91// none, as a key missing from rastrillo's catalogs reads.
92func text(key string, args ...any) string {
93 s, ok := english[key]
94 if !ok {
95 return key
96 }
97 for i := 0; i+1 < len(args); i += 2 {
98 s = strings.ReplaceAll(s, "{"+fmt.Sprint(args[i])+"}", fmt.Sprint(args[i+1]))
99 }
100 return s
101}
102