package controller import ( "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "maps" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/Calcium-Ion/go-epay/epay" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func confirmPaymentComplianceForTest(t *testing.T) { t.Helper() paymentSetting := operation_setting.GetPaymentSetting() originalConfirmed := paymentSetting.ComplianceConfirmed originalTermsVersion := paymentSetting.ComplianceTermsVersion t.Cleanup(func() { paymentSetting.ComplianceConfirmed = originalConfirmed paymentSetting.ComplianceTermsVersion = originalTermsVersion }) paymentSetting.ComplianceConfirmed = true paymentSetting.ComplianceTermsVersion = operation_setting.CurrentComplianceTermsVersion } func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { confirmPaymentComplianceForTest(t) originalAPISecret := setting.StripeApiSecret originalWebhookSecret := setting.StripeWebhookSecret originalPriceID := setting.StripePriceId t.Cleanup(func() { setting.StripeApiSecret = originalAPISecret setting.StripeWebhookSecret = originalWebhookSecret setting.StripePriceId = originalPriceID }) setting.StripeWebhookSecret = "" setting.StripeApiSecret = "sk_test_123" setting.StripePriceId = "price_123" require.False(t, isStripeWebhookEnabled()) setting.StripeWebhookSecret = "whsec_test" require.True(t, isStripeWebhookEnabled()) setting.StripePriceId = "" require.False(t, isStripeWebhookEnabled()) } func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { confirmPaymentComplianceForTest(t) originalAPIKey := setting.CreemApiKey originalProducts := setting.CreemProducts originalWebhookSecret := setting.CreemWebhookSecret t.Cleanup(func() { setting.CreemApiKey = originalAPIKey setting.CreemProducts = originalProducts setting.CreemWebhookSecret = originalWebhookSecret }) setting.CreemWebhookSecret = "" setting.CreemApiKey = "creem_api_key" setting.CreemProducts = `[{"productId":"prod_123"}]` require.False(t, isCreemWebhookEnabled()) setting.CreemWebhookSecret = "creem_secret" require.True(t, isCreemWebhookEnabled()) setting.CreemProducts = "[]" require.False(t, isCreemWebhookEnabled()) } func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { confirmPaymentComplianceForTest(t) originalEnabled := setting.WaffoEnabled originalSandbox := setting.WaffoSandbox originalAPIKey := setting.WaffoApiKey originalPrivateKey := setting.WaffoPrivateKey originalPublicCert := setting.WaffoPublicCert originalSandboxAPIKey := setting.WaffoSandboxApiKey originalSandboxPrivateKey := setting.WaffoSandboxPrivateKey originalSandboxPublicCert := setting.WaffoSandboxPublicCert t.Cleanup(func() { setting.WaffoEnabled = originalEnabled setting.WaffoSandbox = originalSandbox setting.WaffoApiKey = originalAPIKey setting.WaffoPrivateKey = originalPrivateKey setting.WaffoPublicCert = originalPublicCert setting.WaffoSandboxApiKey = originalSandboxAPIKey setting.WaffoSandboxPrivateKey = originalSandboxPrivateKey setting.WaffoSandboxPublicCert = originalSandboxPublicCert }) setting.WaffoEnabled = true setting.WaffoSandbox = false setting.WaffoApiKey = "" setting.WaffoPrivateKey = "private" setting.WaffoPublicCert = "public" require.False(t, isWaffoWebhookEnabled()) setting.WaffoApiKey = "api" require.True(t, isWaffoWebhookEnabled()) setting.WaffoEnabled = false require.False(t, isWaffoWebhookEnabled()) setting.WaffoEnabled = true setting.WaffoSandbox = true setting.WaffoSandboxApiKey = "" setting.WaffoSandboxPrivateKey = "sandbox_private" setting.WaffoSandboxPublicCert = "sandbox_public" require.False(t, isWaffoWebhookEnabled()) setting.WaffoSandboxApiKey = "sandbox_api" require.True(t, isWaffoWebhookEnabled()) } func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { confirmPaymentComplianceForTest(t) originalMerchantID := setting.WaffoPancakeMerchantID originalPrivateKey := setting.WaffoPancakePrivateKey originalProductID := setting.WaffoPancakeProductID t.Cleanup(func() { setting.WaffoPancakeMerchantID = originalMerchantID setting.WaffoPancakePrivateKey = originalPrivateKey setting.WaffoPancakeProductID = originalProductID }) // Presence of all three credentials enables the gateway. Webhook public // keys are bundled in the SDK and there is no separate Enabled toggle — // clear any of the three fields to disable. setting.WaffoPancakeMerchantID = "" setting.WaffoPancakePrivateKey = "private" setting.WaffoPancakeProductID = "product" require.False(t, isWaffoPancakeWebhookEnabled()) setting.WaffoPancakeMerchantID = "merchant" require.True(t, isWaffoPancakeWebhookEnabled()) setting.WaffoPancakeProductID = "" require.False(t, isWaffoPancakeWebhookEnabled()) setting.WaffoPancakeProductID = "product" setting.WaffoPancakePrivateKey = "" require.False(t, isWaffoPancakeWebhookEnabled()) } func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { confirmPaymentComplianceForTest(t) originalPayAddress := operation_setting.PayAddress originalEpayID := operation_setting.EpayId originalEpayKey := operation_setting.EpayKey paymentSetting := operation_setting.GetPaymentSetting() originalSignType := paymentSetting.EpaySignType originalMerchantPrivateKey := paymentSetting.EpayMerchantPrivateKey originalPlatformPublicKey := paymentSetting.EpayPlatformPublicKey originalPayMethods := operation_setting.PayMethods t.Cleanup(func() { operation_setting.PayAddress = originalPayAddress operation_setting.EpayId = originalEpayID operation_setting.EpayKey = originalEpayKey paymentSetting.EpaySignType = originalSignType paymentSetting.EpayMerchantPrivateKey = originalMerchantPrivateKey paymentSetting.EpayPlatformPublicKey = originalPlatformPublicKey operation_setting.PayMethods = originalPayMethods }) operation_setting.PayAddress = "https://pay.example.com" operation_setting.EpayId = "epay_id" operation_setting.EpayKey = "" paymentSetting.EpaySignType = operation_setting.EpaySignTypeMD5 operation_setting.PayMethods = []map[string]string{{"type": "alipay"}} require.False(t, isEpayWebhookEnabled()) operation_setting.EpayKey = "epay_key" require.True(t, isEpayWebhookEnabled()) operation_setting.PayMethods = nil require.False(t, isEpayWebhookEnabled()) // RSA needs both keys, and the MD5 key no longer counts. operation_setting.PayMethods = []map[string]string{{"type": "alipay"}} paymentSetting.EpaySignType = operation_setting.EpaySignTypeRSA paymentSetting.EpayMerchantPrivateKey = "merchant_private_key" paymentSetting.EpayPlatformPublicKey = "" require.False(t, isEpayWebhookEnabled()) paymentSetting.EpayPlatformPublicKey = "platform_public_key" operation_setting.EpayKey = "" require.True(t, isEpayWebhookEnabled()) } // epayCallbackCase is one callback sent to every Epay callback endpoint. type epayCallbackCase struct { name string params map[string]string validPending bool } // setupEpayCallbackTest configures an Epay gateway and leaves model.DB nil: // rejected callbacks and valid non-success events must never reach settlement, // so an accidental attempt to settle fails the test. func setupEpayCallbackTest(t *testing.T) { t.Helper() confirmPaymentComplianceForTest(t) originalPayAddress := operation_setting.PayAddress originalEpayID := operation_setting.EpayId originalEpayKey := operation_setting.EpayKey paymentSetting := operation_setting.GetPaymentSetting() originalSignType := paymentSetting.EpaySignType originalMerchantPrivateKey := paymentSetting.EpayMerchantPrivateKey originalPlatformPublicKey := paymentSetting.EpayPlatformPublicKey originalPayMethods := operation_setting.PayMethods originalServerAddress := system_setting.ServerAddress originalDB := model.DB t.Cleanup(func() { operation_setting.PayAddress = originalPayAddress operation_setting.EpayId = originalEpayID operation_setting.EpayKey = originalEpayKey paymentSetting.EpaySignType = originalSignType paymentSetting.EpayMerchantPrivateKey = originalMerchantPrivateKey paymentSetting.EpayPlatformPublicKey = originalPlatformPublicKey operation_setting.PayMethods = originalPayMethods system_setting.ServerAddress = originalServerAddress model.DB = originalDB }) operation_setting.PayAddress = "https://pay.example.com" operation_setting.EpayId = "epay_id" operation_setting.PayMethods = []map[string]string{{"type": "alipay"}} system_setting.ServerAddress = "https://dashboard.example.com" model.DB = nil } // assertEpayCallbacksVerifyBeforeSettlement sends each callback to the top-up // and subscription endpoints by GET and POST. Only a valid pending callback is // accepted; every other callback is rejected. func assertEpayCallbacksVerifyBeforeSettlement(t *testing.T, cases []epayCallbackCase) { t.Helper() endpoints := []struct { name string path string handler gin.HandlerFunc status int rejectedBody string pendingBody string rejectedLocation string pendingLocation string }{ { name: "topup notify", path: "/api/user/epay/notify", handler: EpayNotify, status: http.StatusOK, rejectedBody: "fail", pendingBody: "success", }, { name: "subscription notify", path: "/api/subscription/epay/notify", handler: SubscriptionEpayNotify, status: http.StatusOK, rejectedBody: "fail", pendingBody: "fail", }, { name: "subscription return", path: "/api/subscription/epay/return", handler: SubscriptionEpayReturn, status: http.StatusFound, rejectedLocation: "https://dashboard.example.com/wallet?pay=fail", pendingLocation: "https://dashboard.example.com/wallet?pay=pending", }, } router := gin.New() for _, endpoint := range endpoints { router.Any(endpoint.path, endpoint.handler) } for _, endpoint := range endpoints { for _, method := range []string{http.MethodGet, http.MethodPost} { for _, tc := range cases { t.Run(endpoint.name+"/"+method+"/"+tc.name, func(t *testing.T) { params := url.Values{} for key, value := range tc.params { params.Set(key, value) } request := httptest.NewRequest(method, endpoint.path+"?"+params.Encode(), nil) if method == http.MethodPost { request = httptest.NewRequest(method, endpoint.path, strings.NewReader(params.Encode())) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") } recorder := httptest.NewRecorder() require.NotPanics(t, func() { router.ServeHTTP(recorder, request) }) assert.Equal(t, endpoint.status, recorder.Code) wantBody, wantLocation := endpoint.rejectedBody, endpoint.rejectedLocation if tc.validPending { wantBody, wantLocation = endpoint.pendingBody, endpoint.pendingLocation } if wantBody != "" { assert.Equal(t, wantBody, recorder.Body.String()) } assert.Equal(t, wantLocation, recorder.Header().Get("Location")) }) } } } } // newEpayRSAKeyPairForTest returns a throwaway key pair as single-line Base64 // text, the format Epay merchant consoles show. func newEpayRSAKeyPairForTest(t *testing.T) (string, string) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) privateDER, err := x509.MarshalPKCS8PrivateKey(key) require.NoError(t, err) publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) require.NoError(t, err) return base64.StdEncoding.EncodeToString(privateDER), base64.StdEncoding.EncodeToString(publicDER) } func TestEpayCallbacksVerifyBeforeSettlement(t *testing.T) { setupEpayCallbackTest(t) paymentSetting := operation_setting.GetPaymentSetting() paymentSetting.EpaySignType = operation_setting.EpaySignTypeMD5 operation_setting.EpayKey = "test-key" require.True(t, isEpayWebhookEnabled()) // Both ambiguous maps below had this same valid signing string in v0.0.4. // This must fail because of parameter ambiguity, not a mismatched signature. ambiguousSign := epay.MD5String("money=1.00&name=item&out_trade_no=other&out_trade_no=order&pid=epay_id&trade_status=TRADE_SUCCESS&type=alipay", "test-key") pendingSign := epay.MD5String("money=1.00&name=item%26other&out_trade_no=order&pid=epay_id&trade_status=WAIT_BUYER_PAY&type=alipay", "test-key") rows := []struct { name string productName string tradeNo string status string sign string validPending bool }{ {"separator in product name", "item&out_trade_no=other", "order", epay.StatusTradeSuccess, ambiguousSign, false}, {"separator in order number", "item", "other&out_trade_no=order", epay.StatusTradeSuccess, ambiguousSign, false}, {"invalid signature", "item", "order", epay.StatusTradeSuccess, "invalid", false}, {"missing signature", "item", "order", epay.StatusTradeSuccess, "", false}, {"valid pending with literal percent escape", "item%26other", "order", "WAIT_BUYER_PAY", pendingSign, true}, } cases := make([]epayCallbackCase, 0, len(rows)) for _, row := range rows { params := map[string]string{ "pid": "epay_id", "type": "alipay", "money": "1.00", "name": row.productName, "out_trade_no": row.tradeNo, "trade_status": row.status, "sign_type": "MD5", } if row.sign != "" { params["sign"] = row.sign } cases = append(cases, epayCallbackCase{name: row.name, params: params, validPending: row.validPending}) } assertEpayCallbacksVerifyBeforeSettlement(t, cases) } func TestEpayRSAModeSignsOrdersAndVerifiesCallbacks(t *testing.T) { setupEpayCallbackTest(t) paymentSetting := operation_setting.GetPaymentSetting() merchantPrivateKey, merchantPublicKey := newEpayRSAKeyPairForTest(t) platformPrivateKey, platformPublicKey := newEpayRSAKeyPairForTest(t) paymentSetting.EpaySignType = operation_setting.EpaySignTypeRSA // A leftover MD5 key must not be accepted once RSA is selected. operation_setting.EpayKey = "test-key" paymentSetting.EpayMerchantPrivateKey = merchantPrivateKey paymentSetting.EpayPlatformPublicKey = platformPublicKey require.True(t, isEpayWebhookEnabled()) client := GetEpayClient() require.NotNil(t, client) notifyURL, err := url.Parse("https://dashboard.example.com/api/user/epay/notify") require.NoError(t, err) returnURL, err := url.Parse("https://dashboard.example.com/usage-logs") require.NoError(t, err) paymentURL, order, err := client.Purchase(&epay.PurchaseArgs{ Type: "alipay", ServiceTradeNo: "order", Name: "TUC1", Money: "1.00", Device: epay.PC, NotifyUrl: notifyURL, ReturnUrl: returnURL, }) require.NoError(t, err) assert.Equal(t, "https://pay.example.com/api/pay/submit", paymentURL) assert.Equal(t, epay.SignTypeRSA, order["sign_type"]) // The platform side holds the merchant public key and signs callbacks with its own private key. platform, err := epay.NewClient(&epay.Config{ PartnerID: "epay_id", SignType: epay.SignTypeRSA, MerchantPrivateKey: platformPrivateKey, PlatformPublicKey: merchantPublicKey, }, "https://pay.example.com") require.NoError(t, err) verifiedOrder, err := platform.Verify(order) require.NoError(t, err) assert.True(t, verifiedOrder.VerifyStatus) pending, err := platform.SignParams(map[string]string{ "type": "alipay", "money": "1.00", "name": "item", "out_trade_no": "order", "trade_status": "WAIT_BUYER_PAY", }) require.NoError(t, err) paidFields := map[string]string{ "type": "alipay", "money": "1.00", "name": "item", "out_trade_no": "order", "trade_status": epay.StatusTradeSuccess, } paid, err := platform.SignParams(paidFields) require.NoError(t, err) tampered := maps.Clone(paid) tampered["money"] = "100.00" merchantSigned, err := client.SignParams(paidFields) require.NoError(t, err) md5Client, err := epay.NewClient(&epay.Config{PartnerID: "epay_id", Key: "test-key"}, "https://pay.example.com") require.NoError(t, err) md5Signed, err := md5Client.SignParams(paidFields) require.NoError(t, err) unsigned := maps.Clone(paid) delete(unsigned, "sign") assertEpayCallbacksVerifyBeforeSettlement(t, []epayCallbackCase{ {name: "valid pending signed by platform", params: pending, validPending: true}, {name: "tampered amount", params: tampered}, {name: "signed with merchant key", params: merchantSigned}, {name: "MD5 signature in RSA mode", params: md5Signed}, {name: "missing signature", params: unsigned}, }) } func TestUpdateOptionValidatesEpaySignSettings(t *testing.T) { db := setupModelListControllerTestDB(t) require.NoError(t, db.AutoMigrate(&model.Option{})) originalOptionMap := common.OptionMap paymentSetting := operation_setting.GetPaymentSetting() originalSignType := paymentSetting.EpaySignType originalMerchantPrivateKey := paymentSetting.EpayMerchantPrivateKey originalPlatformPublicKey := paymentSetting.EpayPlatformPublicKey t.Cleanup(func() { common.OptionMap = originalOptionMap paymentSetting.EpaySignType = originalSignType paymentSetting.EpayMerchantPrivateKey = originalMerchantPrivateKey paymentSetting.EpayPlatformPublicKey = originalPlatformPublicKey }) // A site upgraded from an MD5-only release has no saved RSA settings. common.OptionMap = map[string]string{ operation_setting.EpaySignTypeOptionKey: operation_setting.EpaySignTypeMD5, operation_setting.EpayMerchantPrivateKeyOptionKey: "", operation_setting.EpayPlatformPublicKeyOptionKey: "", } paymentSetting.EpaySignType = operation_setting.EpaySignTypeMD5 paymentSetting.EpayMerchantPrivateKey = "" paymentSetting.EpayPlatformPublicKey = "" signTypeKey := operation_setting.EpaySignTypeOptionKey privateKeyKey := operation_setting.EpayMerchantPrivateKeyOptionKey publicKeyKey := operation_setting.EpayPlatformPublicKeyOptionKey merchantPrivateKey, _ := newEpayRSAKeyPairForTest(t) _, platformPublicKey := newEpayRSAKeyPairForTest(t) const privateKeyError = "The Epay merchant private key is missing or invalid" const publicKeyError = "The Epay platform public key is missing or invalid" steps := []struct { name string key string value string wantError string }{ {"unknown signature type", signTypeKey, "SHA1", "Epay signature type must be MD5 or RSA"}, {"RSA before keys are saved", signTypeKey, "RSA", privateKeyError}, {"malformed private key", privateKeyKey, "not a key", privateKeyError}, {"public key in the private key field", privateKeyKey, platformPublicKey, privateKeyError}, {"save private key", privateKeyKey, merchantPrivateKey, ""}, {"RSA before the public key is saved", signTypeKey, "RSA", publicKeyError}, {"private key in the public key field", publicKeyKey, merchantPrivateKey, publicKeyError}, {"save public key", publicKeyKey, platformPublicKey, ""}, {"switch to RSA", signTypeKey, "RSA", ""}, {"clear private key while RSA is active", privateKeyKey, "", privateKeyError}, {"switch back to MD5", signTypeKey, "MD5", ""}, } for _, step := range steps { err := model.UpdateOption(step.key, step.value) if step.wantError == "" { require.NoError(t, err, step.name) continue } require.Error(t, err, step.name) assert.Equal(t, step.wantError, err.Error(), step.name) } assert.Equal(t, operation_setting.EpaySignTypeMD5, paymentSetting.EpaySignType) assert.Equal(t, merchantPrivateKey, paymentSetting.EpayMerchantPrivateKey) assert.Equal(t, platformPublicKey, paymentSetting.EpayPlatformPublicKey) // The private key is never sent back to the settings page; the public key may be. recorder := httptest.NewRecorder() context, _ := gin.CreateTestContext(recorder) context.Request = httptest.NewRequest(http.MethodGet, "/api/option/", nil) GetOptions(context) var response struct { Success bool `json:"success"` Data []model.Option `json:"data"` } require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) require.True(t, response.Success) returned := make(map[string]string, len(response.Data)) for _, option := range response.Data { returned[option.Key] = option.Value } assert.Equal(t, operation_setting.EpaySignTypeMD5, returned[signTypeKey]) assert.Equal(t, platformPublicKey, returned[publicKeyKey]) assert.NotContains(t, returned, privateKeyKey) }