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:
Claude 2026-05-18 11:35:39 +02:00
parent e5c2797ce6
commit 0b13b040e7
4 changed files with 904 additions and 4 deletions

View File

@ -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

View File

@ -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",

View 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)
}

View File

@ -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)
}
}