mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 12:42:43 +00:00
279 lines
8.1 KiB
Go
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
|
|
}
|