package abono import ( "context" "crypto/rand" "encoding/hex" "fmt" "net/http" "net/http/httptest" "strings" "amadan.net/rastrillo/abono/internal/fakestripe" "amadan.net/rastrillo/abono/internal/stripeapi" ) // SandboxPath is where an app mounts SandboxHandler. The sandbox's // stand-in Checkout and Portal pages are under it, on the app's own // origin, so the app's csrf.Protect passes their forms like any other: // it exempts nothing here. const SandboxPath = "/abono/sandbox/" // sandboxAPI is the address the sandbox's Stripe client calls. Nothing // leaves the process: the client's transport hands each request to the // fake directly. const sandboxAPI = "http://abono-sandbox.invalid" // startSandbox runs a fake Stripe inside the service: Checkout and Portal // return its pages under SandboxPath, its events go straight to receive // with no HTTP and no signature, and it answers any price id, guessing the // interval from the id (price_annual is yearly, anything unmarked // monthly). func (s *Service) startSandbox() { f := fakestripe.New(fakestripe.Options{ Now: s.now, IDTag: runTag(), PageBase: strings.TrimRight(s.cfg.Origin, "/") + strings.TrimSuffix(SandboxPath, "/"), Lookup: text, AnyPrice: true, Deliver: func(ctx context.Context, body []byte) error { if status, msg := s.receive(ctx, body); status != http.StatusOK { return fmt.Errorf("abono: sandbox event: %d %s", status, msg) } return nil }, }) s.sandbox = f s.stripe = &stripeapi.Client{Key: "sk_sandbox", Base: sandboxAPI, HTTP: &http.Client{Transport: inProcess{f.Handler()}}} s.cfg.Logger.Info("abono: sandbox on: payments are pretend", "pages", strings.TrimRight(s.cfg.Origin, "/")+SandboxPath) } // runTag is a short tag unique to this run, for the sandbox's ids: its // fake forgets everything when the app restarts, and the database does // not. func runTag() string { b := make([]byte, 4) _, _ = rand.Read(b) return hex.EncodeToString(b) } // SandboxHandler serves the sandbox's pages. The app mounts it at // SandboxPath, behind its usual csrf.Protect. Without Config.Sandbox it // answers 404 to everything, so an app may mount it unconditionally. func (s *Service) SandboxHandler() http.Handler { if s == nil || s.sandbox == nil { return http.NotFoundHandler() } pages := http.StripPrefix(strings.TrimSuffix(SandboxPath, "/"), s.sandbox.Handler()) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // The pages only: the fake's API is the service's, in-process. if !strings.HasPrefix(r.URL.Path, SandboxPath) || strings.HasPrefix(r.URL.Path, SandboxPath+"v1/") { http.NotFound(w, r) return } pages.ServeHTTP(w, r) }) } // inProcess is a transport that serves each request with h, in the // caller's goroutine. type inProcess struct{ h http.Handler } func (t inProcess) RoundTrip(req *http.Request) (*http.Response, error) { w := httptest.NewRecorder() t.h.ServeHTTP(w, req) return w.Result(), nil } // text is the sandbox pages' string for key from abono's shipped English // (locales/en.toml, the approved copy), its {name} placeholders filled // from alternating name/value args; the key itself when the catalog has // none, as a key missing from rastrillo's catalogs reads. func text(key string, args ...any) string { s, ok := english[key] if !ok { return key } for i := 0; i+1 < len(args); i += 2 { s = strings.ReplaceAll(s, "{"+fmt.Sprint(args[i])+"}", fmt.Sprint(args[i+1])) } return s }