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

136 lines
4.5 KiB
Go

//go:build billing && stripe_integration
package stripeintegration
import (
"net/http"
"strings"
"testing"
"time"
// Required blank imports for the in-process distribution registry that
// the testharness boots: without these the appview panics with
// "StorageDriver not registered: inmemory".
_ "github.com/distribution/distribution/v3/registry/auth/token"
_ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory"
"github.com/stripe/stripe-go/v84/webhook"
"atcr.io/internal/testharness"
"atcr.io/pkg/billing"
)
// TestWebhookEndpointAcceptsSignedPayload boots the full in-process stack
// (fake PDS + gofakes3 + hold + appview) with billing wired in, then POSTs
// a Stripe-signed event to /api/stripe/webhook over real HTTP. This is the
// piece the Manager-level tests in manager_test.go can't cover: that the
// route is actually mounted, the chi router forwards the body, and the
// signature secret in cfg.Billing reaches the handler unchanged.
//
// We assert on the HTTP status code rather than any tier-push side effect
// because the harness uses an in-memory hold whose crew table is seeded
// directly — there is no real "managed hold" to push tier updates to, and
// configuring one would couple this test to the holdclient transport layer
// which has its own coverage.
func TestWebhookEndpointAcceptsSignedPayload(t *testing.T) {
env := requireStripeEnv(t)
billingCfg := 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", MaxWebhooks: 1},
{
Name: env.TierName,
StripePriceMonthly: env.PriceMonthly,
StripePriceYearly: env.PriceYearly,
MaxWebhooks: 10,
WebhookAllTriggers: true,
SupporterBadge: true,
},
},
}
h := testharness.New(t, testharness.WithBilling(billingCfg))
// Build and sign a minimal event Stripe might send.
payload := buildEventPayload("evt_test_endpoint", "checkout.session.completed", map[string]any{
"id": "cs_test_endpoint",
"object": "checkout.session",
"customer": "cus_fake",
"subscription": "sub_fake",
})
signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{
Payload: payload,
Secret: env.WebhookSecret,
Timestamp: time.Now(),
})
url := h.UIBaseURL + "/api/stripe/webhook"
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, strings.NewReader(string(signed.Payload)))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Stripe-Signature", signed.Header)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST webhook: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("webhook endpoint returned %d, want 200", resp.StatusCode)
}
}
// TestWebhookEndpointRejectsBadSignature verifies the same route returns
// a 4xx (not 200) when the signature was generated with a different secret.
// Without this, a misconfigured webhook secret would silently no-op rather
// than failing loudly at the boundary.
func TestWebhookEndpointRejectsBadSignature(t *testing.T) {
env := requireStripeEnv(t)
billingCfg := billing.Config{
StripeSecretKey: env.SecretKey,
WebhookSecret: env.WebhookSecret,
Tiers: []billing.BillingTierConfig{
{Name: "free", MaxWebhooks: 1},
{
Name: env.TierName,
StripePriceMonthly: env.PriceMonthly,
MaxWebhooks: 10,
},
},
}
h := testharness.New(t, testharness.WithBilling(billingCfg))
payload := buildEventPayload("evt_test_bad_sig", "checkout.session.completed", map[string]any{})
signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{
Payload: payload,
Secret: "whsec_wrong_secret_for_test",
Timestamp: time.Now(),
})
url := h.UIBaseURL + "/api/stripe/webhook"
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, strings.NewReader(string(signed.Payload)))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Stripe-Signature", signed.Header)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST webhook: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Fatalf("webhook endpoint accepted bad signature (200); want 4xx")
}
}