kra-new/app/system/internal/integration/payment/identity_test.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/app/system/internal/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, &params); 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, &params); 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)
}
}
}