支付相关
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/pkg/paymentkit"
|
||||
|
||||
gopayAlipay "github.com/go-pay/gopay/alipay"
|
||||
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
|
||||
)
|
||||
|
||||
func testRSAKeyPair(t *testing.T) (*rsa.PrivateKey, string, string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privatePEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER})
|
||||
return key, string(privatePEM), string(publicPEM)
|
||||
}
|
||||
|
||||
func testRSA2Sign(t *testing.T, key *rsa.PrivateKey, data []byte) string {
|
||||
t.Helper()
|
||||
digest := sha256.Sum256(data)
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signature)
|
||||
}
|
||||
|
||||
func TestAlipayGoPayRequestParamHonorsGatewayAndVerifiesResponse(t *testing.T) {
|
||||
platformKey, privatePEM, publicPEM := testRSAKeyPair(t)
|
||||
var requestForm url.Values
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read request: %v", err)
|
||||
return
|
||||
}
|
||||
requestForm, err = url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
t.Errorf("parse request: %v", err)
|
||||
return
|
||||
}
|
||||
if requestForm.Get("method") != "alipay.trade.query" {
|
||||
t.Errorf("method = %q", requestForm.Get("method"))
|
||||
}
|
||||
if !strings.Contains(requestForm.Get("biz_content"), `"out_trade_no":"T-ALIPAY"`) {
|
||||
t.Errorf("biz_content = %q", requestForm.Get("biz_content"))
|
||||
}
|
||||
ok, err := gopayAlipay.VerifySyncSign(goPayAlipayKey(publicPEM, true), alipaySignContent(requestForm), requestForm.Get("sign"))
|
||||
if err != nil || !ok {
|
||||
t.Errorf("request signature valid = %v, err = %v", ok, err)
|
||||
}
|
||||
response := []byte(`{"code":"10000","msg":"Success","trade_no":"P-ALIPAY","out_trade_no":"T-ALIPAY","trade_status":"TRADE_SUCCESS","total_amount":"10.00","buyer_pay_amount":"9.00","point_amount":"1.00","receipt_amount":"9.00","trans_currency":"CNY"}`)
|
||||
payload := fmt.Sprintf(`{"alipay_trade_query_response":%s,"sign":%q}`, response, testRSA2Sign(t, platformKey, response))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := (&alipayAdapter{}).Query(context.Background(), "T-ALIPAY", map[string]any{
|
||||
"app_id": "app-test",
|
||||
"private_key": privatePEM,
|
||||
"public_key": publicPEM,
|
||||
"gateway_url": server.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestForm == nil || result.TradeNo != "T-ALIPAY" || result.ProviderTradeNo != "P-ALIPAY" || result.Amount != 1000 || result.Currency != "CNY" {
|
||||
t.Fatalf("unexpected result: %+v, request: %v", result, requestForm)
|
||||
}
|
||||
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 900 || result.CashPaidAmount != 800 || result.PointPaidAmount != 100 || result.DiscountAmount != 100 || result.SettlementAmount != 900 {
|
||||
t.Fatalf("unexpected amount breakdown: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlipayGoPayCallbackVerificationDoesNotMutateFields(t *testing.T) {
|
||||
key, _, publicPEM := testRSAKeyPair(t)
|
||||
fields := map[string]string{
|
||||
"app_id": "app-test",
|
||||
"trade_no": "P-ALIPAY",
|
||||
"out_trade_no": "T-ALIPAY",
|
||||
"trade_status": "TRADE_SUCCESS",
|
||||
"sign_type": "RSA2",
|
||||
}
|
||||
body := toGoPayBodyMap(fields)
|
||||
body.Remove("sign_type")
|
||||
sign, err := gopayAlipay.GetRsaSign(body, gopayAlipay.RSA2, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields["sign"] = sign
|
||||
config := map[string]any{"app_id": "app-test", "public_key": publicPEM}
|
||||
if err = verifyAlipay(fields, config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fields["sign"] == "" || fields["sign_type"] != "RSA2" {
|
||||
t.Fatalf("verification mutated source fields: %#v", fields)
|
||||
}
|
||||
fields["trade_no"] = "tampered"
|
||||
if err = verifyAlipay(fields, config); err == nil {
|
||||
t.Fatal("tampered callback unexpectedly verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
|
||||
const key = "wechat-secret"
|
||||
var requestValues map[string]string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read request: %v", err)
|
||||
return
|
||||
}
|
||||
requestValues, err = paymentkit.XMLValues(body)
|
||||
if err != nil {
|
||||
t.Errorf("parse request: %v", err)
|
||||
return
|
||||
}
|
||||
if requestValues["total_fee"] != "123" {
|
||||
t.Errorf("total_fee = %q, want 123", requestValues["total_fee"])
|
||||
}
|
||||
if err = verifyWechatV2(requestValues, key); err != nil {
|
||||
t.Errorf("request signature: %v", err)
|
||||
}
|
||||
response := map[string]string{"return_code": "SUCCESS", "return_msg": "OK", "result_code": "SUCCESS", "prepay_id": "prepay"}
|
||||
response["sign"] = wechatV2Sign(response, key, "MD5")
|
||||
_, _ = w.Write(paymentkit.XMLEncode(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := (&wechatV2Adapter{}).Create(context.Background(), &biz.PaymentRequest{
|
||||
TradeNo: "T-WECHAT", Subject: "subject", Amount: 123, Currency: "CNY", NotifyURL: "http://notify.local",
|
||||
}, map[string]any{
|
||||
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
|
||||
"environment": "sandbox", "create_url": server.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "created" || requestValues["total_fee"] != "123" {
|
||||
t.Fatalf("unexpected create result: %+v, request: %#v", result, requestValues)
|
||||
}
|
||||
|
||||
callbackValues := map[string]string{
|
||||
"return_code": "SUCCESS", "result_code": "SUCCESS", "appid": "wx-test", "mch_id": "mch-test",
|
||||
"out_trade_no": "T-WECHAT", "transaction_id": "P-WECHAT", "trade_state": "SUCCESS",
|
||||
}
|
||||
callbackValues["sign"] = wechatV2Sign(callbackValues, key, "MD5")
|
||||
originalSign := callbackValues["sign"]
|
||||
if err = verifyWechatV2(callbackValues, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackValues["sign"] != originalSign {
|
||||
t.Fatal("verification mutated source values")
|
||||
}
|
||||
callbackBody := paymentkit.XMLEncode(callbackValues)
|
||||
callbackResult, err := (&wechatV2Adapter{}).Callback(context.Background(), &biz.PaymentCallback{Body: callbackBody}, map[string]any{
|
||||
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackResult.Status != "success" || callbackResult.ProviderTradeNo != "P-WECHAT" {
|
||||
t.Fatalf("unexpected callback result: %+v", callbackResult)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
|
||||
func TestWechatV3EndpointTransportPreservesSignedPathAndQuery(t *testing.T) {
|
||||
endpoint, err := url.Parse("https://payments.test.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodGet, "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/T-1?mchid=M-1", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Authorization", `WECHATPAY2-SHA256-RSA2048 mchid="M-1"`)
|
||||
|
||||
transport := &wechatV3EndpointTransport{
|
||||
endpoint: endpoint,
|
||||
base: roundTripperFunc(func(got *http.Request) (*http.Response, error) {
|
||||
if got.URL.Scheme != "https" || got.URL.Host != "payments.test.local" {
|
||||
t.Fatalf("endpoint = %s", got.URL)
|
||||
}
|
||||
if got.URL.EscapedPath() != "/v3/pay/transactions/out-trade-no/T-1" || got.URL.RawQuery != "mchid=M-1" {
|
||||
t.Fatalf("signed target changed to %s", got.URL.RequestURI())
|
||||
}
|
||||
if got.Header.Get("Authorization") != request.Header.Get("Authorization") {
|
||||
t.Fatal("authorization header changed while rewriting endpoint")
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header), Request: got}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
if _, err = transport.RoundTrip(request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.URL.Host != "api.mch.weixin.qq.com" {
|
||||
t.Fatal("transport mutated the original request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatV3PrepayResultUsesGoPayClientSigning(t *testing.T) {
|
||||
_, privatePEM, _ := testRSAKeyPair(t)
|
||||
client, err := gopayWechatV3.NewClientV3(
|
||||
"mch-test",
|
||||
"merchant-serial",
|
||||
"01234567890123456789012345678901",
|
||||
privatePEM,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tradeType string
|
||||
assert func(*testing.T, map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "jsapi", tradeType: "jsapi",
|
||||
assert: func(t *testing.T, payload map[string]any) {
|
||||
if payload["appId"] != "wx-test" || payload["package"] != "prepay_id=PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["paySign"])) == "" {
|
||||
t.Fatalf("JSAPI payload = %#v", payload)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "applet", tradeType: "mini_program",
|
||||
assert: func(t *testing.T, payload map[string]any) {
|
||||
if payload["appId"] != "wx-test" || payload["package"] != "prepay_id=PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["paySign"])) == "" {
|
||||
t.Fatalf("applet payload = %#v", payload)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app", tradeType: "app",
|
||||
assert: func(t *testing.T, payload map[string]any) {
|
||||
if payload["appid"] != "wx-test" || payload["partnerid"] != "mch-test" || payload["prepayid"] != "PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["sign"])) == "" {
|
||||
t.Fatalf("App payload = %#v", payload)
|
||||
}
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := wechatV3PrepayCreateResult(client, "wx-test", tc.tradeType, "T-WECHAT-V3", &gopayWechatV3.PrepayRsp{
|
||||
Code: gopayWechatV3.Success,
|
||||
Response: &gopayWechatV3.Prepay{PrepayId: "PREPAY-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.TradeNo != "T-WECHAT-V3" || result.QueryID != "" || result.Status != "created" {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err = json.Unmarshal(result.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.assert(t, payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatV3GoPayCallbackVerificationAndDecryption(t *testing.T) {
|
||||
platformKey, merchantPrivatePEM, platformPublicPEM := testRSAKeyPair(t)
|
||||
const (
|
||||
apiV3Key = "01234567890123456789012345678901"
|
||||
resourceNonce = "0123456789ab"
|
||||
headerNonce = "callback-nonce"
|
||||
timestamp = "1787068800"
|
||||
serial = "ABCDEF123456"
|
||||
)
|
||||
|
||||
plain := []byte(`{"appid":"wx-test","mchid":"mch-test","out_trade_no":"T-WECHAT-V3","transaction_id":"P-WECHAT-V3","trade_state":"SUCCESS"}`)
|
||||
block, err := aes.NewCipher([]byte(apiV3Key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(resourceNonce), plain, []byte("transaction")))
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"id": "EVENT-WECHAT-V3", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", "summary": "payment success",
|
||||
"resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": ciphertext, "associated_data": "transaction", "original_type": "transaction", "nonce": resourceNonce},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signature := testRSA2Sign(t, platformKey, []byte(timestamp+"\n"+headerNonce+"\n"+string(body)+"\n"))
|
||||
callback := &biz.PaymentCallback{
|
||||
Body: body,
|
||||
Headers: map[string]string{
|
||||
gopayWechatV3.HeaderTimestamp: timestamp,
|
||||
gopayWechatV3.HeaderNonce: headerNonce,
|
||||
gopayWechatV3.HeaderSerial: serial,
|
||||
gopayWechatV3.HeaderSignature: signature,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := (&wechatV3Adapter{}).Callback(context.Background(), callback, map[string]any{
|
||||
"api_v3_key": apiV3Key, "platform_cert": platformPublicPEM, "private_key": merchantPrivatePEM,
|
||||
"platform_serial_no": strings.ToLower(serial), "serial_no": "merchant-serial", "app_id": "wx-test", "merchant_id": "mch-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "success" || result.TradeNo != "T-WECHAT-V3" || result.ProviderTradeNo != "P-WECHAT-V3" {
|
||||
t.Fatalf("unexpected callback result: %+v", result)
|
||||
}
|
||||
if callback.Query["event_id"] != "EVENT-WECHAT-V3" {
|
||||
t.Fatalf("event id = %q", callback.Query["event_id"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user