333 lines
12 KiB
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")
|
|
}
|
|
}
|