Files
pulse/internal/cloudcp/stripe/webhook_test.go
T
2026-05-01 14:13:08 +01:00

279 lines
8.1 KiB
Go

package stripe
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
stripelib "github.com/stripe/stripe-go/v82"
stripewebhook "github.com/stripe/stripe-go/v82/webhook"
)
func TestWebhookRetriesFailedEventInsteadOfSkippingDuplicate(t *testing.T) {
reg := newTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := NewProvisioner(reg, tenantsDir, nil, nil, "https://cloud.example.com", nil, "", false)
const secret = "whsec_test_secret"
handler := NewWebhookHandler(secret, provisioner)
eventJSON := `{"id":"evt_retry_failed_123","object":"event","type":"customer.subscription.updated","data":{"object":{"id":"sub_missing_customer","status":"active"}}}`
req1 := signedWebhookRequest(t, secret, eventJSON)
rec1 := httptest.NewRecorder()
handler.ServeHTTP(rec1, req1)
if rec1.Code != http.StatusInternalServerError {
t.Fatalf("first delivery status=%d, want=%d, body=%q", rec1.Code, http.StatusInternalServerError, rec1.Body.String())
}
// Duplicate delivery must retry processing (and fail again here), not short-circuit
// as if the event had already been handled successfully.
req2 := signedWebhookRequest(t, secret, eventJSON)
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusInternalServerError {
t.Fatalf("duplicate delivery status=%d, want=%d, body=%q", rec2.Code, http.StatusInternalServerError, rec2.Body.String())
}
}
func TestWebhookIgnoresSelfHostedProTrialCheckoutSessions(t *testing.T) {
reg := newTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := NewProvisioner(reg, tenantsDir, nil, nil, "https://cloud.example.com", nil, "", false)
const secret = "whsec_test_secret"
handler := NewWebhookHandler(secret, provisioner)
eventJSON := `{
"id":"evt_self_hosted_trial_123",
"object":"event",
"type":"checkout.session.completed",
"data":{
"object":{
"id":"cs_test_self_hosted_pro_trial",
"mode":"subscription",
"customer":"cus_selfhost_trial",
"subscription":"sub_selfhost_trial",
"customer_email":"trial@business.example",
"customer_details":{"email":"trial@business.example"},
"metadata":{
"signup_source":"pulse_pro_trial",
"org_id":"default",
"return_url":"https://pulse.example.com/auth/trial-activate",
"instance_token":"tsi_test"
}
}
}
}`
req := signedWebhookRequest(t, secret, eventJSON)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delivery status=%d, want=%d, body=%q", rec.Code, http.StatusOK, rec.Body.String())
}
tenants, err := reg.List()
if err != nil {
t.Fatalf("List tenants: %v", err)
}
if len(tenants) != 0 {
t.Fatalf("tenant count=%d, want 0 for self-hosted Pro trial checkout", len(tenants))
}
stripeAccount, err := reg.GetStripeAccountByCustomerID("cus_selfhost_trial")
if err != nil {
t.Fatalf("GetStripeAccountByCustomerID: %v", err)
}
if stripeAccount != nil {
t.Fatalf("Stripe account mapping was created for self-hosted Pro trial checkout: %#v", stripeAccount)
}
}
func TestWebhookIgnoresSelfHostedLandingCheckoutSessions(t *testing.T) {
reg := newTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := NewProvisioner(reg, tenantsDir, nil, nil, "https://cloud.example.com", nil, "", false)
const secret = "whsec_test_secret"
handler := NewWebhookHandler(secret, provisioner)
eventJSON := `{
"id":"evt_self_hosted_landing_123",
"object":"event",
"type":"checkout.session.completed",
"data":{
"object":{
"id":"cs_test_self_hosted_landing",
"mode":"subscription",
"customer":"cus_selfhost_landing",
"subscription":"sub_selfhost_landing",
"customer_email":"buyer@example.com",
"customer_details":{"email":"buyer@example.com"},
"metadata":{
"plan_id":"price_1ShIsdBrHBocJIGH71yQusLG",
"generation":"v5",
"checkout_origin":"pulserelay_landing"
}
}
}
}`
req := signedWebhookRequest(t, secret, eventJSON)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delivery status=%d, want=%d, body=%q", rec.Code, http.StatusOK, rec.Body.String())
}
tenants, err := reg.List()
if err != nil {
t.Fatalf("List tenants: %v", err)
}
if len(tenants) != 0 {
t.Fatalf("tenant count=%d, want 0 for self-hosted landing checkout", len(tenants))
}
stripeAccount, err := reg.GetStripeAccountByCustomerID("cus_selfhost_landing")
if err != nil {
t.Fatalf("GetStripeAccountByCustomerID: %v", err)
}
if stripeAccount != nil {
t.Fatalf("Stripe account mapping was created for self-hosted landing checkout: %#v", stripeAccount)
}
}
func TestHostedProvisioningCheckoutClassification(t *testing.T) {
tests := []struct {
name string
metadata map[string]string
want bool
}{
{
name: "public cloud signup source",
metadata: map[string]string{
"signup_source": publicCloudSignupSource,
},
want: true,
},
{
name: "public cloud signup source with self hosted plan id",
metadata: map[string]string{
"signup_source": publicCloudSignupSource,
"plan_id": "price_1ShIsdBrHBocJIGH71yQusLG",
},
want: false,
},
{
name: "cloud plan version",
metadata: map[string]string{
"plan_version": "cloud_starter",
},
want: true,
},
{
name: "cloud plan id",
metadata: map[string]string{
"plan_id": "price_1T5kg2BrHBocJIGHmkoF0zXY",
},
want: true,
},
{
name: "msp plan version",
metadata: map[string]string{
"plan_version": "msp_growth",
},
want: true,
},
{
name: "self hosted v5 plan id",
metadata: map[string]string{
"plan_id": "price_1ShIsdBrHBocJIGH71yQusLG",
},
want: false,
},
{
name: "self hosted v6 generation",
metadata: map[string]string{
"plan_id": "price_1TPmE6BrHBocJIGHHaPwluoM",
"generation": "v6",
},
want: false,
},
{
name: "missing metadata",
metadata: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isHostedProvisioningCheckout(CheckoutSession{Metadata: tt.metadata})
if got != tt.want {
t.Fatalf("isHostedProvisioningCheckout=%t, want %t", got, tt.want)
}
})
}
}
func TestWebhookEventContext_DetachesCheckoutFromRequestContext(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", nil)
ctx, cancelReq := context.WithCancel(req.Context())
cancelReq()
req = req.WithContext(ctx)
gotCtx, cancel := webhookEventContext(req, stripelib.EventType("checkout.session.completed"))
defer cancel()
if err := gotCtx.Err(); err != nil {
t.Fatalf("checkout context should not inherit request cancellation: %v", err)
}
deadline, ok := gotCtx.Deadline()
if !ok {
t.Fatal("checkout context should carry a timeout deadline")
}
if remaining := time.Until(deadline); remaining <= time.Minute || remaining > checkoutProvisioningTimeout {
t.Fatalf("checkout context deadline window=%v, want within (1m,%v]", remaining, checkoutProvisioningTimeout)
}
}
func TestWebhookEventContext_PreservesRequestContextForNonCheckout(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", nil)
ctx, cancelReq := context.WithCancel(req.Context())
cancelReq()
req = req.WithContext(ctx)
gotCtx, cancel := webhookEventContext(req, stripelib.EventType("customer.subscription.updated"))
defer cancel()
if err := gotCtx.Err(); err == nil {
t.Fatal("non-checkout context should preserve request cancellation")
}
}
func signedWebhookRequest(t *testing.T, secret, payload string) *http.Request {
t.Helper()
signed := stripewebhook.GenerateTestSignedPayload(&stripewebhook.UnsignedPayload{
Payload: []byte(payload),
Secret: secret,
Timestamp: time.Now(),
Scheme: "v1",
})
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", bytes.NewReader(signed.Payload))
req.Header.Set("Stripe-Signature", signed.Header)
req.Header.Set("Content-Type", "application/json")
return req
}
func newTestRegistry(t *testing.T) *registry.TenantRegistry {
t.Helper()
reg, err := registry.NewTenantRegistry(t.TempDir())
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
return reg
}