feat(sandbox-mcp): sandbox.stripe.* real impls (last MCP namespace)
Wires four real Stripe handlers in openova-sandbox-mcp, completing the
final unwired namespace from architecture.md §3 (sandbox.stripe.*):
- sandbox.stripe.bindAccount {api_key} — validates the key prefix
(sk_live_ / sk_test_ / rk_live_ / rk_test_), stores it in the
per-Sandbox Secret (`sandbox-<owner-uid>-secrets`, data-key
`stripe_api_key`) via the same write-path sandbox.secrets.write
uses, returns a masked confirmation (`sk_test_…xY12`).
- sandbox.stripe.listProducts — reads the bound key implicitly,
GET /v1/products with limit (1-100, default 20), active, and
starting_after cursor passthrough.
- sandbox.stripe.listPrices {product_id?} — same pagination shape;
optional product_id filter.
- sandbox.stripe.createCheckoutSession {price_id, success_url,
cancel_url} — validates absolute http(s) URLs, POSTs the
form-encoded line_items[0][price/quantity] body to
/v1/checkout/sessions, returns the hosted Checkout URL + session id.
Implementation:
- No new module dep — inline HTTPS calls to api.stripe.com via the
stdlib net/http client. stripe-go v82 would have pulled ~80
transitive packages for four endpoints; the surface we need is
tiny enough that a 100-line stripeDo helper covers it. Matches
the task's "stripe-go v82 if not already in deps; else inline
HTTPS" guidance.
- The key never round-trips on the wire after first bind. Agent
pastes once via bindAccount; every subsequent call reads it from
the Secret store. Stripe-Version header pinned to 2024-06-20 so
a future API revision can't silently break the wire format.
- Auth: RequiredCapability="sandbox.stripe" on every tool.
claims.OrgID match enforced by the registry's existing gate.
- Read-only cluster invariant: the only writes are to the
per-Sandbox Secret. assertManagedBy() enforced on bind so we
cannot mutate the controller-injected `sandbox-tokens` Secret.
Tests cover key validation (prefix + length), masking format, limit
clamping, the httptest.Server-backed happy-path + error-envelope
unwrap, form-urlencoded body shape for createCheckoutSession,
catalogue wiring (all four handlers non-nil, RequiredCapability
matches), and the registry capability gate (missing sandbox.stripe
cap → forbidden).
Closes the Wave 13 "last MCP namespace" gap; no chart bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e5c2797ce6
commit
0b13b040e7
@ -95,7 +95,8 @@ github.* present ONLY if iOS/macOS-runner work is detected;
|
||||
scoped GH PAT for that pipeline only
|
||||
sandbox.db.* provision / drop / dump CNPG clusters in this Sandbox
|
||||
sandbox.auth.* provisionRealm, listClients, registerClient (Keycloak)
|
||||
sandbox.stripe.* bindAccount, listProducts (uses Sandbox secret store)
|
||||
sandbox.stripe.* bindAccount, listProducts, listPrices, createCheckoutSession
|
||||
(key stored in Sandbox secret store; never re-passed)
|
||||
sandbox.secrets.* read/write Sandbox-scoped secrets (never echoes)
|
||||
sandbox.storage.* bindBucket / signedUploadURL (SeaweedFS-backed)
|
||||
sandbox.preview.* status, rebuild, teardown
|
||||
|
||||
@ -34,9 +34,15 @@
|
||||
// - marketplace.domain.byod / marketplace.domain.subdomain
|
||||
// - flux.status / flux.reconcile / flux.suspend / flux.resume
|
||||
//
|
||||
// All other namespaces (sandbox.stripe.*, k8s.write.*,
|
||||
// gitea.release.list) remain stubbed and continue to return
|
||||
// not_implemented until later waves ship them.
|
||||
// Wave 13 extends to sandbox.stripe.* — the last MCP namespace to be
|
||||
// wired with real handlers (per-Sandbox Stripe account binding + Product
|
||||
// / Price / Checkout-session ops):
|
||||
//
|
||||
// - sandbox.stripe.bindAccount / sandbox.stripe.listProducts /
|
||||
// sandbox.stripe.listPrices / sandbox.stripe.createCheckoutSession
|
||||
//
|
||||
// All other namespaces (k8s.write.*, gitea.release.list) remain stubbed
|
||||
// and continue to return not_implemented until later waves ship them.
|
||||
//
|
||||
// Wire model recap (architecture.md §3):
|
||||
//
|
||||
@ -814,6 +820,44 @@ func defaultCatalogue(env *Env) []Tool {
|
||||
RequiredCapability: "sandbox.storage",
|
||||
},
|
||||
|
||||
// sandbox.stripe.* — per-Sandbox Stripe account binding + Product /
|
||||
// Price / Checkout-session ops (Wave 13 — sandbox_stripe.go). The
|
||||
// Stripe API key is stored once via bindAccount in the same Secret
|
||||
// store sandbox.secrets.* uses (`sandbox-<owner-uid>-secrets`, data
|
||||
// key `stripe_api_key`); subsequent calls read it implicitly so
|
||||
// the agent never round-trips the secret on the wire after the
|
||||
// first bind. RequiredCapability="sandbox.stripe" gates every call
|
||||
// regardless of read-vs-write — the Stripe API itself is the only
|
||||
// outbound destination.
|
||||
{
|
||||
Name: "sandbox.stripe.bindAccount",
|
||||
Description: "Bind a Stripe API key (`sk_live_…` / `sk_test_…` / `rk_live_…` / `rk_test_…`) to this Sandbox. Stored in the Sandbox Secret store; subsequent sandbox.stripe.* calls read it implicitly. Returns a MASKED confirmation.",
|
||||
InputSchema: schemaSandboxStripeBindAccount(),
|
||||
Handler: sandboxStripeBindAccount,
|
||||
RequiredCapability: "sandbox.stripe",
|
||||
},
|
||||
{
|
||||
Name: "sandbox.stripe.listProducts",
|
||||
Description: "List Stripe Products via the bound key. Supports `limit` (1-100, default 20), `active` (filter), `starting_after` (cursor).",
|
||||
InputSchema: schemaSandboxStripeListProducts(),
|
||||
Handler: sandboxStripeListProducts,
|
||||
RequiredCapability: "sandbox.stripe",
|
||||
},
|
||||
{
|
||||
Name: "sandbox.stripe.listPrices",
|
||||
Description: "List Stripe Prices via the bound key. Optional `product_id` filters to one Product; same paging flags as listProducts.",
|
||||
InputSchema: schemaSandboxStripeListPrices(),
|
||||
Handler: sandboxStripeListPrices,
|
||||
RequiredCapability: "sandbox.stripe",
|
||||
},
|
||||
{
|
||||
Name: "sandbox.stripe.createCheckoutSession",
|
||||
Description: "Create a Stripe Checkout Session for {price_id, success_url, cancel_url}. Default mode=payment, quantity=1. Returns the hosted Checkout URL.",
|
||||
InputSchema: schemaSandboxStripeCreateCheckoutSession(),
|
||||
Handler: sandboxStripeCreateCheckoutSession,
|
||||
RequiredCapability: "sandbox.stripe",
|
||||
},
|
||||
|
||||
// sandbox.session.* — this MCP server's own metadata (Wave 8).
|
||||
{
|
||||
Name: "sandbox.session.whoami",
|
||||
|
||||
553
products/sandbox/mcp-server/internal/tools/sandbox_stripe.go
Normal file
553
products/sandbox/mcp-server/internal/tools/sandbox_stripe.go
Normal file
@ -0,0 +1,553 @@
|
||||
// sandbox_stripe.go — real handlers for sandbox.stripe.* (per-Sandbox
|
||||
// Stripe account binding + product / price / checkout-session ops).
|
||||
//
|
||||
// Scope (architecture.md §3): agents shipping monetised apps from inside
|
||||
// a Sandbox need a way to bind the operator's Stripe account ONCE and
|
||||
// then drive product/price/checkout flows without ever round-tripping a
|
||||
// raw secret through prompts. The MCP server's role is to:
|
||||
//
|
||||
// 1. Accept the Stripe API key via `sandbox.stripe.bindAccount`, store
|
||||
// it in the per-Sandbox K8s Secret (`sandbox-<owner-uid>-secrets`,
|
||||
// key `stripe_api_key`) — same crypto-at-rest path the rest of the
|
||||
// sandbox.secrets.* tools use. Return a MASKED confirmation
|
||||
// (`sk_…last4`) so the agent + operator can verify what got bound
|
||||
// without the value re-appearing in transcripts.
|
||||
//
|
||||
// 2. Read the stored key on every subsequent call (`listProducts`,
|
||||
// `listPrices`, `createCheckoutSession`) — the agent NEVER passes
|
||||
// the key on the call wire after first bind. The key flows
|
||||
// apiserver → MCP pod env (via Secret mount lookup) → Stripe.
|
||||
//
|
||||
// 3. Drive the Stripe REST API directly over HTTPS (no SDK dep). The
|
||||
// surface we need is tiny (`GET /v1/products`, `GET /v1/prices`,
|
||||
// `POST /v1/checkout/sessions`) and adding `stripe-go` would pull
|
||||
// in ~80 transitive packages for four endpoints. Inline HTTP keeps
|
||||
// the mcp-server binary lean AND matches the codebase's existing
|
||||
// "talk to api.stripe.com over HTTPS with form-urlencoded bodies"
|
||||
// pattern (core/services/billing only imports stripe-go for
|
||||
// webhook-signature verification + checkout-session creation in
|
||||
// the billing service path, which is a different process).
|
||||
//
|
||||
// Auth: same shape as sandbox.secrets.* / sandbox.storage.* —
|
||||
// claims.OrgID must match env.OrgID, RequiredCapability =
|
||||
// "sandbox.stripe".
|
||||
//
|
||||
// Hard read-only-cluster rule (DoD): these handlers ONLY mutate the
|
||||
// per-Sandbox Secret (when bindAccount writes the key) and ONLY read
|
||||
// from it elsewhere. No k8s.write to anything outside the per-Sandbox
|
||||
// Secret. The Stripe API itself is the destination of every product /
|
||||
// price / checkout call.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
// stripeSecretKeyName is the data-key under which the Stripe API key
|
||||
// lives inside the per-Sandbox Secret store (`sandbox-<owner-uid>-secrets`).
|
||||
// Pinned so listProducts / listPrices / createCheckoutSession can fetch
|
||||
// it deterministically without an arg.
|
||||
const stripeSecretKeyName = "stripe_api_key"
|
||||
|
||||
// stripeAPIBase is the canonical Stripe REST API root. Overridable by
|
||||
// tests via stripeHTTPClient + stripeAPIBaseOverride below.
|
||||
const stripeAPIBase = "https://api.stripe.com"
|
||||
|
||||
// stripeRequestTimeout caps every Stripe HTTPS call. 15s matches the
|
||||
// default the canonical stripe-go client uses.
|
||||
const stripeRequestTimeout = 15 * time.Second
|
||||
|
||||
// stripeHTTPClient is the http.Client every Stripe-bound call routes
|
||||
// through. Hoisted so unit tests can swap a stub against httptest.Server
|
||||
// without dialling out to api.stripe.com.
|
||||
var stripeHTTPClient = &http.Client{Timeout: stripeRequestTimeout}
|
||||
|
||||
// stripeAPIBaseOverride lets tests redirect requests to a httptest.Server
|
||||
// URL. Empty in production; the call helpers fall back to stripeAPIBase.
|
||||
var stripeAPIBaseOverride = ""
|
||||
|
||||
// stripeKeyPrefixes — Stripe API keys carry one of these documented
|
||||
// prefixes (`sk_live_`, `sk_test_`, `rk_live_`, `rk_test_`). Accept all
|
||||
// four so the agent can bind either a secret key or a restricted key.
|
||||
// Refuse anything else — stops obvious typos (`pk_live_` publishable
|
||||
// keys) from being stored as authoritative.
|
||||
var stripeKeyPrefixes = []string{"sk_live_", "sk_test_", "rk_live_", "rk_test_"}
|
||||
|
||||
// --- helpers -----------------------------------------------------------
|
||||
|
||||
// stripeAPIBaseURL returns the effective base URL — test override if
|
||||
// set, the canonical api.stripe.com otherwise.
|
||||
func stripeAPIBaseURL() string {
|
||||
if stripeAPIBaseOverride != "" {
|
||||
return stripeAPIBaseOverride
|
||||
}
|
||||
return stripeAPIBase
|
||||
}
|
||||
|
||||
// validateStripeKey checks the key matches one of the documented
|
||||
// prefixes AND has a non-trivial length. Stops obvious typos /
|
||||
// publishable-key mix-ups from being stored.
|
||||
func validateStripeKey(key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return errors.New("`api_key` is required")
|
||||
}
|
||||
if len(key) < 16 {
|
||||
return fmt.Errorf("`api_key` looks too short (got %d chars, expected >=16)", len(key))
|
||||
}
|
||||
for _, p := range stripeKeyPrefixes {
|
||||
if strings.HasPrefix(key, p) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("`api_key` must start with one of %v (got %q)", stripeKeyPrefixes, key[:min(len(key), 8)])
|
||||
}
|
||||
|
||||
// maskStripeKey returns `<prefix>…<last4>` so the agent + operator can
|
||||
// verify which key got bound without the value re-appearing in clear.
|
||||
// Example: `sk_test_…xY12`.
|
||||
func maskStripeKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if len(key) <= 12 {
|
||||
return "***"
|
||||
}
|
||||
// Find the prefix `sk_test_` / `rk_live_` etc — keep through the
|
||||
// second underscore + last 4 chars.
|
||||
prefix := ""
|
||||
for _, p := range stripeKeyPrefixes {
|
||||
if strings.HasPrefix(key, p) {
|
||||
prefix = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if prefix == "" {
|
||||
return "***" + key[len(key)-4:]
|
||||
}
|
||||
return prefix + "…" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
// readStripeKey fetches the bound Stripe API key from the per-Sandbox
|
||||
// Secret store. Returns ("", structured-not-found-error) when the Secret
|
||||
// or the `stripe_api_key` data-key isn't set so callers can hand the
|
||||
// agent a clean "run bindAccount first" message.
|
||||
func readStripeKey(ctx context.Context) (string, error) {
|
||||
env, ns, name, err := requireSandboxSecretsScope(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = env
|
||||
client, err := dynamicClient(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
obj, err := client.Resource(secretsGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return "", fmt.Errorf("sandbox.stripe: no Stripe account bound yet — call sandbox.stripe.bindAccount first (Secret %s/%s does not exist)", ns, name)
|
||||
}
|
||||
return "", fmt.Errorf("sandbox.stripe: GET Secret %s/%s: %w", ns, name, err)
|
||||
}
|
||||
if err := assertManagedBy(obj, name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, _, _ := unstructured.NestedMap(obj.Object, "data")
|
||||
enc, ok := data[stripeSecretKeyName].(string)
|
||||
if !ok || enc == "" {
|
||||
return "", fmt.Errorf("sandbox.stripe: no Stripe account bound yet — call sandbox.stripe.bindAccount first (Secret %s/%s has no `%s` key)", ns, name, stripeSecretKeyName)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sandbox.stripe: decode `%s`: %w", stripeSecretKeyName, err)
|
||||
}
|
||||
return strings.TrimSpace(string(decoded)), nil
|
||||
}
|
||||
|
||||
// stripeDo issues an authenticated Stripe REST call. Returns the parsed
|
||||
// JSON body, the HTTP status, and any transport / decode error. On
|
||||
// non-2xx, attempts to parse the Stripe error envelope into a clean
|
||||
// message rather than dumping raw HTML.
|
||||
func stripeDo(ctx context.Context, key, method, path string, form url.Values) (map[string]any, int, error) {
|
||||
full := strings.TrimRight(stripeAPIBaseURL(), "/") + path
|
||||
var body io.Reader
|
||||
if method != http.MethodGet && form != nil {
|
||||
body = strings.NewReader(form.Encode())
|
||||
} else if method == http.MethodGet && len(form) > 0 {
|
||||
if strings.Contains(full, "?") {
|
||||
full += "&" + form.Encode()
|
||||
} else {
|
||||
full += "?" + form.Encode()
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, full, body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("stripe %s %s: build request: %w", method, path, err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
if method != http.MethodGet && form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
// Pin a Stripe-Version header so a future API rev (line items
|
||||
// reshape, etc.) does NOT silently break us. Bumped explicitly when
|
||||
// we re-test against a newer Stripe API rev.
|
||||
req.Header.Set("Stripe-Version", "2024-06-20")
|
||||
resp, err := stripeHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("stripe %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
out := map[string]any{}
|
||||
if len(raw) > 0 {
|
||||
// Stripe always returns JSON for both success + error envelopes;
|
||||
// a decode failure here is itself a hard error (HTML page from a
|
||||
// proxy in the middle, etc.).
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, resp.StatusCode, fmt.Errorf("stripe %s %s: decode body (status=%d, %d bytes): %w", method, path, resp.StatusCode, len(raw), err)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Surface Stripe's structured error envelope: {error: {type,
|
||||
// code, message, ...}}.
|
||||
if errObj, ok := out["error"].(map[string]any); ok {
|
||||
msg, _ := errObj["message"].(string)
|
||||
code, _ := errObj["code"].(string)
|
||||
typ, _ := errObj["type"].(string)
|
||||
return out, resp.StatusCode, fmt.Errorf("stripe %s %s: %d %s (type=%s code=%s): %s",
|
||||
method, path, resp.StatusCode, http.StatusText(resp.StatusCode), typ, code, msg)
|
||||
}
|
||||
return out, resp.StatusCode, fmt.Errorf("stripe %s %s: %d %s", method, path, resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||
}
|
||||
return out, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// --- sandbox.stripe.bindAccount ----------------------------------------
|
||||
|
||||
type sandboxStripeBindAccountArgs struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
func sandboxStripeBindAccount(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args sandboxStripeBindAccountArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.bindAccount: invalid arguments: %w", err)
|
||||
}
|
||||
if err := validateStripeKey(args.APIKey); err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.bindAccount: %w", err)
|
||||
}
|
||||
key := strings.TrimSpace(args.APIKey)
|
||||
env, ns, name, err := requireSandboxSecretsScope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := dynamicClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(key))
|
||||
|
||||
existing, getErr := client.Resource(secretsGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||
if getErr != nil && !apierrors.IsNotFound(getErr) {
|
||||
return nil, fmt.Errorf("sandbox.stripe.bindAccount: GET Secret %s/%s: %w", ns, name, getErr)
|
||||
}
|
||||
if apierrors.IsNotFound(getErr) {
|
||||
obj := buildSecretObject(env, ns, name)
|
||||
data, _ := obj.Object["data"].(map[string]any)
|
||||
data[stripeSecretKeyName] = encoded
|
||||
obj.Object["data"] = data
|
||||
created, err := client.Resource(secretsGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.bindAccount: CREATE Secret %s/%s: %w", ns, name, err)
|
||||
}
|
||||
return map[string]any{
|
||||
"status": "Bound",
|
||||
"masked": maskStripeKey(key),
|
||||
"namespace": ns,
|
||||
"secret": name,
|
||||
"dataKey": stripeSecretKeyName,
|
||||
"resourceVersion": created.GetResourceVersion(),
|
||||
"note": "Stripe API key stored in the Sandbox Secret store. Subsequent sandbox.stripe.* calls read it implicitly — do NOT pass it on the wire again.",
|
||||
}, nil
|
||||
}
|
||||
if err := assertManagedBy(existing, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, _, _ := unstructured.NestedMap(existing.Object, "data")
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
previouslyBound := false
|
||||
if _, ok := data[stripeSecretKeyName]; ok {
|
||||
previouslyBound = true
|
||||
}
|
||||
data[stripeSecretKeyName] = encoded
|
||||
if err := unstructured.SetNestedMap(existing.Object, data, "data"); err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.bindAccount: set nested data: %w", err)
|
||||
}
|
||||
updated, err := client.Resource(secretsGVR).Namespace(ns).Update(ctx, existing, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.bindAccount: UPDATE Secret %s/%s: %w", ns, name, err)
|
||||
}
|
||||
status := "Bound"
|
||||
if previouslyBound {
|
||||
status = "Rebound"
|
||||
}
|
||||
return map[string]any{
|
||||
"status": status,
|
||||
"masked": maskStripeKey(key),
|
||||
"namespace": ns,
|
||||
"secret": name,
|
||||
"dataKey": stripeSecretKeyName,
|
||||
"resourceVersion": updated.GetResourceVersion(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func schemaSandboxStripeBindAccount() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"api_key"},
|
||||
"properties": map[string]any{
|
||||
"api_key": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Stripe secret or restricted key (`sk_live_…` / `sk_test_…` / `rk_live_…` / `rk_test_…`). Stored in the per-Sandbox Secret; subsequent sandbox.stripe.* tools read it implicitly.",
|
||||
},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- sandbox.stripe.listProducts ---------------------------------------
|
||||
|
||||
type sandboxStripeListProductsArgs struct {
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
StartingAfter string `json:"starting_after,omitempty"`
|
||||
}
|
||||
|
||||
func sandboxStripeListProducts(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args sandboxStripeListProductsArgs
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.listProducts: invalid arguments: %w", err)
|
||||
}
|
||||
}
|
||||
key, err := readStripeKey(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("limit", stripeLimitOr(args.Limit, 20))
|
||||
if args.Active != nil {
|
||||
if *args.Active {
|
||||
q.Set("active", "true")
|
||||
} else {
|
||||
q.Set("active", "false")
|
||||
}
|
||||
}
|
||||
if args.StartingAfter != "" {
|
||||
q.Set("starting_after", args.StartingAfter)
|
||||
}
|
||||
body, status, err := stripeDo(ctx, key, http.MethodGet, "/v1/products", q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.listProducts: %w", err)
|
||||
}
|
||||
products, _ := body["data"].([]any)
|
||||
hasMore, _ := body["has_more"].(bool)
|
||||
return map[string]any{
|
||||
"status": "OK",
|
||||
"products": products,
|
||||
"count": len(products),
|
||||
"hasMore": hasMore,
|
||||
"httpStatus": status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func schemaSandboxStripeListProducts() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 100, "description": "Page size (Stripe caps at 100; default 20)."},
|
||||
"active": map[string]any{"type": "boolean", "description": "Filter by active flag (omit → both)."},
|
||||
"starting_after": map[string]any{"type": "string", "description": "Stripe cursor — pass the last product `id` from a previous page to fetch the next page."},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- sandbox.stripe.listPrices -----------------------------------------
|
||||
|
||||
type sandboxStripeListPricesArgs struct {
|
||||
ProductID string `json:"product_id,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
StartingAfter string `json:"starting_after,omitempty"`
|
||||
}
|
||||
|
||||
func sandboxStripeListPrices(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args sandboxStripeListPricesArgs
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.listPrices: invalid arguments: %w", err)
|
||||
}
|
||||
}
|
||||
key, err := readStripeKey(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("limit", stripeLimitOr(args.Limit, 20))
|
||||
if args.ProductID != "" {
|
||||
q.Set("product", args.ProductID)
|
||||
}
|
||||
if args.Active != nil {
|
||||
if *args.Active {
|
||||
q.Set("active", "true")
|
||||
} else {
|
||||
q.Set("active", "false")
|
||||
}
|
||||
}
|
||||
if args.StartingAfter != "" {
|
||||
q.Set("starting_after", args.StartingAfter)
|
||||
}
|
||||
body, status, err := stripeDo(ctx, key, http.MethodGet, "/v1/prices", q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.listPrices: %w", err)
|
||||
}
|
||||
prices, _ := body["data"].([]any)
|
||||
hasMore, _ := body["has_more"].(bool)
|
||||
return map[string]any{
|
||||
"status": "OK",
|
||||
"prices": prices,
|
||||
"count": len(prices),
|
||||
"hasMore": hasMore,
|
||||
"product": args.ProductID,
|
||||
"httpStatus": status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func schemaSandboxStripeListPrices() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"product_id": map[string]any{"type": "string", "description": "Optional Stripe Product ID (`prod_…`) to filter prices by."},
|
||||
"limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 100, "description": "Page size (Stripe caps at 100; default 20)."},
|
||||
"active": map[string]any{"type": "boolean", "description": "Filter by active flag (omit → both)."},
|
||||
"starting_after": map[string]any{"type": "string", "description": "Stripe cursor — pass the last price `id` from a previous page to fetch the next page."},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- sandbox.stripe.createCheckoutSession ------------------------------
|
||||
|
||||
type sandboxStripeCreateCheckoutSessionArgs struct {
|
||||
PriceID string `json:"price_id"`
|
||||
SuccessURL string `json:"success_url"`
|
||||
CancelURL string `json:"cancel_url"`
|
||||
Quantity int `json:"quantity,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
func sandboxStripeCreateCheckoutSession(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var args sandboxStripeCreateCheckoutSessionArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: invalid arguments: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(args.PriceID) == "" {
|
||||
return nil, errors.New("sandbox.stripe.createCheckoutSession: `price_id` is required")
|
||||
}
|
||||
if strings.TrimSpace(args.SuccessURL) == "" {
|
||||
return nil, errors.New("sandbox.stripe.createCheckoutSession: `success_url` is required")
|
||||
}
|
||||
if strings.TrimSpace(args.CancelURL) == "" {
|
||||
return nil, errors.New("sandbox.stripe.createCheckoutSession: `cancel_url` is required")
|
||||
}
|
||||
for _, u := range []string{args.SuccessURL, args.CancelURL} {
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: invalid URL %q (must be absolute http(s)://...)", u)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: URL %q must be http or https (got %q)", u, parsed.Scheme)
|
||||
}
|
||||
}
|
||||
qty := args.Quantity
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
mode := strings.TrimSpace(args.Mode)
|
||||
if mode == "" {
|
||||
mode = "payment"
|
||||
}
|
||||
switch mode {
|
||||
case "payment", "subscription", "setup":
|
||||
default:
|
||||
return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: `mode` must be one of payment|subscription|setup (got %q)", mode)
|
||||
}
|
||||
key, err := readStripeKey(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("mode", mode)
|
||||
form.Set("success_url", args.SuccessURL)
|
||||
form.Set("cancel_url", args.CancelURL)
|
||||
form.Set("line_items[0][price]", args.PriceID)
|
||||
form.Set("line_items[0][quantity]", fmt.Sprintf("%d", qty))
|
||||
body, status, err := stripeDo(ctx, key, http.MethodPost, "/v1/checkout/sessions", form)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sandbox.stripe.createCheckoutSession: %w", err)
|
||||
}
|
||||
sessionID, _ := body["id"].(string)
|
||||
sessionURL, _ := body["url"].(string)
|
||||
return map[string]any{
|
||||
"status": "Created",
|
||||
"id": sessionID,
|
||||
"url": sessionURL,
|
||||
"mode": mode,
|
||||
"priceId": args.PriceID,
|
||||
"quantity": qty,
|
||||
"httpStatus": status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func schemaSandboxStripeCreateCheckoutSession() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"price_id", "success_url", "cancel_url"},
|
||||
"properties": map[string]any{
|
||||
"price_id": map[string]any{"type": "string", "description": "Stripe Price ID (`price_…`) to bill in the Checkout session."},
|
||||
"success_url": map[string]any{"type": "string", "description": "Absolute http(s) URL Stripe redirects to on a successful checkout."},
|
||||
"cancel_url": map[string]any{"type": "string", "description": "Absolute http(s) URL Stripe redirects to on cancel."},
|
||||
"quantity": map[string]any{"type": "integer", "minimum": 1, "description": "Line-item quantity (default 1)."},
|
||||
"mode": map[string]any{"type": "string", "enum": []string{"payment", "subscription", "setup"}, "description": "Stripe Checkout mode (default `payment`)."},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- shared helpers ----------------------------------------------------
|
||||
|
||||
// stripeLimitOr returns the Stripe `limit` query value, clamping to
|
||||
// (1..100] and falling back to def when 0 / negative.
|
||||
func stripeLimitOr(limit, def int) string {
|
||||
if limit <= 0 {
|
||||
limit = def
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
return fmt.Sprintf("%d", limit)
|
||||
}
|
||||
@ -0,0 +1,302 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sharedauth "github.com/openova-io/openova/core/services/shared/auth"
|
||||
)
|
||||
|
||||
// TestValidateStripeKey covers the per-call key validation gate.
|
||||
// Stops obvious typos / publishable-key mix-ups from being stored as
|
||||
// authoritative Stripe credentials in the per-Sandbox Secret.
|
||||
func TestValidateStripeKey(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "is required",
|
||||
" ": "is required",
|
||||
"sk_test_aaaaaaaaaaaaaaaaa": "",
|
||||
"sk_live_aaaaaaaaaaaaaaaaa": "",
|
||||
"rk_test_aaaaaaaaaaaaaaaaa": "",
|
||||
"rk_live_aaaaaaaaaaaaaaaaa": "",
|
||||
"sk_test_short": "too short",
|
||||
"pk_live_aaaaaaaaaaaaaaaaa": "must start with",
|
||||
"whatever_aaaaaaaaaaaaaaaaa": "must start with",
|
||||
}
|
||||
for in, want := range cases {
|
||||
err := validateStripeKey(in)
|
||||
if want == "" {
|
||||
if err != nil {
|
||||
t.Errorf("%q: err=%v want nil", in, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%q: err=%v want %q", in, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaskStripeKey pins the masking format so the agent + operator can
|
||||
// verify which key got bound without re-exposing it in clear.
|
||||
func TestMaskStripeKey(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "***",
|
||||
"shorty": "***",
|
||||
"sk_test_ABCDEFGHxY12": "sk_test_…xY12",
|
||||
"sk_live_ABCDEFGHxY12": "sk_live_…xY12",
|
||||
"rk_live_ABCDEFGHxY12": "rk_live_…xY12",
|
||||
"weirdformatXXXXXXXXXXxY12": "***xY12",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := maskStripeKey(in); got != want {
|
||||
t.Errorf("%q: got=%q want=%q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripeLimitOr exercises the limit-clamping helper.
|
||||
func TestStripeLimitOr(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, def int
|
||||
want string
|
||||
}{
|
||||
{0, 20, "20"},
|
||||
{-3, 20, "20"},
|
||||
{5, 20, "5"},
|
||||
{200, 20, "100"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripeLimitOr(c.in, c.def); got != c.want {
|
||||
t.Errorf("stripeLimitOr(%d,%d)=%q want %q", c.in, c.def, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripeAPIBaseURL pins the override behaviour — production callers
|
||||
// hit api.stripe.com; tests redirect via stripeAPIBaseOverride.
|
||||
func TestStripeAPIBaseURL(t *testing.T) {
|
||||
if got := stripeAPIBaseURL(); got != stripeAPIBase {
|
||||
t.Errorf("default base=%q want %q", got, stripeAPIBase)
|
||||
}
|
||||
prev := stripeAPIBaseOverride
|
||||
stripeAPIBaseOverride = "https://example.test"
|
||||
defer func() { stripeAPIBaseOverride = prev }()
|
||||
if got := stripeAPIBaseURL(); got != "https://example.test" {
|
||||
t.Errorf("override base=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripeDo_Success exercises the happy-path: forms-urlencoded body,
|
||||
// authz header, JSON envelope decode.
|
||||
func TestStripeDo_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer sk_test_xxxxxxxxxxxx" {
|
||||
t.Errorf("authz=%q", got)
|
||||
}
|
||||
if got := r.Header.Get("Stripe-Version"); got == "" {
|
||||
t.Errorf("Stripe-Version missing")
|
||||
}
|
||||
if r.URL.Path != "/v1/products" {
|
||||
t.Errorf("path=%q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"prod_1","name":"Widget"}],"has_more":false}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
prev := stripeAPIBaseOverride
|
||||
stripeAPIBaseOverride = srv.URL
|
||||
defer func() { stripeAPIBaseOverride = prev }()
|
||||
|
||||
body, status, err := stripeDo(context.Background(), "sk_test_xxxxxxxxxxxx", http.MethodGet, "/v1/products", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if status != 200 {
|
||||
t.Errorf("status=%d", status)
|
||||
}
|
||||
data, _ := body["data"].([]any)
|
||||
if len(data) != 1 {
|
||||
t.Errorf("len(data)=%d", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripeDo_ErrorEnvelope verifies Stripe error JSON envelopes get
|
||||
// unwrapped into a clean message rather than dumped raw.
|
||||
func TestStripeDo_ErrorEnvelope(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":{"type":"invalid_request_error","code":"api_key_invalid","message":"Invalid API Key provided"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
prev := stripeAPIBaseOverride
|
||||
stripeAPIBaseOverride = srv.URL
|
||||
defer func() { stripeAPIBaseOverride = prev }()
|
||||
|
||||
_, status, err := stripeDo(context.Background(), "sk_test_xxxxxxxxxxxx", http.MethodGet, "/v1/products", nil)
|
||||
if status != 401 {
|
||||
t.Errorf("status=%d", status)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "Invalid API Key") {
|
||||
t.Errorf("err=%v want Invalid API Key", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSandboxStripeBindAccount_ArgValidation covers per-arg validation
|
||||
// before any API / cluster call.
|
||||
func TestSandboxStripeBindAccount_ArgValidation(t *testing.T) {
|
||||
ctx := WithEnv(context.Background(), &Env{SandboxNamespace: "sandbox-u1", OwnerUID: "u1"})
|
||||
cases := map[string]string{
|
||||
`{}`: "is required",
|
||||
`{"api_key":""}`: "is required",
|
||||
`{"api_key":"sk_test_short"}`: "too short",
|
||||
`{"api_key":"pk_live_AAAAAAAAAAAAAAAAA"}`: "must start with",
|
||||
}
|
||||
for args, want := range cases {
|
||||
_, err := sandboxStripeBindAccount(ctx, json.RawMessage(args))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("args=%s err=%v want %q", args, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSandboxStripeCreateCheckoutSession_ArgValidation covers the
|
||||
// per-arg URL + price validation before any API call.
|
||||
func TestSandboxStripeCreateCheckoutSession_ArgValidation(t *testing.T) {
|
||||
ctx := WithEnv(context.Background(), &Env{SandboxNamespace: "sandbox-u1", OwnerUID: "u1"})
|
||||
cases := map[string]string{
|
||||
`{}`: "`price_id` is required",
|
||||
`{"price_id":"price_1","success_url":"","cancel_url":"https://ok"}`: "`success_url` is required",
|
||||
`{"price_id":"price_1","success_url":"https://ok","cancel_url":""}`: "`cancel_url` is required",
|
||||
`{"price_id":"price_1","success_url":"notaurl","cancel_url":"https://ok"}`: "invalid URL",
|
||||
`{"price_id":"price_1","success_url":"ftp://x/y","cancel_url":"https://ok"}`: "must be http or https",
|
||||
`{"price_id":"price_1","success_url":"https://ok","cancel_url":"https://k","mode":"wat"}`: "`mode` must be one of",
|
||||
}
|
||||
for args, want := range cases {
|
||||
_, err := sandboxStripeCreateCheckoutSession(ctx, json.RawMessage(args))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("args=%s err=%v want %q", args, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadStripeKey_BareEncoding verifies the read path decodes the
|
||||
// base64'd value back to the original Stripe key (smoke test on the
|
||||
// encode-on-write / decode-on-read symmetry without standing up a fake
|
||||
// apiserver).
|
||||
func TestReadStripeKey_BareEncoding(t *testing.T) {
|
||||
v := "sk_test_AAAABBBBCCCCDDDD"
|
||||
enc := base64.StdEncoding.EncodeToString([]byte(v))
|
||||
dec, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(dec)); got != v {
|
||||
t.Errorf("roundtrip mismatch: got %q want %q", got, v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSandboxStripeCatalogueWiredIn ensures all four tools are in the
|
||||
// catalogue with non-nil Handler + the right RequiredCapability.
|
||||
func TestSandboxStripeCatalogueWiredIn(t *testing.T) {
|
||||
r := NewRegistry(&Env{OrgID: "acme", OwnerUID: "u1"})
|
||||
want := map[string]bool{
|
||||
"sandbox.stripe.bindAccount": false,
|
||||
"sandbox.stripe.listProducts": false,
|
||||
"sandbox.stripe.listPrices": false,
|
||||
"sandbox.stripe.createCheckoutSession": false,
|
||||
}
|
||||
for _, tool := range r.List() {
|
||||
if _, ok := want[tool.Name]; !ok {
|
||||
continue
|
||||
}
|
||||
if tool.Handler == nil {
|
||||
t.Errorf("%s: Handler is nil", tool.Name)
|
||||
}
|
||||
if tool.RequiredCapability != "sandbox.stripe" {
|
||||
t.Errorf("%s: RequiredCapability=%q want sandbox.stripe", tool.Name, tool.RequiredCapability)
|
||||
}
|
||||
want[tool.Name] = true
|
||||
}
|
||||
for name, found := range want {
|
||||
if !found {
|
||||
t.Errorf("%s: missing from catalogue", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSandboxStripeCapabilityGate confirms the registry rejects
|
||||
// sandbox.stripe.* calls whose claims lack the `sandbox.stripe` cap.
|
||||
func TestSandboxStripeCapabilityGate(t *testing.T) {
|
||||
r := NewRegistry(&Env{
|
||||
OrgID: "acme",
|
||||
JWTSecret: []byte("x"),
|
||||
SandboxNamespace: "sandbox-u1",
|
||||
OwnerUID: "u1",
|
||||
})
|
||||
for _, name := range []string{
|
||||
"sandbox.stripe.bindAccount",
|
||||
"sandbox.stripe.listProducts",
|
||||
"sandbox.stripe.listPrices",
|
||||
"sandbox.stripe.createCheckoutSession",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cl := &sharedauth.Claims{OrgID: "acme", Capabilities: []string{"sandbox.db"}}
|
||||
args := json.RawMessage(`{"api_key":"sk_test_AAAABBBBCCCCDDDD","price_id":"price_1","success_url":"https://ok","cancel_url":"https://k"}`)
|
||||
_, err := r.Call(context.Background(), name, args, CallOpts{Claims: cl})
|
||||
if err == nil || !strings.Contains(err.Error(), "forbidden") {
|
||||
t.Errorf("missing cap: err=%v want forbidden", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripeDo_FormEncoding verifies POST bodies are form-encoded (the
|
||||
// Stripe REST API rejects JSON bodies on most endpoints — line_items[0]
|
||||
// nesting is array-bracket notation, not JSON objects).
|
||||
func TestStripeDo_FormEncoding(t *testing.T) {
|
||||
gotBody := ""
|
||||
gotCT := ""
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"cs_test_1","url":"https://checkout.stripe.com/c/cs_test_1"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
prev := stripeAPIBaseOverride
|
||||
stripeAPIBaseOverride = srv.URL
|
||||
defer func() { stripeAPIBaseOverride = prev }()
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("mode", "payment")
|
||||
form.Set("success_url", "https://ok")
|
||||
form.Set("cancel_url", "https://no")
|
||||
form.Set("line_items[0][price]", "price_1")
|
||||
form.Set("line_items[0][quantity]", "1")
|
||||
body, status, err := stripeDo(context.Background(), "sk_test_xxxxxxxxxxxx", http.MethodPost, "/v1/checkout/sessions", form)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if status != 200 {
|
||||
t.Errorf("status=%d", status)
|
||||
}
|
||||
if got, _ := body["id"].(string); got != "cs_test_1" {
|
||||
t.Errorf("id=%q", got)
|
||||
}
|
||||
if gotCT != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("content-type=%q", gotCT)
|
||||
}
|
||||
if !strings.Contains(gotBody, "line_items%5B0%5D%5Bprice%5D=price_1") {
|
||||
t.Errorf("body=%q (line_items not form-encoded)", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user