Files
pay/internal/provider/stripe/stripe_test.go
T
wangjia 4a1a431f62 fix(v2): stripe sessionToEvent 回填 SubscriptionRef(订阅首期激活主链路生产可用)
checkout.session.completed 的 sessionToEvent 从不读 sess.Subscription,导致真实
Stripe 路径下 PaidEvent.SubscriptionRef 恒为空,settle.go 的激活门(SubscriptionRef
!= "")恒 false,订阅创建→首付→激活链路在生产环境整体不工作。fake provider 测试
直塞 SubscriptionRef,掩盖了这个接缝。

补 sess.Subscription != nil 时回填 ID(与既有 inv.Subscription 同套路);Kind 保持
零值 EventPayment——分派门只看 SubscriptionRef,不看 Kind。新增 adapter 测试先红
后绿,验证 VerifyCallback 真实路径产出正确 SubscriptionRef。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 19:12:33 +08:00

451 lines
19 KiB
Go

package stripe_test
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
gostripe "github.com/stripe/stripe-go/v79"
"github.com/stripe/stripe-go/v79/client"
"github.com/wangjia/pay/internal/provider"
st "github.com/wangjia/pay/internal/provider/stripe"
)
const whSecret = "whsec_test_secret"
func fakeStripeAPI(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions"):
// 创建 session。一次性(mode=payment)与订阅(mode=subscription)共用此分支,
// 靠 form body 是否带 "subscription" 区分,回不同 id 供各自用例断言。
b, _ := io.ReadAll(r.Body)
if strings.Contains(string(b), "subscription") {
fmt.Fprint(w, `{"id":"cs_sub_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_sub_123","mode":"subscription","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
} else {
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
}
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/checkout/sessions/cs_test_123"):
// 查询 session — 已付
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}`)
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_ok"):
fmt.Fprint(w, `{"id":"sub_cancel_ok","object":"subscription","status":"canceled"}`)
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_resource_missing"):
// 渠道已彻底删除该订阅对象:invalid_request_error + code=resource_missing(有明确
// 机器可读 Code)。
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such subscription: 'sub_resource_missing'"}}`)
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_already_canceled"):
// 订阅对象仍在但 status=canceled,二次 Cancel:invalid_request_error,**无 code**,
// 只有 Stripe 实测的固定文案。
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"This subscription has already been canceled."}}`)
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_other_error"):
// 与"已取消"无关的普通渠道拒绝(如权限/网络类),不应被误判成哨兵。
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"Something else went wrong."}}`)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/payment_intents/pi_1"):
// 一次性单的 PI:携带 Create 时 stamp 的 out_trade_no metadata。
fmt.Fprint(w, `{"id":"pi_1","object":"payment_intent","metadata":{"out_trade_no":"PAY-1"}}`)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/payment_intents/pi_sub_1"):
// 订阅续费 charge 的 PI:不带 out_trade_no metadata(Stripe 不透传订阅 metadata 到 PI)。
fmt.Fprint(w, `{"id":"pi_sub_1","object":"payment_intent","metadata":{}}`)
default:
http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound)
}
}))
}
func newStripe(t *testing.T, ts *httptest.Server) *st.Provider {
backends := &gostripe.Backends{
API: gostripe.GetBackendWithConfig(gostripe.APIBackend, &gostripe.BackendConfig{
URL: gostripe.String(ts.URL),
}),
}
sc := client.New("sk_test_x", backends)
return st.New(sc, whSecret)
}
func TestCreateSubscriptionCheckout(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
sess, err := p.CreateSubscriptionCheckout(context.Background(), provider.CreateRequest{
OutTradeNo: "PAY-S1", Subject: "Pro Monthly", AmountMinor: 2999, Currency: "USD",
ReturnURL: "https://x/return", Metadata: map[string]string{"pay_sub_id": "SUB-1"},
})
if err != nil {
t.Fatalf("create sub checkout: %v", err)
}
if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "cs_sub_123" {
t.Fatalf("session = %+v", sess)
}
}
func TestCancelSubscription(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
if err := p.CancelSubscription(context.Background(), "sub_cancel_ok"); err != nil {
t.Fatalf("cancel: %v", err)
}
}
// TestCancelSubscriptionResourceMissingWrapsSentinel 覆盖"渠道已彻底删除该订阅对象"这一
// 已取消形态:*stripe.Error{Type:invalid_request_error, Code:resource_missing} → wrap 成
// provider.ErrSubAlreadyCanceled(errors.Is 可判),不是不可判别的裸字符串错误。
func TestCancelSubscriptionResourceMissingWrapsSentinel(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
err := p.CancelSubscription(context.Background(), "sub_resource_missing")
if err == nil {
t.Fatalf("cancel resource_missing: want error, got nil")
}
if !errors.Is(err, provider.ErrSubAlreadyCanceled) {
t.Fatalf("cancel resource_missing err = %v, want wraps provider.ErrSubAlreadyCanceled", err)
}
}
// TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel 覆盖"订阅对象仍在但 status=canceled
// 二次 Cancel"这一形态:Stripe 对此场景**不下发机器可读 Code**,只有 invalid_request_error 类型
// + 固定文案"already been canceled"——同样应 wrap 成哨兵。
func TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
err := p.CancelSubscription(context.Background(), "sub_already_canceled")
if err == nil {
t.Fatalf("cancel already_canceled: want error, got nil")
}
if !errors.Is(err, provider.ErrSubAlreadyCanceled) {
t.Fatalf("cancel already_canceled err = %v, want wraps provider.ErrSubAlreadyCanceled", err)
}
}
// TestCancelSubscriptionOtherErrorNotWrapped 反例:与"已取消"无关的渠道拒绝不应被误判成
// 哨兵,原样透传成普通错误(不能 errors.Is 命中)。
func TestCancelSubscriptionOtherErrorNotWrapped(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
err := p.CancelSubscription(context.Background(), "sub_cancel_other_error")
if err == nil {
t.Fatalf("cancel other error: want error, got nil")
}
if errors.Is(err, provider.ErrSubAlreadyCanceled) {
t.Fatalf("cancel other error err = %v, 不应误判成 ErrSubAlreadyCanceled", err)
}
}
func TestCapabilitiesRecurring(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
c := newStripe(t, ts).Capabilities()
if !c.SupportsRecurring || c.RecurringKind != provider.RecurringKindGatewayScheduled {
t.Fatalf("caps = %+v", c)
}
}
func TestCreateCheckoutRedirect(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
sess, err := p.Create(context.Background(), provider.CreateRequest{
OutTradeNo: "PAY-1", Subject: "Pro Year", AmountMinor: 2999, Currency: "USD",
ReturnURL: "https://x/return",
})
if err != nil {
t.Fatalf("create: %v", err)
}
if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "cs_test_123" {
t.Fatalf("session = %+v", sess)
}
if !strings.Contains(sess.Payload["url"].(string), "cs_test_123") {
t.Fatalf("url = %v", sess.Payload["url"])
}
}
func TestQueryPaid(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
ev, err := p.Query(context.Background(), provider.QueryRequest{ProviderRef: "cs_test_123", Currency: "USD"})
if err != nil {
t.Fatalf("query: %v", err)
}
if ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 2999 || ev.PaidCurrency != "USD" {
t.Fatalf("event = %+v", ev)
}
}
func TestVerifyWebhook(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_1","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.ProviderRef != "cs_test_123" || ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 2999 {
t.Fatalf("event = %+v", ev)
}
}
// 订阅 Checkout(mode=subscription)完成时,session payload 携带 subscription 字段
// (stripe-go v79 CheckoutSession.Subscription 为 *Subscription,字符串 id 会 unmarshal
// 成 &Subscription{ID:...})。sessionToEvent 必须回填 PaidEvent.SubscriptionRef——否则
// gateway.settleRenewal 的激活门(ev.SubscriptionRef != "")恒 false,订阅首期激活主链路
// 在生产环境(真实 Stripe webhook)整体不工作,只是被 fake provider 的测试掩盖了。
func TestVerifyWebhookSubscriptionSessionBackfillsSubscriptionRef(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_sub_1","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_sub_123","object":"checkout.session","mode":"subscription","amount_total":2999,"currency":"usd","payment_status":"paid","subscription":"sub_new"}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.SubscriptionRef != "sub_new" {
t.Fatalf("SubscriptionRef = %q, want %q (event = %+v)", ev.SubscriptionRef, "sub_new", ev)
}
if ev.ProviderRef != "cs_sub_123" || ev.Status != provider.PaidSucceeded {
t.Fatalf("event = %+v", ev)
}
}
// 用错误的签名密钥(冒充攻击者伪造 webhook)→ ConstructEventWithOptions 内部 HMAC 校验
// 必失败,VerifyCallback 必须返回 error,绝不能返回 PaidEvent(哪怕 payload 里状态是 paid)。
func TestVerifyWebhookWrongSecretFails(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_evil","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}}}`
sig := signStripe(payload, "whsec_completely_different_secret", time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err == nil {
t.Fatalf("want 验签失败(签名密钥不匹配), got event = %+v", ev)
}
if ev != nil {
t.Fatalf("验签失败时不应返回 PaidEvent, got %+v", ev)
}
}
// invoice.paid + billing_reason=subscription_cycle → 归一化为 EventSubscriptionRenewal,
// 携带 invoice/subscription/金额,供 gateway.settleRenewal 铸 renewal order。
func TestVerifyInvoicePaidRenewal(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_r","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_123","object":"invoice","billing_reason":"subscription_cycle","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventSubscriptionRenewal {
t.Fatalf("kind = %v, want EventSubscriptionRenewal", ev.Kind)
}
if ev.InvoiceRef != "in_123" || ev.SubscriptionRef != "sub_new" || ev.PaidAmountMinor != 2999 || ev.Status != provider.PaidSucceeded {
t.Fatalf("event = %+v", ev)
}
if ev.PaidCurrency != "USD" {
t.Fatalf("currency = %s, want USD", ev.PaidCurrency)
}
}
// invoice.paid 但 billing_reason=subscription_create 是首期发票,与 checkout.session.completed
// 是同一笔钱——由后者入账,这里必须跳过(归一化为 EventPayment,不触发续费铸单)。
func TestVerifyInvoicePaidFirstPeriodSkipped(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_r2","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_first","object":"invoice","billing_reason":"subscription_create","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventPayment {
t.Fatalf("kind = %v, want EventPayment(skip)", ev.Kind)
}
}
// customer.subscription.deleted(Stripe 侧取消,主动取消/欠费催收耗尽后网关删除)→
// 归一化为 EventSubscriptionCanceled,携带渠道订阅号供 gateway 反查 Subscription。
func TestVerifyCustomerSubscriptionDeleted(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_del","object":"event","type":"customer.subscription.deleted","data":{"object":{"id":"sub_new","object":"subscription","status":"canceled"}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventSubscriptionCanceled {
t.Fatalf("kind = %v, want EventSubscriptionCanceled", ev.Kind)
}
if ev.SubscriptionRef != "sub_new" {
t.Fatalf("subscription_ref = %s, want sub_new", ev.SubscriptionRef)
}
}
// TestCreateSendsPaymentIntentMetadata 覆盖 P8 Task6:一次性(mode=payment)Checkout 的
// Create 必须给 PaymentIntentData 打 out_trade_no metadata,dispute webhook 反查 PI 才能
// 定位原订单(见 VerifyCallback 的 charge.dispute.created 分支)。
func TestCreateSendsPaymentIntentMetadata(t *testing.T) {
var gotBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
}))
defer ts.Close()
p := newStripe(t, ts)
if _, err := p.Create(context.Background(), provider.CreateRequest{
OutTradeNo: "PAY-1", Subject: "Pro Year", AmountMinor: 2999, Currency: "USD",
ReturnURL: "https://x/return",
}); err != nil {
t.Fatalf("create: %v", err)
}
if !strings.Contains(gotBody, "payment_intent_data") || !strings.Contains(gotBody, "out_trade_no") || !strings.Contains(gotBody, "PAY-1") {
t.Fatalf("create request body missing payment_intent_data out_trade_no metadata: %s", gotBody)
}
}
// charge.dispute.created(拒付)→ 归一化为 EventChargeback:dispute payload 的
// payment_intent 只是 id("pi_1")未展开,须反查 PaymentIntents.Get 取 metadata
// out_trade_no(一次性单 Create 已 stamp)。
func TestVerifyChargeDisputeCreated(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_dp1","object":"event","type":"charge.dispute.created","data":{"object":{"id":"dp_1","object":"dispute","amount":2999,"currency":"usd","reason":"fraudulent","status":"needs_response","payment_intent":"pi_1"}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventChargeback {
t.Fatalf("kind = %v, want EventChargeback", ev.Kind)
}
if ev.DisputeRef != "dp_1" || ev.OutTradeNo != "PAY-1" || ev.PaidAmountMinor != 2999 || ev.PaidCurrency != "USD" || ev.Reason != "fraudulent" {
t.Fatalf("event = %+v", ev)
}
if ev.ProviderPaymentRef != "pi_1" {
t.Fatalf("provider_payment_ref = %s, want pi_1", ev.ProviderPaymentRef)
}
}
// 订阅续费 charge 的 PI 不带 out_trade_no metadata(Stripe 不透传订阅 metadata 到 PI)→
// OutTradeNo 归一化为空,gateway.recordChargeback 据此记录但不转发(honest scope)。
func TestVerifyChargeDisputeCreatedNoOutTradeNo(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_dp2","object":"event","type":"charge.dispute.created","data":{"object":{"id":"dp_sub_1","object":"dispute","amount":999,"currency":"usd","reason":"fraudulent","status":"needs_response","payment_intent":"pi_sub_1"}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventChargeback || ev.DisputeRef != "dp_sub_1" {
t.Fatalf("event = %+v", ev)
}
if ev.OutTradeNo != "" {
t.Fatalf("out_trade_no = %q, want empty(订阅拒付无法定位)", ev.OutTradeNo)
}
}
// invoice.payment_failed(某期扣款失败)→ 归一化为 EventSubscriptionPastDue,携带失败
// 发票号(供 gateway 铸 outbox 幂等键)+ 渠道订阅号。
func TestVerifyInvoicePaymentFailed(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_fail","object":"event","type":"invoice.payment_failed","data":{"object":{"id":"in_failed","object":"invoice","subscription":{"id":"sub_new","object":"subscription"}}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventSubscriptionPastDue {
t.Fatalf("kind = %v, want EventSubscriptionPastDue", ev.Kind)
}
if ev.InvoiceRef != "in_failed" || ev.SubscriptionRef != "sub_new" {
t.Fatalf("event = %+v", ev)
}
}
// signStripe 复刻 Stripe webhook 签名头: t=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
func signStripe(payload, secret string, ts int64) string {
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.%s", ts, payload)
return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil)))
}