mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-07 10:46:57 +00:00
181 lines
5.8 KiB
Go
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)
|
|
}
|