diff --git a/controller/payment_webhook_availability_test.go b/controller/payment_webhook_availability_test.go index 002428be0b..ab596d5b4a 100644 --- a/controller/payment_webhook_availability_test.go +++ b/controller/payment_webhook_availability_test.go @@ -1,10 +1,19 @@ package controller import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" "testing" + "github.com/Calcium-Ion/go-epay/epay" + "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" ) @@ -167,3 +176,110 @@ func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { operation_setting.PayMethods = nil require.False(t, isEpayWebhookEnabled()) } + +func TestEpayCallbacksVerifyBeforeSettlement(t *testing.T) { + confirmPaymentComplianceForTest(t) + originalPayAddress := operation_setting.PayAddress + originalEpayID := operation_setting.EpayId + originalEpayKey := operation_setting.EpayKey + 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 + 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.EpayKey = "test-key" + operation_setting.PayMethods = []map[string]string{{"type": "alipay"}} + system_setting.ServerAddress = "https://dashboard.example.com" + // Rejected callbacks and valid non-success events must never reach settlement. + // A nil database makes an accidental attempt to settle fail the test. + model.DB = nil + 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") + cases := []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}, + } + 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{ + "pid": {"epay_id"}, "type": {"alipay"}, "money": {"1.00"}, + "name": {tc.productName}, "out_trade_no": {tc.tradeNo}, + "trade_status": {tc.status}, "sign_type": {"MD5"}, + } + if tc.sign != "" { + params.Set("sign", tc.sign) + } + 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")) + }) + } + } + } +} diff --git a/go.mod b/go.mod index 7bd69bfe5c..f2dd13d392 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ module github.com/QuantumNous/new-api go 1.25.1 require ( - github.com/Calcium-Ion/go-epay v0.0.4 + github.com/Calcium-Ion/go-epay v0.0.5 github.com/abema/go-mp4 v1.4.1 github.com/andybalholm/brotli v1.2.0 github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 diff --git a/go.sum b/go.sum index ac1091ccff..73b2edd4cf 100644 --- a/go.sum +++ b/go.sum @@ -631,8 +631,8 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A= -github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U= +github.com/Calcium-Ion/go-epay v0.0.5 h1:TgIx44c/bhdFqso4kbmDzZFzgyDJLma5hMDNA6sOXbo= +github.com/Calcium-Ion/go-epay v0.0.5/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U= github.com/Calcium-Ion/moejs v0.1.0-alpha.5 h1:hkTuLRIhtyXMPQciULZHTbdrw/LEJYvO2MDzl+/F1Y8= github.com/Calcium-Ion/moejs v0.1.0-alpha.5/go.mod h1:9YqHj36EV7pzV/vlEYSw49f3oggeJFdmqcjM9TmDpHs= github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw=