387 lines
15 KiB
Go
387 lines
15 KiB
Go
package payment
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"kra/internal/modules/system/biz"
|
|
|
|
"github.com/go-pay/gopay"
|
|
"github.com/go-pay/gopay/allinpay"
|
|
"github.com/go-pay/gopay/douyin"
|
|
"github.com/go-pay/gopay/lakala"
|
|
)
|
|
|
|
func paymentIdentityRSAKeyPair(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)
|
|
}
|
|
|
|
// GoPay clients use fixed provider hosts. Redirect their HTTPS transport to a
|
|
// local server so adapter tests still exercise the SDK request and response path.
|
|
func redirectPaymentHTTPS(t *testing.T, handler http.Handler) {
|
|
t.Helper()
|
|
server := httptest.NewTLSServer(handler)
|
|
oldDefault := http.DefaultTransport
|
|
base, ok := oldDefault.(*http.Transport)
|
|
if !ok {
|
|
server.Close()
|
|
t.Fatal("http.DefaultTransport is not *http.Transport")
|
|
}
|
|
transport := base.Clone()
|
|
transport.Proxy = nil
|
|
transport.DisableKeepAlives = true
|
|
transport.ForceAttemptHTTP2 = false
|
|
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true} //nolint:gosec // local test server
|
|
target := server.Listener.Addr().String()
|
|
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, network, target)
|
|
}
|
|
http.DefaultTransport = transport
|
|
t.Cleanup(func() {
|
|
http.DefaultTransport = oldDefault
|
|
transport.CloseIdleConnections()
|
|
server.Close()
|
|
})
|
|
}
|
|
|
|
func TestLakalaKeepsQueryAndFinalTradeIdentitiesSeparate(t *testing.T) {
|
|
created, err := lakalaCreateResult("MERCHANT-LAKALA-1", &lakala.PaymentRsp{
|
|
ErrorCode: lakala.ErrorCode{ReturnCode: "SUCCESS", ResultCode: "SUCCESS"},
|
|
OrderId: " LAKALA-ORDER-1 ",
|
|
PartnerOrderId: "MERCHANT-LAKALA-1",
|
|
CodeUrl: "https://pay.example/qr",
|
|
SdkParams: `{"prepay":"client-only"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created.QueryID != "LAKALA-ORDER-1" || created.ProviderTradeNo != "" {
|
|
t.Fatalf("create identities = query %q, provider trade %q", created.QueryID, created.ProviderTradeNo)
|
|
}
|
|
|
|
redirectPaymentHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if r.URL.Path != "/api/v1.0/gateway/partners/PART/orders/LAKALA-ORDER-1" {
|
|
t.Errorf("query path = %q", r.URL.Path)
|
|
}
|
|
_, _ = fmt.Fprint(w, `{"return_code":"SUCCESS","result_code":"PAY_SUCCESS","order_id":"LAKALA-ORDER-1","partner_order_id":"MERCHANT-LAKALA-1","channel_order_id":"CHANNEL-LAKALA-1","total_fee":100,"real_fee":100,"currency":"JPY"}`)
|
|
case http.MethodPut:
|
|
if r.URL.Path != "/api/v1.0/gateway/partners/PART/orders/LAKALA-ORDER-1/refunds/REFUND-LAKALA-1" {
|
|
t.Errorf("refund path = %q", r.URL.Path)
|
|
}
|
|
_, _ = fmt.Fprint(w, `{"return_code":"SUCCESS","result_code":"SUCCESS","refund_id":"LAKALA-REFUND-1","partner_refund_id":"REFUND-LAKALA-1","amount":40,"currency":"JPY"}`)
|
|
default:
|
|
t.Errorf("unexpected method %s", r.Method)
|
|
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
|
}
|
|
}))
|
|
|
|
config := map[string]any{
|
|
"partner_code": "PART", "credential_code": "credential",
|
|
"order_id": "STATIC-CONFIG-MUST-NOT-BE-USED", "currency": "JPY",
|
|
}
|
|
result, err := (&lakalaAdapter{}).Query(context.Background(), created.QueryID, config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.TradeNo != "MERCHANT-LAKALA-1" || result.QueryID != "LAKALA-ORDER-1" || result.ProviderTradeNo != "CHANNEL-LAKALA-1" {
|
|
t.Fatalf("query identities = %+v", result)
|
|
}
|
|
if result.Status != "success" || result.Amount != 100 || result.Currency != "JPY" {
|
|
t.Fatalf("query result = %+v", result)
|
|
}
|
|
|
|
refund, err := (&lakalaAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
|
|
TradeNo: "MERCHANT-LAKALA-1", QueryID: created.QueryID,
|
|
RefundNo: "REFUND-LAKALA-1", Amount: 40, Currency: "JPY",
|
|
}, config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if refund.TradeNo != "MERCHANT-LAKALA-1" || refund.ProviderTradeNo != "LAKALA-REFUND-1" {
|
|
t.Fatalf("refund result = %+v", refund)
|
|
}
|
|
}
|
|
|
|
func TestLakalaCreateRejectsMissingOrderID(t *testing.T) {
|
|
_, err := lakalaCreateResult("MERCHANT-LAKALA-MISSING", &lakala.PaymentRsp{
|
|
ErrorCode: lakala.ErrorCode{ReturnCode: "SUCCESS", ResultCode: "SUCCESS"},
|
|
PartnerOrderId: "MERCHANT-LAKALA-MISSING",
|
|
CodeUrl: "https://pay.example/qr",
|
|
})
|
|
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "order_id") {
|
|
t.Fatalf("missing order_id error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLakalaQueryRejectsMissingMerchantOrderID(t *testing.T) {
|
|
redirectPaymentHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1.0/gateway/partners/PART/orders/LAKALA-ORDER-MISSING-MERCHANT" {
|
|
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
_, _ = fmt.Fprint(w, `{"return_code":"SUCCESS","result_code":"PAY_SUCCESS","order_id":"LAKALA-ORDER-MISSING-MERCHANT","channel_order_id":"CHANNEL-LAKALA-1","total_fee":100,"currency":"JPY"}`)
|
|
}))
|
|
|
|
_, err := (&lakalaAdapter{}).Query(context.Background(), "LAKALA-ORDER-MISSING-MERCHANT", map[string]any{
|
|
"partner_code": "PART", "credential_code": "credential",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "商户订单号") {
|
|
t.Fatalf("missing merchant order id error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLakalaCreateRoutesSupportedMethods(t *testing.T) {
|
|
type testCase struct {
|
|
name string
|
|
method string
|
|
configMethod string
|
|
tradeNo string
|
|
path string
|
|
appID string
|
|
}
|
|
cases := []testCase{
|
|
{name: "default jsapi", tradeNo: "L-JSAPI-DEFAULT", path: "/api/v1.0/jsapi_gateway/partners/PART/orders/L-JSAPI-DEFAULT"},
|
|
{name: "jsapi", method: "jsapi", tradeNo: "L-JSAPI", path: "/api/v1.0/jsapi_gateway/partners/PART/orders/L-JSAPI"},
|
|
{name: "h5", method: "h5", tradeNo: "L-H5", path: "/api/v1.0/h5_payment/partners/PART/orders/L-H5"},
|
|
{name: "mini", method: "mini", tradeNo: "L-MINI", path: "/api/v1.0/gateway/partners/PART/microapp_orders/L-MINI"},
|
|
{name: "native", method: "native", tradeNo: "L-NATIVE", path: "/api/v1.0/gateway/partners/PART/native_orders/L-NATIVE"},
|
|
{name: "qrcode", method: "qrcode", tradeNo: "L-QRCODE", path: "/api/v1.0/gateway/partners/PART/orders/L-QRCODE"},
|
|
{name: "native jsapi", method: "native_jsapi", tradeNo: "L-NATIVE-JSAPI", path: "/api/v1.0/gateway/partners/PART/native_jsapi/L-NATIVE-JSAPI", appID: "wx-test-app"},
|
|
{name: "sdk", method: "sdk", tradeNo: "L-SDK", path: "/api/v1.0/gateway/partners/PART/app_orders/L-SDK", appID: "wx-test-app"},
|
|
{name: "web", method: "web", tradeNo: "L-WEB", path: "/api/v1.0/web_gateway/partners/PART/orders/L-WEB"},
|
|
{name: "config method", configMethod: "h5", tradeNo: "L-CONFIG-H5", path: "/api/v1.0/h5_payment/partners/PART/orders/L-CONFIG-H5"},
|
|
}
|
|
expected := make(map[string]string, len(cases))
|
|
for _, tc := range cases {
|
|
expected[tc.path] = tc.tradeNo
|
|
}
|
|
redirectPaymentHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
t.Errorf("create method = %s, want PUT", r.Method)
|
|
}
|
|
tradeNo, ok := expected[r.URL.Path]
|
|
if !ok {
|
|
t.Errorf("unexpected Lakala create path = %q", r.URL.Path)
|
|
http.Error(w, "unexpected path", http.StatusNotFound)
|
|
return
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode create body: %v", err)
|
|
}
|
|
if body["description"] != "subject" || fmt.Sprint(body["price"]) != "100" || body["channel"] != "Wechat" {
|
|
t.Errorf("create body = %#v", body)
|
|
}
|
|
_, _ = fmt.Fprintf(w, `{"return_code":"SUCCESS","result_code":"SUCCESS","order_id":"ORDER-%s","partner_order_id":"%s","code_url":"https://pay.example/%s","pay_url":"https://pay.example/pay/%s","sdk_params":"{\"prepay\":\"%s\"}"}`, tradeNo, tradeNo, tradeNo, tradeNo, tradeNo)
|
|
}))
|
|
|
|
adapter := &lakalaAdapter{}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
extra := map[string]any{}
|
|
if tc.method != "" {
|
|
extra["method"] = tc.method
|
|
}
|
|
if tc.appID != "" {
|
|
extra["appid"] = tc.appID
|
|
}
|
|
config := map[string]any{
|
|
"partner_code": "PART", "credential_code": "credential", "channel": "Wechat", "currency": "JPY",
|
|
}
|
|
if tc.configMethod != "" {
|
|
config["method"] = tc.configMethod
|
|
}
|
|
result, err := adapter.Create(context.Background(), &biz.PaymentRequest{
|
|
Provider: biz.PaymentLakala, TradeNo: tc.tradeNo, Subject: "subject", Amount: 100,
|
|
Currency: "JPY", Extra: extra,
|
|
}, config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.QueryID != "ORDER-"+tc.tradeNo || result.ProviderTradeNo != "" {
|
|
t.Fatalf("identities = %+v", result)
|
|
}
|
|
var payload lakala.PaymentRsp
|
|
if err := json.Unmarshal(result.Payload, &payload); err != nil {
|
|
t.Fatalf("decode payload: %v", err)
|
|
}
|
|
if payload.OrderId != "ORDER-"+tc.tradeNo || payload.PartnerOrderId != tc.tradeNo {
|
|
t.Fatalf("payload = %+v", payload)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLakalaCreateRejectsUnknownMethod(t *testing.T) {
|
|
_, err := (&lakalaAdapter{}).Create(context.Background(), &biz.PaymentRequest{
|
|
Provider: biz.PaymentLakala, TradeNo: "L-UNKNOWN", Subject: "subject", Amount: 100,
|
|
Currency: "JPY", Extra: map[string]any{"method": "unsupported"},
|
|
}, map[string]any{"partner_code": "PART", "credential_code": "credential", "channel": "Wechat"})
|
|
if err == nil || !strings.Contains(err.Error(), "不支持的下单方式") {
|
|
t.Fatalf("unknown method error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAllinPayPersistsQueryIDOnlyForTransactionLookupMode(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
config map[string]any
|
|
want string
|
|
}{
|
|
{name: "default reqsn", config: map[string]any{}},
|
|
{name: "explicit reqsn", config: map[string]any{"query_order_type": allinpay.OrderTypeReqSN}},
|
|
{name: "trxid", config: map[string]any{"query_order_type": strings.ToUpper(allinpay.OrderTypeTrxId)}, want: "TRX-1"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
orderType, err := allinpayOrderType(tc.config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := allinpayCreateQueryID(orderType, " TRX-1 ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != tc.want {
|
|
t.Fatalf("query id = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAllinPayQueryIdentityMatchesConfiguredLookupMode(t *testing.T) {
|
|
key, _, _ := paymentIdentityRSAKeyPair(t)
|
|
privateKey := base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(key))
|
|
publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publicKey := base64.StdEncoding.EncodeToString(publicDER)
|
|
for _, tc := range []struct {
|
|
name string
|
|
orderType string
|
|
queryID string
|
|
merchantNo string
|
|
wantResultKey string
|
|
}{
|
|
{name: "merchant order number", queryID: "MERCHANT-ALLIN-1", merchantNo: "MERCHANT-ALLIN-1"},
|
|
{name: "provider transaction number", orderType: allinpay.OrderTypeTrxId, queryID: "TRX-ALLIN-1", merchantNo: "MERCHANT-ALLIN-1", wantResultKey: "TRX-ALLIN-1"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
redirectPaymentHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/apiweb/tranx/query" {
|
|
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Errorf("parse form: %v", err)
|
|
}
|
|
if tc.orderType == allinpay.OrderTypeTrxId {
|
|
if r.Form.Get("trxid") != tc.queryID || r.Form.Get("reqsn") != "" {
|
|
t.Errorf("transaction query form = %v", r.Form)
|
|
}
|
|
} else if r.Form.Get("reqsn") != tc.queryID || r.Form.Get("trxid") != "" {
|
|
t.Errorf("merchant query form = %v", r.Form)
|
|
}
|
|
response := signedAllinPayResponse(t, key, gopay.BodyMap{
|
|
"retcode": "SUCCESS", "retmsg": "ok", "reqsn": tc.merchantNo,
|
|
"trxid": "TRX-ALLIN-1", "trxstatus": "SUCCESS", "trxamt": "100",
|
|
})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(response)
|
|
}))
|
|
|
|
config := map[string]any{
|
|
"cus_id": "customer", "app_id": "app", "private_key": privateKey,
|
|
"public_key": publicKey, "environment": "sandbox", "currency": "CNY",
|
|
}
|
|
if tc.orderType != "" {
|
|
config["query_order_type"] = tc.orderType
|
|
}
|
|
result, err := (&allinpayAdapter{}).Query(context.Background(), tc.queryID, config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.TradeNo != tc.merchantNo || result.ProviderTradeNo != "TRX-ALLIN-1" || result.QueryID != tc.wantResultKey {
|
|
t.Fatalf("query identities = %+v", result)
|
|
}
|
|
if result.Status != "success" || result.Amount != 100 || result.Currency != "CNY" {
|
|
t.Fatalf("query result = %+v", result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDouyinPrepayPayloadSignaturesAreVerifiable(t *testing.T) {
|
|
key, privatePEM, _ := paymentIdentityRSAKeyPair(t)
|
|
client, err := douyin.NewClient("merchant-douyin", "merchant-serial", "01234567890123456789012345678901", privatePEM)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, app := range []bool{false, true} {
|
|
result, err := douyinPrepayResult(client, "douyin-app", "MERCHANT-DOUYIN-1", "PREPAY-DOUYIN-1", app)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.QueryID != "" || result.ProviderTradeNo != "" {
|
|
t.Fatalf("prepay id leaked into durable identity: %+v", result)
|
|
}
|
|
|
|
var content, signature string
|
|
if app {
|
|
var params douyin.AppPayParams
|
|
if err = json.Unmarshal(result.Payload, ¶ms); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if params.AppId != "douyin-app" || params.PartnerId != "merchant-douyin" || params.PrepayId != "PREPAY-DOUYIN-1" {
|
|
t.Fatalf("app params = %+v", params)
|
|
}
|
|
content = params.AppId + "\n" + params.Timestamp + "\n" + params.NonceStr + "\n" + params.PrepayId + "\n"
|
|
signature = params.Sign
|
|
} else {
|
|
var params douyin.JSAPIPayParams
|
|
if err = json.Unmarshal(result.Payload, ¶ms); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if params.AppId != "douyin-app" || params.Package != "prepay_id=PREPAY-DOUYIN-1" || params.SignType != douyin.SignTypeRSA {
|
|
t.Fatalf("JSAPI params = %+v", params)
|
|
}
|
|
content = params.AppId + "\n" + params.TimeStamp + "\n" + params.NonceStr + "\n" + params.Package + "\n"
|
|
signature = params.PaySign
|
|
}
|
|
signatureBytes, err := base64.StdEncoding.DecodeString(signature)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256([]byte(content))
|
|
if err = rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], signatureBytes); err != nil {
|
|
t.Fatalf("app=%v signature verification: %v", app, err)
|
|
}
|
|
}
|
|
}
|