226 lines
8.3 KiB
Go
226 lines
8.3 KiB
Go
package stripe_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"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"}`)
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 用错误的签名密钥(冒充攻击者伪造 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)
|
|
}
|
|
}
|
|
|
|
// 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)))
|
|
}
|