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") } }