Files
at-container-registry/test/stripe-integration/manager_test.go
T
Evan JarrettandClaude Opus 5 12c55ed560 billing: make Stripe webhook delivery idempotent and retryable
Webhook delivery was neither idempotent nor order-safe, and every failure
returned 400, which Stripe does not retry. A transient DB or hold error
therefore dropped a subscription change silently and permanently.

  - New stripe_processed_events table: event_id as primary key dedups
    redelivery, and event_created per customer drops stale out-of-order
    deliveries.
  - HandleWebhook distinguishes ErrWebhookSignature (400, no retry) from
    ErrWebhookProcessing (500, Stripe redelivers). The event handlers
    return errors instead of swallowing them. ErrBillingDisabled maps to
    400: the route is mounted but billing is off, so redelivery can never
    succeed and Stripe should stop rather than retry to exhaustion.
  - Refuse to boot when billing is enabled with an empty
    STRIPE_WEBHOOK_SECRET. Stripe HMACs with the empty key, so an
    attacker can reproduce the signature and the endpoint is forgeable.
  - UpdateCrewTierOnAllHolds retries each hold (3 attempts, linear
    backoff, 5s per request) and returns a joined error so the webhook
    can fail and let Stripe redeliver.

The fan-out contacts holds concurrently rather than in sequence. Serially,
one unreachable hold burns the caller's entire 10s budget on its own
retries (3 x 5s plus backoff) and the holds after it are never contacted;
because Stripe redelivers in the same order, a persistently-down first
hold means the rest are never updated at all.

On the hold, the signature-validated sub claim is now the source of truth
for updateCrewTier: a mismatched body userDid is rejected with 403 rather
than retargeting the grant to another DID. "Not crew on this hold" is a
200 no-op, since the appview fans updates out to every managed hold and a
subscriber is not crew everywhere.

That no-op has to be told apart from a storage failure. GetCrewMember
collapsed both into one generic error, so a CAR-store failure read as
"not a member", answered 200, and let the appview record the event as
processed — losing the tier grant permanently, which is exactly the
failure mode this commit exists to prevent. Missing records now carry an
ErrCrewMemberNotFound sentinel, and anything else returns 500 so Stripe
redelivers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:14:58 -05:00

572 lines
21 KiB
Go

//go:build billing && stripe_integration
package stripeintegration
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
stripe "github.com/stripe/stripe-go/v84"
"github.com/stripe/stripe-go/v84/customer"
"github.com/stripe/stripe-go/v84/webhook"
"atcr.io/pkg/billing"
)
// newManager builds a billing.Manager bound to the sandbox account.
// managedHolds is intentionally empty: the webhook-driven
// UpdateCrewTierOnAllHolds call becomes a no-op, which is what we want for
// hermetic Manager-level tests. The hold-side wiring is exercised separately
// by the harness E2E test in this same package.
func newManager(t *testing.T, env stripeEnv) *billing.Manager {
t.Helper()
cfg := &billing.Config{
StripeSecretKey: env.SecretKey,
WebhookSecret: env.WebhookSecret,
Currency: "usd",
SuccessURL: "{base_url}/billing/success",
CancelURL: "{base_url}/billing/cancel",
Tiers: []billing.BillingTierConfig{
{Name: "free", Description: "Free tier", MaxWebhooks: 1},
{
Name: env.TierName,
Description: "Paid tier",
StripePriceMonthly: env.PriceMonthly,
StripePriceYearly: env.PriceYearly,
MaxWebhooks: 10,
WebhookAllTriggers: true,
SupporterBadge: true,
},
},
}
return billing.New(cfg, nil, "did:web:test-appview.local", nil, "http://test-appview.local", nil)
}
func TestManagerEnabled(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
if !m.Enabled() {
t.Fatalf("manager should be Enabled() with sandbox config + billing build tag")
}
}
// TestGetSubscriptionInfoNoCustomer covers the cold-cache path: a DID with
// no Stripe customer should resolve to the lowest-rank tier and still come
// back populated with live monthly/yearly prices fetched from Stripe.
func TestGetSubscriptionInfoNoCustomer(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
info, err := m.GetSubscriptionInfo(uniqueDID(t))
if err != nil {
t.Fatalf("GetSubscriptionInfo: %v", err)
}
if info.CurrentTier != "free" {
t.Errorf("CurrentTier = %q, want %q", info.CurrentTier, "free")
}
if info.TierRank != 0 {
t.Errorf("TierRank = %d, want 0", info.TierRank)
}
if len(info.Tiers) != 2 {
t.Fatalf("expected 2 tiers, got %d", len(info.Tiers))
}
if info.Tiers[1].PriceCentsMonthly <= 0 {
t.Errorf("paid tier monthly price not populated from Stripe: %+v", info.Tiers[1])
}
if info.Tiers[1].PriceCentsYearly <= 0 {
t.Errorf("paid tier yearly price not populated from Stripe: %+v", info.Tiers[1])
}
}
// TestCreateCheckoutSessionMonthly exercises the full checkout-session flow:
// customer creation as a side effect, line-item construction, and the URL
// shape Stripe returns. The created customer is cleaned up via metadata
// search at teardown.
func TestCreateCheckoutSessionMonthly(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
userDID := uniqueDID(t)
cleanupCustomerByDID(t, userDID)
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil)
resp, err := m.CreateCheckoutSession(req, userDID, "stripe-test-handle", &billing.CheckoutSessionRequest{
Tier: env.TierName,
Interval: "monthly",
})
if err != nil {
t.Fatalf("CreateCheckoutSession: %v", err)
}
if !strings.HasPrefix(resp.CheckoutURL, "https://checkout.stripe.com/") {
t.Errorf("CheckoutURL = %q, want a checkout.stripe.com URL", resp.CheckoutURL)
}
if resp.SessionID == "" {
t.Error("SessionID empty")
}
}
// TestCreateCheckoutSessionYearly confirms the interval flag actually selects
// the yearly price when present (the manager falls back to monthly otherwise,
// which we don't want to silently mask).
func TestCreateCheckoutSessionYearly(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
userDID := uniqueDID(t)
cleanupCustomerByDID(t, userDID)
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil)
resp, err := m.CreateCheckoutSession(req, userDID, "stripe-test", &billing.CheckoutSessionRequest{
Tier: env.TierName,
Interval: "yearly",
})
if err != nil {
t.Fatalf("CreateCheckoutSession(yearly): %v", err)
}
if !strings.HasPrefix(resp.CheckoutURL, "https://checkout.stripe.com/") {
t.Errorf("CheckoutURL = %q, want a checkout.stripe.com URL", resp.CheckoutURL)
}
}
func TestCreateCheckoutSessionUnknownTier(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil)
_, err := m.CreateCheckoutSession(req, uniqueDID(t), "h", &billing.CheckoutSessionRequest{
Tier: "tier-does-not-exist",
})
if err == nil {
t.Fatal("expected error for unknown tier, got nil")
}
if !strings.Contains(err.Error(), "unknown tier") {
t.Errorf("error = %v, want one containing 'unknown tier'", err)
}
}
// TestGetBillingPortalURL exercises the billing portal session creation.
// The portal lookup goes through Stripe's customer search API, which is
// eventually consistent (typically ~minutes), so a freshly created customer
// often isn't findable yet. We poll up to the documented worst-case and
// surface the lag as a t.Skip rather than a failure — it isn't a bug in the
// code under test, just a property of the sandbox API.
//
// To make this test reliably pass in a tight loop, set
// STRIPE_TEST_EXISTING_CUSTOMER_DID to a DID whose customer already lives in
// the search index (e.g. one created by a prior run).
func TestGetBillingPortalURL(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
userDID := getenvDefault("STRIPE_TEST_EXISTING_CUSTOMER_DID", "")
if userDID == "" {
userDID = uniqueDID(t)
cleanupCustomerByDID(t, userDID)
// Create a customer so the portal call has something to look up.
// CreateCheckoutSession is the simplest public path that creates a
// customer; we discard the URL it returns.
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", nil)
if _, err := m.CreateCheckoutSession(req, userDID, "portal-test", &billing.CheckoutSessionRequest{
Tier: env.TierName,
Interval: "monthly",
}); err != nil {
t.Fatalf("seed customer via CreateCheckoutSession: %v", err)
}
}
deadline := time.Now().Add(90 * time.Second)
var lastErr error
for time.Now().Before(deadline) {
resp, err := m.GetBillingPortalURL(userDID, "https://test-appview.local/settings/billing")
if err == nil {
if !strings.HasPrefix(resp.PortalURL, "https://billing.stripe.com/") {
t.Errorf("PortalURL = %q, want https://billing.stripe.com/...", resp.PortalURL)
}
return
}
lastErr = err
if strings.Contains(err.Error(), "configuration") || strings.Contains(err.Error(), "No configuration") {
t.Skipf("Stripe billing portal is not configured for the sandbox account. "+
"Set it up at https://dashboard.stripe.com/test/settings/billing/portal then re-run. (raw: %v)", err)
}
if !strings.Contains(err.Error(), "no billing account found") {
t.Fatalf("GetBillingPortalURL: unexpected error %v", err)
}
time.Sleep(3 * time.Second)
}
t.Skipf("Stripe customer search index did not surface the new customer within 90s "+
"(last error: %v). This is an eventual-consistency property of the search API, not a "+
"code-under-test bug. Set STRIPE_TEST_EXISTING_CUSTOMER_DID to a pre-existing DID to "+
"skip this wait.", lastErr)
}
// TestHandleWebhookValidSignature confirms the happy path: a payload signed
// with the configured webhook secret is accepted, parsed, and dispatched to
// the right branch (checkout.session.completed is logged-only today).
func TestHandleWebhookValidSignature(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
payload := buildEventPayload("evt_test_checkout", "checkout.session.completed", map[string]any{
"id": "cs_test_x",
"object": "checkout.session",
"customer": "cus_fake",
"subscription": "sub_fake",
})
req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("HandleWebhook(valid signature): %v", err)
}
}
func TestHandleWebhookInvalidSignature(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
payload := buildEventPayload("evt_test", "checkout.session.completed", map[string]any{})
// Sign with a deliberately wrong secret so ConstructEvent rejects it.
req := signedWebhookRequest(t, payload, "whsec_wrong_secret_for_test", time.Now())
err := m.HandleWebhook(req)
if err == nil {
t.Fatal("expected signature verification to fail, got nil")
}
if !strings.Contains(err.Error(), "signature") {
t.Errorf("error = %v, want one mentioning 'signature'", err)
}
}
func TestHandleWebhookStaleTimestamp(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
payload := buildEventPayload("evt_test", "checkout.session.completed", map[string]any{})
// 10 minutes in the past — beyond Stripe's default 5-minute tolerance.
req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now().Add(-10*time.Minute))
err := m.HandleWebhook(req)
if err == nil {
t.Fatal("expected stale-timestamp rejection, got nil")
}
if !errors.Is(err, webhook.ErrTooOld) && !strings.Contains(err.Error(), "signature") {
t.Errorf("error = %v, want timestamp/signature failure", err)
}
}
// TestHandleWebhookSubscriptionCreated drives the subscription lifecycle
// branch with a hand-built payload. We can't easily create a real Stripe
// subscription in the sandbox without a payment method on the customer, so
// we fabricate an event whose shape matches what Stripe sends. The handler
// looks up the user_did from the customer metadata, so a real customer must
// exist behind the cus_... ID in the payload.
func TestHandleWebhookSubscriptionCreated(t *testing.T) {
env := requireStripeEnv(t)
// Set the global Stripe key so direct customer.New calls below
// authenticate. billing.New sets stripe.Key as a side effect, but
// belt-and-suspenders.
stripe.Key = env.SecretKey
m := newManager(t, env)
userDID := uniqueDID(t)
// Create the Stripe customer directly so we have its ID up front. The
// webhook handler will call getCustomerDID(custID), which hits Stripe's
// customer.Get (not search) — that's strongly consistent, so no lag
// concern here.
cust, err := customer.New(&stripe.CustomerParams{
Params: stripe.Params{Metadata: map[string]string{"user_did": userDID}},
})
if err != nil {
t.Fatalf("create 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)
}
})
payload := buildEventPayload("evt_test_sub_created", "customer.subscription.created", map[string]any{
"id": "sub_test_fake_" + cust.ID,
"object": "subscription",
"status": "active",
"customer": cust.ID,
"items": map[string]any{
"object": "list",
"data": []map[string]any{{
"id": "si_test_fake",
"object": "subscription_item",
"price": map[string]any{
"id": env.PriceMonthly,
"object": "price",
"recurring": map[string]any{
"interval": "month",
},
},
}},
},
})
whReq := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(whReq); err != nil {
t.Fatalf("HandleWebhook(subscription.created): %v", err)
}
// With managedHolds=[], the dispatched UpdateCrewTierOnAllHolds goroutine
// is a no-op. We've already verified the parse+dispatch path succeeded
// (no error returned and no signature failure). The hold-side push is
// covered by TestWebhookEndpoint in webhook_test.go which boots a real
// appview + harness.
}
// TestHandleWebhookAllSubscribedEvents iterates billing.SubscribedEvents and
// confirms each one is dispatched to a real handler (not silently dropped to
// the default branch). For each event type we send a minimally-shaped but
// validly-signed payload through HandleWebhook and check that the captured
// slog output contains the handler's distinctive log line — and does NOT
// contain "Ignoring Stripe event", which would mean a switch case was
// removed without updating the SubscribedEvents list.
//
// Most events use a fake customer ID; the subscription.* handlers short-
// circuit on the resulting empty user_did and emit "No user DID found"
// rather than running through to the tier-push branch, which is fine for
// this test — we're verifying dispatch, not downstream side effects. The
// revert-to-free branch is covered separately by
// TestHandleWebhookSubscriptionDeleted.
func TestHandleWebhookAllSubscribedEvents(t *testing.T) {
env := requireStripeEnv(t)
m := newManager(t, env)
cap := newLogCapture(t)
// Distinctive substring each handler emits. If a switch case is dropped,
// the event hits the default branch ("Ignoring Stripe event") and these
// substrings won't appear in the captured output.
expectedLog := map[string]string{
billing.EventCheckoutSessionCompleted: "Checkout completed",
billing.EventSubscriptionCreated: "No user DID found",
billing.EventSubscriptionUpdated: "No user DID found",
billing.EventSubscriptionDeleted: "No user DID found",
billing.EventSubscriptionPaused: "No user DID found",
billing.EventSubscriptionResumed: "No user DID found",
billing.EventInvoicePaymentFailed: "Stripe invoice payment failed",
billing.EventChargeDisputeCreated: "Stripe chargeback opened",
}
for _, eventType := range billing.SubscribedEvents {
t.Run(eventType, func(t *testing.T) {
cap.reset()
payload := buildEventPayload("evt_test_"+eventType, eventType, payloadFor(eventType, env.PriceMonthly))
req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("HandleWebhook(%s): %v", eventType, err)
}
wantSubstr, ok := expectedLog[eventType]
if !ok {
t.Fatalf("test bug: no expected log substring registered for %q. "+
"Add it to expectedLog when adding a new SubscribedEvents entry.", eventType)
}
if !cap.contains(wantSubstr) {
t.Errorf("event %q did not produce expected log line %q.\nCaptured output:\n%s",
eventType, wantSubstr, cap.String())
}
if cap.contains("Ignoring Stripe event") {
t.Errorf("event %q hit the default switch branch — it is in SubscribedEvents "+
"but HandleWebhook has no case for it.\nCaptured output:\n%s",
eventType, cap.String())
}
})
}
}
// TestHandleWebhookSubscriptionDeleted covers the revert-to-free branch
// inside handleSubscriptionChange that the .created test doesn't reach: when
// Stripe sends a canceled subscription, the user must drop to tier rank 0.
// We seed a real Stripe customer (so getCustomerDID returns non-empty and
// the handler proceeds past its early return) and verify the tier-update log
// reports tierName=free / tierRank=0.
func TestHandleWebhookSubscriptionDeleted(t *testing.T) {
env := requireStripeEnv(t)
stripe.Key = env.SecretKey
m := newManager(t, env)
cap := newLogCapture(t)
userDID := uniqueDID(t)
cust, err := customer.New(&stripe.CustomerParams{
Params: stripe.Params{Metadata: map[string]string{"user_did": userDID}},
})
if err != nil {
t.Fatalf("create 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)
}
})
payload := buildEventPayload("evt_test_sub_deleted", billing.EventSubscriptionDeleted, map[string]any{
"id": "sub_test_deleted_" + cust.ID,
"object": "subscription",
"status": "canceled",
"customer": cust.ID,
// items is intentionally absent: the canceled branch resolves the
// tier from rank 0 directly and never reads items[0].price.id.
})
req := signedWebhookRequest(t, payload, env.WebhookSecret, time.Now())
if err := m.HandleWebhook(req); err != nil {
t.Fatalf("HandleWebhook(subscription.deleted): %v", err)
}
if !cap.contains("Pushing tier update to managed holds") {
t.Fatalf("expected tier-update log line, got:\n%s", cap.String())
}
if !cap.contains(`tierName=free`) {
t.Errorf("expected tierName=free in tier-update log, got:\n%s", cap.String())
}
if !cap.contains(`tierRank=0`) {
t.Errorf("expected tierRank=0 in tier-update log, got:\n%s", cap.String())
}
}
// TestTierResolutionByPriceID is a config-only roundtrip: it makes sure the
// price IDs we feed in actually map back to the configured tier name. A
// mis-pasted env value here makes the rest of the suite mysteriously fail,
// so failing fast on the mapping itself produces better error messages.
func TestTierResolutionByPriceID(t *testing.T) {
env := requireStripeEnv(t)
// Cfg goes through the Manager so the test exercises the same code path
// production does (Manager.New → tier slice → GetTierByPriceID).
cfg := &billing.Config{
Tiers: []billing.BillingTierConfig{
{Name: "free"},
{
Name: env.TierName,
StripePriceMonthly: env.PriceMonthly,
StripePriceYearly: env.PriceYearly,
},
},
}
name, rank := cfg.GetTierByPriceID(env.PriceMonthly)
if name != env.TierName || rank != 1 {
t.Errorf("GetTierByPriceID(monthly) = (%q, %d), want (%q, 1)", name, rank, env.TierName)
}
name, rank = cfg.GetTierByPriceID(env.PriceYearly)
if name != env.TierName || rank != 1 {
t.Errorf("GetTierByPriceID(yearly) = (%q, %d), want (%q, 1)", name, rank, env.TierName)
}
if name, rank := cfg.GetTierByPriceID("price_unknown_xxx"); name != "" || rank != -1 {
t.Errorf("GetTierByPriceID(unknown) = (%q, %d), want (\"\", -1)", name, rank)
}
}
// --- helpers ---------------------------------------------------------------
// buildEventPayload assembles a Stripe Event envelope with api_version set
// to the version stripe-go expects, so ConstructEvent doesn't reject it for
// version mismatch. The handler reads event.Type, event.Data.Raw, and not
// much else, so the surrounding fields are minimal.
func buildEventPayload(id, eventType string, dataObject map[string]any) []byte {
envelope := map[string]any{
"id": id,
"object": "event",
"api_version": stripe.APIVersion,
"type": eventType,
"data": map[string]any{"object": dataObject},
}
b, err := json.Marshal(envelope)
if err != nil {
// Marshal of a known-good map literal can't fail in practice.
panic("buildEventPayload: " + err.Error())
}
return b
}
// payloadFor returns a minimal but well-shaped data.object for the given
// event type — just enough that the handler's json.Unmarshal succeeds and
// the function runs to its first observable log line. priceID is the price
// ID stamped into subscription payloads (unused for non-subscription events).
func payloadFor(eventType, priceID string) map[string]any {
switch eventType {
case "checkout.session.completed":
return map[string]any{
"id": "cs_test_x",
"object": "checkout.session",
"customer": "cus_fake",
"subscription": "sub_fake",
}
case "customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed":
return map[string]any{
"id": "sub_test_fake",
"object": "subscription",
"status": "active",
"customer": "cus_fake",
"items": map[string]any{
"object": "list",
"data": []map[string]any{{
"id": "si_test_fake",
"object": "subscription_item",
"price": map[string]any{
"id": priceID,
"object": "price",
"recurring": map[string]any{"interval": "month"},
},
}},
},
}
case "invoice.payment_failed":
return map[string]any{
"id": "in_test_fake",
"object": "invoice",
"customer": "cus_fake",
"amount_due": 100,
"currency": "usd",
"attempt_count": 1,
}
case "charge.dispute.created":
return map[string]any{
"id": "dp_test_fake",
"object": "dispute",
"amount": 100,
"currency": "usd",
"reason": "fraudulent",
"status": "warning_needs_response",
// charge is required to be present, even minimally, so the
// handler's dispute.Charge.Customer dereference doesn't NPE.
"charge": map[string]any{
"id": "ch_test_fake",
"object": "charge",
"customer": "cus_fake",
},
}
}
// Unknown event type — return an empty object. The test will then notice
// the missing expectedLog entry and fail with a helpful message.
return map[string]any{}
}
// signedWebhookRequest builds an http.Request with a Stripe-signed body.
// Tests use it for both the happy path (correct secret + recent timestamp)
// and the rejection paths (wrong secret or stale timestamp).
func signedWebhookRequest(t *testing.T, payload []byte, secret string, ts time.Time) *http.Request {
t.Helper()
signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{
Payload: payload,
Secret: secret,
Timestamp: ts,
})
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader(string(signed.Payload)))
req.Header.Set("Stripe-Signature", signed.Header)
req.Header.Set("Content-Type", "application/json")
return req
}