package payment import ( "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "fmt" bizpayment "kra/internal/biz/payment" "net" "net/http" "net/http/httptest" "strings" "testing" "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(), &bizpayment.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(), &bizpayment.PaymentRequest{ Provider: bizpayment.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(), &bizpayment.PaymentRequest{ Provider: bizpayment.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) } } }