Files
at-container-registry/test/stripe-integration/env.go
T
2026-05-18 22:10:20 -05:00

181 lines
5.8 KiB
Go

//go:build billing && stripe_integration
// Package stripeintegration holds the Stripe sandbox-backed integration test
// suite. Everything here is gated behind both the `billing` and
// `stripe_integration` build tags so it never compiles into production
// binaries or default `go test ./...` runs in CI.
//
// To run locally:
//
// source ../../../atcr-secrets.env
// export STRIPE_TEST_PRICE_MONTHLY=price_...
// export STRIPE_TEST_PRICE_YEARLY=price_...
// make stripe-integration-test
package stripeintegration
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"testing"
"github.com/stripe/stripe-go/v84"
"github.com/stripe/stripe-go/v84/customer"
)
// stripeEnv holds the resolved sandbox configuration for a test run.
type stripeEnv struct {
SecretKey string // sk_test_... (live keys are rejected)
WebhookSecret string // whsec_...
PriceMonthly string // recurring price ID with interval=month
PriceYearly string // recurring price ID with interval=year
TierName string // logical name for the tier holding PriceMonthly/PriceYearly
}
// requireStripeEnv returns the resolved Stripe sandbox configuration. If any
// required variable is missing or obviously wrong (e.g. a live key in a test
// context), it calls t.Fatalf with the full list — opting into the
// `stripe_integration` build tag is the contract for committing to set these,
// so a hard failure (rather than t.Skip) is intentional.
func requireStripeEnv(t *testing.T) stripeEnv {
t.Helper()
env := stripeEnv{
SecretKey: os.Getenv("STRIPE_SECRET_KEY"),
WebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
PriceMonthly: os.Getenv("STRIPE_TEST_PRICE_MONTHLY"),
PriceYearly: os.Getenv("STRIPE_TEST_PRICE_YEARLY"),
TierName: getenvDefault("STRIPE_TEST_TIER_NAME", "Supporter"),
}
var missing []string
if env.SecretKey == "" {
missing = append(missing, "STRIPE_SECRET_KEY (sk_test_...)")
}
if env.WebhookSecret == "" {
missing = append(missing, "STRIPE_WEBHOOK_SECRET (whsec_...)")
}
if env.PriceMonthly == "" {
missing = append(missing, "STRIPE_TEST_PRICE_MONTHLY (recurring price ID, interval=month)")
}
if env.PriceYearly == "" {
missing = append(missing, "STRIPE_TEST_PRICE_YEARLY (recurring price ID, interval=year)")
}
if len(missing) > 0 {
t.Fatalf("Stripe integration tests need the following env vars:\n - %s\n\n"+
"Set them (e.g. via ../../atcr-secrets.env) and re-run with `make stripe-integration-test`.",
strings.Join(missing, "\n - "))
}
if !strings.HasPrefix(env.SecretKey, "sk_test_") {
t.Fatalf("STRIPE_SECRET_KEY must be a sandbox key (sk_test_...); refusing to run against a live account.")
}
if !strings.HasPrefix(env.WebhookSecret, "whsec_") {
t.Fatalf("STRIPE_WEBHOOK_SECRET does not look like a Stripe signing secret (expected whsec_...).")
}
return env
}
// uniqueDID generates a one-off DID per test so customer lookups can't
// collide with parallel runs or stale sandbox state. The DID is just a
// metadata string for Stripe — no PDS resolution happens here.
func uniqueDID(t *testing.T) string {
t.Helper()
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
t.Fatalf("random DID: %v", err)
}
return "did:web:stripe-test." + hex.EncodeToString(b[:]) + ".local"
}
// cleanupCustomerByDID schedules a t.Cleanup that searches Stripe for any
// customer whose metadata.user_did matches and deletes it. Several entry
// points (CreateCheckoutSession, GetSubscriptionInfo) create customers as
// side effects without returning the ID, so we re-search at teardown rather
// than tracking IDs through every code path. The search index lags writes by
// minutes but cleanup runs at the end of the test, by which point the index
// has usually caught up. Stragglers can be removed with the periodic sandbox
// cleanup script.
func cleanupCustomerByDID(t *testing.T, userDID string) {
t.Helper()
t.Cleanup(func() {
params := &stripe.CustomerSearchParams{
SearchParams: stripe.SearchParams{
Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID),
},
}
iter := customer.Search(params)
for iter.Next() {
id := iter.Customer().ID
if _, err := customer.Del(id, nil); err != nil {
t.Logf("cleanup: failed to delete customer %s: %v", id, err)
}
}
})
}
func getenvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// logCapture installs a slog handler that records every log line into an
// in-memory buffer, and registers a t.Cleanup to restore the previous
// default. Tests use this to verify which branch of HandleWebhook's switch
// fired — each branch has a distinctive log signature, and the default
// "Ignoring Stripe event" branch logs at DEBUG, so capturing at DEBUG level
// distinguishes "dispatched to handler" from "dropped to default".
//
// slog.SetDefault is global, so callers must not t.Parallel.
type logCapture struct {
mu sync.Mutex
buf bytes.Buffer
}
func newLogCapture(t *testing.T) *logCapture {
t.Helper()
c := &logCapture{}
handler := slog.NewTextHandler(&lockedWriter{c: c}, &slog.HandlerOptions{Level: slog.LevelDebug})
prev := slog.Default()
slog.SetDefault(slog.New(handler))
t.Cleanup(func() { slog.SetDefault(prev) })
return c
}
// reset discards captured output so the next assertion starts clean.
func (c *logCapture) reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.buf.Reset()
}
// contains reports whether the captured output contains s.
func (c *logCapture) contains(s string) bool {
c.mu.Lock()
defer c.mu.Unlock()
return strings.Contains(c.buf.String(), s)
}
// String returns the full captured output, for use in failure messages.
func (c *logCapture) String() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.buf.String()
}
type lockedWriter struct{ c *logCapture }
func (w *lockedWriter) Write(p []byte) (int, error) {
w.c.mu.Lock()
defer w.c.mu.Unlock()
return w.c.buf.Write(p)
}