| 1 | package abono |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | const 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. |
| 25 | const 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). |
| 32 | func (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. |
| 54 | func 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. |
| 63 | func (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. |
| 80 | type inProcess struct{ h http.Handler } |
| 81 | |
| 82 | func (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. |
| 92 | func 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 | |