kra-new/internal/integration/payment/alipay_v3_test.go

333 lines
12 KiB
Go

package payment
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"io"
bizpayment "kra/internal/biz/payment"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
gopayAlipay "github.com/go-pay/gopay/alipay"
gopayAlipayV3 "github.com/go-pay/gopay/alipay/v3"
)
func testAlipayV3Config(t *testing.T) (*rsa.PrivateKey, map[string]any) {
t.Helper()
key, privatePEM, _ := testRSAKeyPair(t)
template := &x509.Certificate{
SerialNumber: big.NewInt(1001),
Subject: pkixName("GoPay Alipay V3 Test"),
Issuer: pkixName("GoPay Alipay V3 Test"),
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certificate := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
return key, map[string]any{
"app_id": "app-v3-test",
"private_key": privatePEM,
"app_cert": certificate,
"root_cert": certificate,
"public_cert": certificate,
"environment": "sandbox",
}
}
func pkixName(commonName string) pkix.Name {
return pkix.Name{CommonName: commonName, Organization: []string{"GoPay Tests"}}
}
func cloneAlipayV3Config(values map[string]any) map[string]any {
clone := make(map[string]any, len(values)+1)
for key, value := range values {
clone[key] = value
}
return clone
}
func alipayV3ResponseSignature(t *testing.T, key *rsa.PrivateKey, body string) (string, string, string) {
t.Helper()
timestamp := "1660000000000"
nonce := "alipay-v3-test-nonce"
signature := testRSA2Sign(t, key, []byte(timestamp+"\n"+nonce+"\n"+body+"\n"))
return timestamp, nonce, signature
}
func writeAlipayV3Response(w http.ResponseWriter, timestamp, nonce, signature, body string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set(gopayAlipayV3.HeaderTimestamp, timestamp)
w.Header().Set(gopayAlipayV3.HeaderNonce, nonce)
w.Header().Set(gopayAlipayV3.HeaderSignature, signature)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, body)
}
func TestAlipayV3CreateRESTMethodsUseGoPay(t *testing.T) {
platformKey, baseConfig := testAlipayV3Config(t)
for _, tc := range []struct {
name string
method string
path string
response string
extra map[string]any
wantStatus string
wantTrade string
}{
{
name: "trade create", path: "/v3/alipay/trade/create",
response: `{"trade_no":"P-V3-CREATE","out_trade_no":"T-V3-CREATE"}`,
extra: map[string]any{"buyer_open_id": "buyer-open-id"}, wantStatus: "created", wantTrade: "P-V3-CREATE",
},
{
name: "trade pay", method: "barcode", path: "/v3/alipay/trade/pay",
response: `{"trade_no":"P-V3-PAY","out_trade_no":"T-V3-PAY","total_amount":"10.00","buyer_pay_amount":"9.00","point_amount":"1.00","receipt_amount":"9.00"}`,
extra: map[string]any{"auth_code": "BARCODE-V3"}, wantStatus: "success", wantTrade: "P-V3-PAY",
},
{
name: "precreate", method: "native", path: "/v3/alipay/trade/precreate",
response: `{"out_trade_no":"T-V3-PRECREATE","qr_code":"https://qr.example/v3"}`,
wantStatus: "created",
},
} {
t.Run(tc.name, func(t *testing.T) {
tradeNo := "T-V3-CREATE"
if tc.method == "barcode" {
tradeNo = "T-V3-PAY"
} else if tc.method == "native" {
tradeNo = "T-V3-PRECREATE"
}
timestamp, nonce, signature := alipayV3ResponseSignature(t, platformKey, tc.response)
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != tc.path {
t.Errorf("request path = %q, want %q", r.URL.Path, tc.path)
}
if !strings.HasPrefix(r.Header.Get(gopayAlipayV3.HeaderAuthorization), gopayAlipayV3.SignTypeRSA+" ") {
t.Errorf("authorization = %q", r.Header.Get(gopayAlipayV3.HeaderAuthorization))
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Errorf("decode request: %v", err)
}
writeAlipayV3Response(w, timestamp, nonce, signature, tc.response)
}))
defer server.Close()
config := cloneAlipayV3Config(baseConfig)
config["api_base_url"] = server.URL
extra := cloneAlipayV3Config(tc.extra)
if tc.method != "" {
extra["method"] = tc.method
}
result, err := (&alipayV3Adapter{}).Create(context.Background(), &bizpayment.PaymentRequest{
Provider: bizpayment.PaymentAlipayV3, TradeNo: tradeNo, Subject: "subject", Amount: 1000,
Currency: "CNY", NotifyURL: "https://merchant.example/alipay-v3/notify", Extra: extra,
}, config)
if err != nil {
t.Fatal(err)
}
if requestBody == nil || requestBody["out_trade_no"] != tradeNo || requestBody["notify_url"] == "" {
t.Fatalf("request body = %#v", requestBody)
}
if result.Provider != bizpayment.PaymentAlipayV3 || result.Status != tc.wantStatus || result.TradeNo != tradeNo || result.ProviderTradeNo != tc.wantTrade {
t.Fatalf("result = %+v", result)
}
if tc.method == "barcode" {
if requestBody["scene"] != "bar_code" || requestBody["auth_code"] != "BARCODE-V3" {
t.Fatalf("trade pay request = %#v", requestBody)
}
if !result.AmountBreakdownKnown || result.Amount != 1000 || result.PayerPaidAmount != 900 || result.PointPaidAmount != 100 {
t.Fatalf("trade pay amount breakdown = %+v", result)
}
}
if tc.method == "" && (requestBody["product_code"] != "JSAPI_PAY" || requestBody["op_app_id"] != "app-v3-test") {
t.Fatalf("trade create defaults = %#v", requestBody)
}
})
}
}
func TestAlipayV3GeneratedPaymentMethodsUseGoPay(t *testing.T) {
_, config := testAlipayV3Config(t)
config["gateway_url"] = "https://merchant-gateway.example/gateway.do"
for _, method := range []string{"app", "page", "wap"} {
t.Run(method, func(t *testing.T) {
result, err := (&alipayV3Adapter{}).Create(context.Background(), &bizpayment.PaymentRequest{
Provider: bizpayment.PaymentAlipayV3, TradeNo: "T-V3-" + strings.ToUpper(method), Subject: "subject",
Amount: 1000, Currency: "CNY", NotifyURL: "https://merchant.example/alipay-v3/notify",
ReturnURL: "https://merchant.example/alipay-v3/return", Extra: map[string]any{"method": method},
}, config)
if err != nil {
t.Fatal(err)
}
var payload map[string]string
if err = json.Unmarshal(result.Payload, &payload); err != nil {
t.Fatal(err)
}
generated := payload["order_string"]
if generated == "" {
generated = payload["pay_url"]
}
if !strings.Contains(generated, "alipay.trade."+method+".pay") || !strings.Contains(generated, "notify_url=") {
t.Fatalf("generated payment data = %q", generated)
}
if method != "app" && !strings.HasPrefix(generated, "https://merchant-gateway.example/gateway.do?") {
t.Fatalf("rewritten gateway URL = %q", generated)
}
})
}
}
func TestAlipayV3QueryVerifiesSignedIdentityAndAmount(t *testing.T) {
platformKey, config := testAlipayV3Config(t)
body := `{"trade_no":"P-V3-QUERY","out_trade_no":"T-V3-QUERY","trade_status":"TRADE_SUCCESS","total_amount":"10.00","buyer_pay_amount":"9.00","point_amount":"1.00","receipt_amount":"9.00"}`
timestamp, nonce, signature := alipayV3ResponseSignature(t, platformKey, body)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v3/alipay/trade/query" {
t.Errorf("request path = %q", r.URL.Path)
}
writeAlipayV3Response(w, timestamp, nonce, signature, body)
}))
defer server.Close()
config["api_base_url"] = server.URL
result, err := (&alipayV3Adapter{}).Query(context.Background(), "T-V3-QUERY", config)
if err != nil {
t.Fatal(err)
}
if result.Status != "success" || result.TradeNo != "T-V3-QUERY" || result.ProviderTradeNo != "P-V3-QUERY" || result.Amount != 1000 || result.Currency != "CNY" {
t.Fatalf("result = %+v", result)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 900 || result.CashPaidAmount != 800 || result.PointPaidAmount != 100 || result.DiscountAmount != 100 || result.SettlementAmount != 900 {
t.Fatalf("amount breakdown = %+v", result)
}
}
func TestAlipayV3QueryRejectsInvalidSignatureAndIdentity(t *testing.T) {
platformKey, baseConfig := testAlipayV3Config(t)
for _, tc := range []struct {
name string
body string
signature string
want string
}{
{name: "mismatched order", body: `{"trade_no":"P-V3","out_trade_no":"OTHER","trade_status":"TRADE_SUCCESS","total_amount":"10.00"}`, want: "out_trade_no"},
{name: "invalid signature", body: `{"trade_no":"P-V3","out_trade_no":"T-V3-QUERY","trade_status":"TRADE_SUCCESS","total_amount":"10.00"}`, signature: "invalid", want: "signature"},
} {
t.Run(tc.name, func(t *testing.T) {
timestamp, nonce, signature := alipayV3ResponseSignature(t, platformKey, tc.body)
if tc.signature != "" {
signature = tc.signature
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeAlipayV3Response(w, timestamp, nonce, signature, tc.body)
}))
defer server.Close()
config := cloneAlipayV3Config(baseConfig)
config["api_base_url"] = server.URL
_, err := (&alipayV3Adapter{}).Query(context.Background(), "T-V3-QUERY", config)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) {
t.Fatalf("error = %v, want %q", err, tc.want)
}
})
}
}
func TestAlipayV3RefundValidatesResponse(t *testing.T) {
platformKey, baseConfig := testAlipayV3Config(t)
for _, tc := range []struct {
name string
refundFee string
wantError bool
}{
{name: "success", refundFee: "1.23"},
{name: "amount mismatch", refundFee: "1.22", wantError: true},
} {
t.Run(tc.name, func(t *testing.T) {
body := `{"trade_no":"P-V3-REFUND","out_trade_no":"T-V3-REFUND","fund_change":"Y","refund_fee":"` + tc.refundFee + `"}`
timestamp, nonce, signature := alipayV3ResponseSignature(t, platformKey, body)
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Errorf("decode request: %v", err)
}
writeAlipayV3Response(w, timestamp, nonce, signature, body)
}))
defer server.Close()
config := cloneAlipayV3Config(baseConfig)
config["api_base_url"] = server.URL
result, err := (&alipayV3Adapter{}).Refund(context.Background(), &bizpayment.PaymentRefundRequest{
Provider: bizpayment.PaymentAlipayV3, TradeNo: "T-V3-REFUND", ProviderTradeNo: "P-V3-REFUND",
RefundNo: "R-V3-REFUND", Amount: 123, Currency: "CNY",
}, config)
if tc.wantError {
if err == nil || !strings.Contains(err.Error(), "金额不匹配") {
t.Fatalf("error = %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if requestBody["out_request_no"] != "R-V3-REFUND" || requestBody["refund_amount"] != "1.23" {
t.Fatalf("refund request = %#v", requestBody)
}
if result.Amount != 123 || result.ProviderTradeNo != "P-V3-REFUND" || result.Status != "created" {
t.Fatalf("refund result = %+v", result)
}
})
}
}
func TestAlipayV3CallbackUsesGoPayCertificateVerification(t *testing.T) {
platformKey, config := testAlipayV3Config(t)
fields := map[string]string{
"app_id": "app-v3-test",
"trade_no": "P-V3-CALLBACK",
"out_trade_no": "T-V3-CALLBACK",
"trade_status": "TRADE_SUCCESS",
"sign_type": "RSA2",
}
bodyMap := toGoPayBodyMap(fields)
bodyMap.Remove("sign_type")
signature, err := gopayAlipay.GetRsaSign(bodyMap, gopayAlipay.RSA2, platformKey)
if err != nil {
t.Fatal(err)
}
fields["sign"] = signature
values := url.Values{}
for key, value := range fields {
values.Set(key, value)
}
callback := &bizpayment.PaymentCallback{
Body: []byte(values.Encode()), Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}
result, err := (&alipayV3Adapter{}).Callback(context.Background(), callback, config)
if err != nil {
t.Fatal(err)
}
if result.Provider != bizpayment.PaymentAlipayV3 || result.Status != "success" || result.TradeNo != "T-V3-CALLBACK" || result.ProviderTradeNo != "P-V3-CALLBACK" {
t.Fatalf("callback result = %+v", result)
}
callback.Body = []byte(strings.ReplaceAll(values.Encode(), "P-V3-CALLBACK", "P-V3-TAMPERED"))
if _, err = (&alipayV3Adapter{}).Callback(context.Background(), callback, config); err == nil {
t.Fatal("tampered callback unexpectedly verified")
}
}