Files
at-container-registry/test/stripe-integration/env.go
T
Evan JarrettandClaude Opus 5 2d30f6abb7 test/stripe-integration: give the suite a real database
The suite built its Manager with a nil database, which switches off every
`m.db != nil` branch in HandleWebhook: the idempotency check, the per-customer
ordering guard, and the processed-event record. It ran against real Stripe and
exercised none of the code those guards live in, so it read as far broader
coverage than it was.

It now opens a libsql file under t.TempDir (":memory:" is per-connection, so
the pool's second connection would see no tables), and two new tests cover the
branches that were dead: a redelivery is skipped, and a stale out-of-order
event is ignored. Both assert that the second delivery did not reach a handler
rather than counting rows -- RecordStripeEvent is an idempotent upsert, so
deleting either guard leaves the table identical. Both were mutation-verified
against the guard they cover.

Two fixes fall out of turning the database on:

buildEventPayload never set `created`, which unmarshals as 0. Harmless with no
database; with one, the ordering guard reads every later event for a customer
as older than what it already applied, so the second event silently becomes a
no-op. It is stamped now, with buildEventPayloadAt for the ordering test.

TestHandleWebhookAllSubscribedEvents was failing on this branch and nothing
caught it, because stripe-integration-test is not part of `make test`. It
posted events for the literal "cus_fake" and expected "No user DID found" --
which was true only while a failed customer.Get collapsed to an empty DID.
Since that became a retryable error, the fixture reached the error branch
instead. It now uses a real sandbox customer carrying no user_did, so the
assertion tests the branch it names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB
2026-08-25 16:34:26 -05:00

204 lines
6.7 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)
}
}
})
}
// newBareCustomer creates a real sandbox customer carrying no user_did
// metadata, and returns its ID.
//
// This exists because "customer.Get fails" and "customer has no user_did" are
// different conclusions that getCustomerDID used to collapse into the same
// empty string. Now that a failed lookup is an error (and retryable), a test
// that wants the no-DID branch has to name a customer that actually resolves.
// A literal "cus_fake" reaches the error branch instead and no longer proves
// anything about dispatch.
func newBareCustomer(t *testing.T) string {
t.Helper()
cust, err := customer.New(&stripe.CustomerParams{})
if err != nil {
t.Fatalf("create bare customer: %v", err)
}
t.Cleanup(func() {
if _, err := customer.Del(cust.ID, nil); err != nil {
t.Logf("cleanup: delete customer %s: %v", cust.ID, err)
}
})
return cust.ID
}
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)
}