fix(payment): restore upgrade-safe payment flows
This commit is contained in:
@@ -471,11 +471,12 @@ func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService {
|
func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService {
|
||||||
|
var legacyKey []byte
|
||||||
key, err := payment.ProvideEncryptionKey(h.cfg)
|
key, err := payment.ProvideEncryptionKey(h.cfg)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return service.NewPaymentResumeService(nil)
|
legacyKey = []byte(key)
|
||||||
}
|
}
|
||||||
return service.NewPaymentResumeService([]byte(key))
|
return service.NewLegacyAwarePaymentResumeService(legacyKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
type completeWeChatOAuthRequest struct {
|
type completeWeChatOAuthRequest struct {
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T)
|
|||||||
handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings("mp", "wx-mp-app", "wx-mp-secret", "/auth/wechat/callback"))
|
handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings("mp", "wx-mp-app", "wx-mp-secret", "/auth/wechat/callback"))
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
handler.cfg.Totp.EncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
handler.cfg.Totp.EncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
handler.cfg.Totp.EncryptionKeyConfigured = true
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(recorder)
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
@@ -415,6 +416,67 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T)
|
|||||||
require.Equal(t, "/purchase?from=wechat", claims.RedirectTo)
|
require.Equal(t, "/purchase?from=wechat", claims.RedirectTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeChatPaymentOAuthCallbackUsesExplicitPaymentResumeSigningKeyWhenMixedKeysConfigured(t *testing.T) {
|
||||||
|
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||||
|
t.Cleanup(func() {
|
||||||
|
wechatOAuthAccessTokenURL = originalAccessTokenURL
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/sns/oauth2/access_token") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"wechat-access","openid":"openid-mixed-key","scope":"snsapi_base"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
|
||||||
|
|
||||||
|
handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings("mp", "wx-mp-app", "wx-mp-secret", "/auth/wechat/callback"))
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
legacyKeyHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
explicitSigningKey := "explicit-payment-resume-signing-key"
|
||||||
|
t.Setenv("PAYMENT_RESUME_SIGNING_KEY", explicitSigningKey)
|
||||||
|
handler.cfg.Totp.EncryptionKey = legacyKeyHex
|
||||||
|
handler.cfg.Totp.EncryptionKeyConfigured = true
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/payment/callback?code=wechat-code&state=state-mixed", nil)
|
||||||
|
req.Host = "api.example.com"
|
||||||
|
req.AddCookie(encodedCookie(wechatPaymentOAuthStateName, "state-mixed"))
|
||||||
|
req.AddCookie(encodedCookie(wechatPaymentOAuthRedirect, "/purchase?from=wechat"))
|
||||||
|
req.AddCookie(encodedCookie(wechatPaymentOAuthContextName, `{"payment_type":"wxpay","amount":"18.8","order_type":"subscription","plan_id":9}`))
|
||||||
|
req.AddCookie(encodedCookie(wechatPaymentOAuthScope, "snsapi_base"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.WeChatPaymentOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
location := recorder.Header().Get("Location")
|
||||||
|
parsed, err := url.Parse(location)
|
||||||
|
require.NoError(t, err)
|
||||||
|
fragment, err := url.ParseQuery(parsed.Fragment)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
token := fragment.Get("wechat_resume_token")
|
||||||
|
require.NotEmpty(t, token)
|
||||||
|
|
||||||
|
claims, err := service.NewPaymentResumeService([]byte(explicitSigningKey)).ParseWeChatPaymentResumeToken(token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "openid-mixed-key", claims.OpenID)
|
||||||
|
require.Equal(t, payment.TypeWxpay, claims.PaymentType)
|
||||||
|
require.Equal(t, "18.8", claims.Amount)
|
||||||
|
require.Equal(t, payment.OrderTypeSubscription, claims.OrderType)
|
||||||
|
require.EqualValues(t, 9, claims.PlanID)
|
||||||
|
require.Equal(t, "/purchase?from=wechat", claims.RedirectTo)
|
||||||
|
|
||||||
|
_, err = service.NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef")).ParseWeChatPaymentResumeToken(token)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *testing.T) {
|
func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), wxpayErrNoAuth) {
|
if wxpayShouldFallbackToNative(err) {
|
||||||
return nil, fmt.Errorf("wxpay h5 payments are not authorized for this merchant: %w", err)
|
return w.prepayNativeFallback(ctx, client, req, notifyURL, totalFen)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
case wxpayModeNative:
|
case wxpayModeNative:
|
||||||
@@ -292,6 +292,23 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create
|
|||||||
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
|
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *Wxpay) prepayNativeFallback(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||||
|
resp, err := w.prepayNative(ctx, c, req, notifyURL, totalFen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wxpay native fallback after NO_AUTH: %w", err)
|
||||||
|
}
|
||||||
|
nativeURL := strings.TrimSpace(resp.PayURL)
|
||||||
|
if nativeURL == "" {
|
||||||
|
nativeURL = strings.TrimSpace(resp.QRCode)
|
||||||
|
}
|
||||||
|
if nativeURL == "" {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
resp.PayURL = nativeURL
|
||||||
|
resp.QRCode = nativeURL
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildWxpayH5Info(config map[string]string) *h5.H5Info {
|
func buildWxpayH5Info(config map[string]string) *h5.H5Info {
|
||||||
tp := wxpayH5Type
|
tp := wxpayH5Type
|
||||||
info := &h5.H5Info{Type: &tp}
|
info := &h5.H5Info{Type: &tp}
|
||||||
@@ -304,6 +321,10 @@ func buildWxpayH5Info(config map[string]string) *h5.H5Info {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wxpayShouldFallbackToNative(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), wxpayErrNoAuth)
|
||||||
|
}
|
||||||
|
|
||||||
func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) {
|
func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) {
|
||||||
if strings.TrimSpace(req.OpenID) != "" {
|
if strings.TrimSpace(req.OpenID) != "" {
|
||||||
return wxpayModeJSAPI, nil
|
return wxpayModeJSAPI, nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -641,3 +642,68 @@ func TestCreatePaymentMobileH5IncludesConfiguredSceneInfo(t *testing.T) {
|
|||||||
t.Fatalf("pay_url = %q, want redirect_url query appended", resp.PayURL)
|
t.Fatalf("pay_url = %q, want redirect_url query appended", resp.PayURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatePaymentMobileH5FallsBackToNativeOnNoAuth(t *testing.T) {
|
||||||
|
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
|
||||||
|
origNativePrepay := wxpayNativePrepay
|
||||||
|
origH5Prepay := wxpayH5Prepay
|
||||||
|
t.Cleanup(func() {
|
||||||
|
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
|
||||||
|
wxpayNativePrepay = origNativePrepay
|
||||||
|
wxpayH5Prepay = origH5Prepay
|
||||||
|
})
|
||||||
|
|
||||||
|
jsapiCalls := 0
|
||||||
|
nativeCalls := 0
|
||||||
|
h5Calls := 0
|
||||||
|
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
|
||||||
|
jsapiCalls++
|
||||||
|
return &jsapi.PrepayWithRequestPaymentResponse{}, nil, nil
|
||||||
|
}
|
||||||
|
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
|
||||||
|
h5Calls++
|
||||||
|
return nil, nil, errors.New("NO_AUTH")
|
||||||
|
}
|
||||||
|
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
|
||||||
|
nativeCalls++
|
||||||
|
return &native.PrepayResponse{
|
||||||
|
CodeUrl: core.String("weixin://wxpay/bizpayurl?pr=fallback-native"),
|
||||||
|
}, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &Wxpay{
|
||||||
|
config: map[string]string{
|
||||||
|
"appId": "wx123",
|
||||||
|
"mchId": "mch123",
|
||||||
|
},
|
||||||
|
coreClient: &core.Client{},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
|
||||||
|
OrderID: "sub2_100",
|
||||||
|
Amount: "66.88",
|
||||||
|
PaymentType: payment.TypeWxpay,
|
||||||
|
Subject: "Balance Recharge",
|
||||||
|
NotifyURL: "https://merchant.example/payment/notify",
|
||||||
|
ClientIP: "203.0.113.10",
|
||||||
|
IsMobile: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if jsapiCalls != 0 {
|
||||||
|
t.Fatalf("jsapi prepay calls = %d, want 0", jsapiCalls)
|
||||||
|
}
|
||||||
|
if h5Calls != 1 {
|
||||||
|
t.Fatalf("h5 prepay calls = %d, want 1", h5Calls)
|
||||||
|
}
|
||||||
|
if nativeCalls != 1 {
|
||||||
|
t.Fatalf("native prepay calls = %d, want 1", nativeCalls)
|
||||||
|
}
|
||||||
|
if resp.PayURL != "weixin://wxpay/bizpayurl?pr=fallback-native" {
|
||||||
|
t.Fatalf("pay_url = %q, want native fallback url", resp.PayURL)
|
||||||
|
}
|
||||||
|
if resp.QRCode != "weixin://wxpay/bizpayurl?pr=fallback-native" {
|
||||||
|
t.Fatalf("qr_code = %q, want native fallback url", resp.QRCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,6 +116,17 @@ var providerSensitiveConfigFields = map[string]map[string]struct{}{
|
|||||||
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
|
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// providerPendingOrderProtectedConfigFields lists config keys that cannot be
|
||||||
|
// changed while the instance has in-progress orders. This includes secrets plus
|
||||||
|
// all provider identity fields that are snapshotted into orders or used by
|
||||||
|
// webhook/refund verification.
|
||||||
|
var providerPendingOrderProtectedConfigFields = map[string]map[string]struct{}{
|
||||||
|
payment.TypeEasyPay: {"pkey": {}, "pid": {}},
|
||||||
|
payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}, "appid": {}},
|
||||||
|
payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}},
|
||||||
|
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
|
||||||
|
}
|
||||||
|
|
||||||
func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
|
func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
|
||||||
fields, ok := providerSensitiveConfigFields[providerKey]
|
fields, ok := providerSensitiveConfigFields[providerKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -125,6 +136,28 @@ func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasPendingOrderProtectedConfigChange(providerKey string, currentConfig, nextConfig map[string]string) bool {
|
||||||
|
fields, ok := providerPendingOrderProtectedConfigFields[providerKey]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for fieldName := range fields {
|
||||||
|
if providerConfigFieldValue(currentConfig, fieldName) != providerConfigFieldValue(nextConfig, fieldName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerConfigFieldValue(config map[string]string, fieldName string) string {
|
||||||
|
for key, value := range config {
|
||||||
|
if strings.EqualFold(key, fieldName) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) {
|
func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) {
|
||||||
return s.entClient.PaymentOrder.Query().
|
return s.entClient.PaymentOrder.Query().
|
||||||
Where(
|
Where(
|
||||||
@@ -190,6 +223,18 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load provider instance: %w", err)
|
return nil, fmt.Errorf("load provider instance: %w", err)
|
||||||
}
|
}
|
||||||
|
var pendingOrderCount *int
|
||||||
|
getPendingOrderCount := func() (int, error) {
|
||||||
|
if pendingOrderCount != nil {
|
||||||
|
return *pendingOrderCount, nil
|
||||||
|
}
|
||||||
|
count, err := s.countPendingOrders(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("check pending orders: %w", err)
|
||||||
|
}
|
||||||
|
pendingOrderCount = &count
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
nextEnabled := current.Enabled
|
nextEnabled := current.Enabled
|
||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
nextEnabled = *req.Enabled
|
nextEnabled = *req.Enabled
|
||||||
@@ -201,18 +246,20 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
|||||||
if err := s.validateVisibleMethodEnablementConflicts(ctx, id, current.ProviderKey, nextSupportedTypes, nextEnabled); err != nil {
|
if err := s.validateVisibleMethodEnablementConflicts(ctx, id, current.ProviderKey, nextSupportedTypes, nextEnabled); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
var mergedConfig map[string]string
|
||||||
if req.Config != nil {
|
if req.Config != nil {
|
||||||
hasSensitive := false
|
currentConfig, err := s.decryptConfig(current.Config)
|
||||||
for k, v := range req.Config {
|
if err != nil {
|
||||||
if v != "" && isSensitiveProviderConfigField(current.ProviderKey, k) {
|
return nil, fmt.Errorf("decrypt existing config: %w", err)
|
||||||
hasSensitive = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if hasSensitive {
|
mergedConfig, err = s.mergeConfig(ctx, id, req.Config)
|
||||||
count, err := s.countPendingOrders(ctx, id)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hasPendingOrderProtectedConfigChange(current.ProviderKey, currentConfig, mergedConfig) {
|
||||||
|
count, err := getPendingOrderCount()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("check pending orders: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
|
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
|
||||||
@@ -221,9 +268,9 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.Enabled != nil && !*req.Enabled {
|
if req.Enabled != nil && !*req.Enabled {
|
||||||
count, err := s.countPendingOrders(ctx, id)
|
count, err := getPendingOrderCount()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("check pending orders: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
|
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
|
||||||
@@ -237,13 +284,6 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
|||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
finalEnabled = *req.Enabled
|
finalEnabled = *req.Enabled
|
||||||
}
|
}
|
||||||
var mergedConfig map[string]string
|
|
||||||
if req.Config != nil {
|
|
||||||
mergedConfig, err = s.mergeConfig(ctx, id, req.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if finalEnabled {
|
if finalEnabled {
|
||||||
configToValidate := mergedConfig
|
configToValidate := mergedConfig
|
||||||
if configToValidate == nil {
|
if configToValidate == nil {
|
||||||
@@ -269,9 +309,9 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
|
|||||||
}
|
}
|
||||||
if req.SupportedTypes != nil {
|
if req.SupportedTypes != nil {
|
||||||
// Check pending orders before removing payment types
|
// Check pending orders before removing payment types
|
||||||
count, err := s.countPendingOrders(ctx, id)
|
count, err := getPendingOrderCount()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("check pending orders: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
// Load current instance to compare types
|
// Load current instance to compare types
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ import (
|
|||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||||
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -315,10 +320,263 @@ func TestUpdateProviderInstancePersistsEnabledAndSupportedTypes(t *testing.T) {
|
|||||||
require.Equal(t, "alipay,wxpay", saved.SupportedTypes)
|
require.Equal(t, "alipay,wxpay", saved.SupportedTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateProviderInstanceRejectsProtectedConfigChangesWhilePendingOrders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
providerKey string
|
||||||
|
createConfig func(*testing.T) map[string]string
|
||||||
|
supportedType []string
|
||||||
|
updateConfig map[string]string
|
||||||
|
fieldName string
|
||||||
|
wantValue string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "wxpay appId",
|
||||||
|
providerKey: payment.TypeWxpay,
|
||||||
|
createConfig: validWxpayProviderConfig,
|
||||||
|
supportedType: []string{payment.TypeWxpay},
|
||||||
|
updateConfig: map[string]string{"appId": "wx-app-updated"},
|
||||||
|
fieldName: "appId",
|
||||||
|
wantValue: "wx-app-test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wxpay mpAppId",
|
||||||
|
providerKey: payment.TypeWxpay,
|
||||||
|
createConfig: validWxpayProviderConfigWithJSAPIAppID,
|
||||||
|
supportedType: []string{payment.TypeWxpay},
|
||||||
|
updateConfig: map[string]string{"mpAppId": "wx-mp-app-updated"},
|
||||||
|
fieldName: "mpAppId",
|
||||||
|
wantValue: "wx-mp-app-test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wxpay mchId",
|
||||||
|
providerKey: payment.TypeWxpay,
|
||||||
|
createConfig: validWxpayProviderConfig,
|
||||||
|
supportedType: []string{payment.TypeWxpay},
|
||||||
|
updateConfig: map[string]string{"mchId": "mch-updated"},
|
||||||
|
fieldName: "mchId",
|
||||||
|
wantValue: "mch-test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wxpay publicKeyId",
|
||||||
|
providerKey: payment.TypeWxpay,
|
||||||
|
createConfig: validWxpayProviderConfig,
|
||||||
|
supportedType: []string{payment.TypeWxpay},
|
||||||
|
updateConfig: map[string]string{"publicKeyId": "public-key-id-updated"},
|
||||||
|
fieldName: "publicKeyId",
|
||||||
|
wantValue: "public-key-id-test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wxpay certSerial",
|
||||||
|
providerKey: payment.TypeWxpay,
|
||||||
|
createConfig: validWxpayProviderConfig,
|
||||||
|
supportedType: []string{payment.TypeWxpay},
|
||||||
|
updateConfig: map[string]string{"certSerial": "cert-serial-updated"},
|
||||||
|
fieldName: "certSerial",
|
||||||
|
wantValue: "cert-serial-test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "alipay appId",
|
||||||
|
providerKey: payment.TypeAlipay,
|
||||||
|
createConfig: validAlipayProviderConfig,
|
||||||
|
supportedType: []string{payment.TypeAlipay},
|
||||||
|
updateConfig: map[string]string{"appId": "alipay-app-updated"},
|
||||||
|
fieldName: "appId",
|
||||||
|
wantValue: "alipay-app-test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "easypay pid",
|
||||||
|
providerKey: payment.TypeEasyPay,
|
||||||
|
createConfig: validEasyPayProviderConfig,
|
||||||
|
supportedType: []string{payment.TypeAlipay},
|
||||||
|
updateConfig: map[string]string{"pid": "pid-updated"},
|
||||||
|
fieldName: "pid",
|
||||||
|
wantValue: "pid-test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
client := newPaymentConfigServiceTestClient(t)
|
||||||
|
svc := &PaymentConfigService{
|
||||||
|
entClient: client,
|
||||||
|
encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
|
||||||
|
}
|
||||||
|
|
||||||
|
instance, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
|
||||||
|
ProviderKey: tc.providerKey,
|
||||||
|
Name: "protected-config-instance",
|
||||||
|
Config: tc.createConfig(t),
|
||||||
|
SupportedTypes: tc.supportedType,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
createPendingProviderConfigOrder(t, ctx, client, instance)
|
||||||
|
|
||||||
|
updated, err := svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{
|
||||||
|
Config: tc.updateConfig,
|
||||||
|
})
|
||||||
|
require.Nil(t, updated)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, "PENDING_ORDERS", infraerrors.Reason(err))
|
||||||
|
|
||||||
|
saved, err := client.PaymentProviderInstance.Get(ctx, instance.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg, err := svc.decryptConfig(saved.Config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc.wantValue, cfg[tc.fieldName])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateProviderInstanceAllowsSafeConfigChangesWhilePendingOrders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
providerKey string
|
||||||
|
createConfig func(*testing.T) map[string]string
|
||||||
|
supportedType []string
|
||||||
|
updateConfig map[string]string
|
||||||
|
fieldName string
|
||||||
|
wantValue string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "wxpay notifyUrl",
|
||||||
|
providerKey: payment.TypeWxpay,
|
||||||
|
createConfig: validWxpayProviderConfig,
|
||||||
|
supportedType: []string{payment.TypeWxpay},
|
||||||
|
updateConfig: map[string]string{"notifyUrl": "https://merchant.example.com/wxpay/notify-v2"},
|
||||||
|
fieldName: "notifyUrl",
|
||||||
|
wantValue: "https://merchant.example.com/wxpay/notify-v2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "alipay same appId",
|
||||||
|
providerKey: payment.TypeAlipay,
|
||||||
|
createConfig: validAlipayProviderConfig,
|
||||||
|
supportedType: []string{payment.TypeAlipay},
|
||||||
|
updateConfig: map[string]string{"appId": "alipay-app-test"},
|
||||||
|
fieldName: "appId",
|
||||||
|
wantValue: "alipay-app-test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
client := newPaymentConfigServiceTestClient(t)
|
||||||
|
svc := &PaymentConfigService{
|
||||||
|
entClient: client,
|
||||||
|
encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
|
||||||
|
}
|
||||||
|
|
||||||
|
instance, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
|
||||||
|
ProviderKey: tc.providerKey,
|
||||||
|
Name: "safe-config-instance",
|
||||||
|
Config: tc.createConfig(t),
|
||||||
|
SupportedTypes: tc.supportedType,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
createPendingProviderConfigOrder(t, ctx, client, instance)
|
||||||
|
|
||||||
|
updated, err := svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{
|
||||||
|
Config: tc.updateConfig,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, updated)
|
||||||
|
|
||||||
|
saved, err := client.PaymentProviderInstance.Get(ctx, instance.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
cfg, err := svc.decryptConfig(saved.Config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc.wantValue, cfg[tc.fieldName])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPendingProviderConfigOrder(t *testing.T, ctx context.Context, client *dbent.Client, instance *dbent.PaymentProviderInstance) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
user, err := client.User.Create().
|
||||||
|
SetEmail("provider-config-pending@example.com").
|
||||||
|
SetPasswordHash("hash").
|
||||||
|
SetUsername("provider-config-pending-user").
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
instanceID := strconv.FormatInt(instance.ID, 10)
|
||||||
|
_, err = client.PaymentOrder.Create().
|
||||||
|
SetUserID(user.ID).
|
||||||
|
SetUserEmail(user.Email).
|
||||||
|
SetUserName(user.Username).
|
||||||
|
SetAmount(88).
|
||||||
|
SetPayAmount(88).
|
||||||
|
SetFeeRate(0).
|
||||||
|
SetRechargeCode("PENDING-PROVIDER-CONFIG-" + instanceID).
|
||||||
|
SetOutTradeNo("sub2_pending_provider_config_" + instanceID).
|
||||||
|
SetPaymentType(providerPendingOrderPaymentType(instance.ProviderKey)).
|
||||||
|
SetPaymentTradeNo("").
|
||||||
|
SetOrderType(payment.OrderTypeBalance).
|
||||||
|
SetStatus(OrderStatusPending).
|
||||||
|
SetExpiresAt(time.Now().Add(time.Hour)).
|
||||||
|
SetClientIP("127.0.0.1").
|
||||||
|
SetSrcHost("api.example.com").
|
||||||
|
SetProviderInstanceID(instanceID).
|
||||||
|
SetProviderKey(instance.ProviderKey).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerPendingOrderPaymentType(providerKey string) string {
|
||||||
|
switch providerKey {
|
||||||
|
case payment.TypeWxpay:
|
||||||
|
return payment.TypeWxpay
|
||||||
|
case payment.TypeAlipay:
|
||||||
|
return payment.TypeAlipay
|
||||||
|
default:
|
||||||
|
return payment.TypeAlipay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func boolPtrValue(v bool) *bool {
|
func boolPtrValue(v bool) *bool {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validAlipayProviderConfig(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
"appId": "alipay-app-test",
|
||||||
|
"privateKey": "alipay-private-key-test",
|
||||||
|
"notifyUrl": "https://merchant.example.com/alipay/notify",
|
||||||
|
"returnUrl": "https://merchant.example.com/alipay/return",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validEasyPayProviderConfig(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
"pid": "pid-test",
|
||||||
|
"pkey": "pkey-test",
|
||||||
|
"apiBase": "https://pay.example.com",
|
||||||
|
"notifyUrl": "https://merchant.example.com/easypay/notify",
|
||||||
|
"returnUrl": "https://merchant.example.com/easypay/return",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validWxpayProviderConfig(t *testing.T) map[string]string {
|
func validWxpayProviderConfig(t *testing.T) map[string]string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -340,3 +598,11 @@ func validWxpayProviderConfig(t *testing.T) map[string]string {
|
|||||||
"certSerial": "cert-serial-test",
|
"certSerial": "cert-serial-test",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validWxpayProviderConfigWithJSAPIAppID(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := validWxpayProviderConfig(t)
|
||||||
|
cfg["mpAppId"] = "wx-mp-app-test"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|||||||
@@ -387,6 +387,45 @@ func TestPaymentServiceParseWeChatPaymentResumeTokenAcceptsLegacyEncryptionKeyDu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewConfiguredPaymentResumeServicePrefersExplicitSigningKeyAndKeepsLegacyVerificationFallback(t *testing.T) {
|
||||||
|
t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "explicit-payment-resume-signing-key")
|
||||||
|
|
||||||
|
legacyKey := []byte("0123456789abcdef0123456789abcdef")
|
||||||
|
svc := newLegacyAwarePaymentResumeService(legacyKey)
|
||||||
|
|
||||||
|
explicitToken, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
|
||||||
|
OpenID: "openid-explicit-key",
|
||||||
|
PaymentType: payment.TypeWxpay,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWeChatPaymentResumeToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
explicitClaims, err := NewPaymentResumeService([]byte("explicit-payment-resume-signing-key")).ParseWeChatPaymentResumeToken(explicitToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseWeChatPaymentResumeToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
if explicitClaims.OpenID != "openid-explicit-key" {
|
||||||
|
t.Fatalf("openid = %q, want %q", explicitClaims.OpenID, "openid-explicit-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyToken, err := NewPaymentResumeService(legacyKey).CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
|
||||||
|
OpenID: "openid-legacy-key",
|
||||||
|
PaymentType: payment.TypeWxpay,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWeChatPaymentResumeToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyClaims, err := svc.ParseWeChatPaymentResumeToken(legacyToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseWeChatPaymentResumeToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
if legacyClaims.OpenID != "openid-legacy-key" {
|
||||||
|
t.Fatalf("openid = %q, want %q", legacyClaims.OpenID, "openid-legacy-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeVisibleMethodSource(t *testing.T) {
|
func TestNormalizeVisibleMethodSource(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -268,8 +268,16 @@ func (s *PaymentService) paymentResume() *PaymentResumeService {
|
|||||||
return psNewPaymentResumeService(s.configService)
|
return psNewPaymentResumeService(s.configService)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewLegacyAwarePaymentResumeService(legacyKey []byte) *PaymentResumeService {
|
||||||
|
return newLegacyAwarePaymentResumeService(legacyKey)
|
||||||
|
}
|
||||||
|
|
||||||
func psNewPaymentResumeService(configService *PaymentConfigService) *PaymentResumeService {
|
func psNewPaymentResumeService(configService *PaymentConfigService) *PaymentResumeService {
|
||||||
signingKey, verifyFallbacks := psResumeSigningKeys(configService)
|
return newLegacyAwarePaymentResumeService(psResumeLegacyVerificationKey(configService))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLegacyAwarePaymentResumeService(legacyKey []byte) *PaymentResumeService {
|
||||||
|
signingKey, verifyFallbacks := resolvePaymentResumeSigningKeys(legacyKey)
|
||||||
return NewPaymentResumeService(signingKey, verifyFallbacks...)
|
return NewPaymentResumeService(signingKey, verifyFallbacks...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,8 +287,18 @@ func psResumeSigningKey(configService *PaymentConfigService) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func psResumeSigningKeys(configService *PaymentConfigService) ([]byte, [][]byte) {
|
func psResumeSigningKeys(configService *PaymentConfigService) ([]byte, [][]byte) {
|
||||||
|
return resolvePaymentResumeSigningKeys(psResumeLegacyVerificationKey(configService))
|
||||||
|
}
|
||||||
|
|
||||||
|
func psResumeLegacyVerificationKey(configService *PaymentConfigService) []byte {
|
||||||
|
if configService == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return configService.encryptionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePaymentResumeSigningKeys(legacyKey []byte) ([]byte, [][]byte) {
|
||||||
signingKey := parsePaymentResumeSigningKey(os.Getenv(paymentResumeSigningKeyEnv))
|
signingKey := parsePaymentResumeSigningKey(os.Getenv(paymentResumeSigningKeyEnv))
|
||||||
legacyKey := psResumeLegacyVerificationKey(configService)
|
|
||||||
if len(signingKey) == 0 {
|
if len(signingKey) == 0 {
|
||||||
if len(legacyKey) == 0 {
|
if len(legacyKey) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -293,13 +311,6 @@ func psResumeSigningKeys(configService *PaymentConfigService) ([]byte, [][]byte)
|
|||||||
return signingKey, [][]byte{legacyKey}
|
return signingKey, [][]byte{legacyKey}
|
||||||
}
|
}
|
||||||
|
|
||||||
func psResumeLegacyVerificationKey(configService *PaymentConfigService) []byte {
|
|
||||||
if configService == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return configService.encryptionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePaymentResumeSigningKey(raw string) []byte {
|
func parsePaymentResumeSigningKey(raw string) []byte {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
-- Build the payment order uniqueness guarantee online.
|
-- Build the payment order uniqueness guarantee online.
|
||||||
|
-- The migration runner performs an explicit duplicate out_trade_no precheck and
|
||||||
|
-- drops any stale invalid paymentorder_out_trade_no_unique index before retrying.
|
||||||
-- Create the new partial unique index concurrently first so writes keep flowing,
|
-- Create the new partial unique index concurrently first so writes keep flowing,
|
||||||
-- then remove the legacy index name once the replacement is ready.
|
-- then remove the legacy index name once the replacement is ready.
|
||||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique
|
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ func TestMigration119DefersPaymentIndexRolloutToOnlineFollowup(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
followupSQL := string(followupContent)
|
followupSQL := string(followupContent)
|
||||||
|
require.Contains(t, followupSQL, "explicit duplicate out_trade_no precheck")
|
||||||
|
require.Contains(t, followupSQL, "stale invalid paymentorder_out_trade_no_unique index")
|
||||||
require.Contains(t, followupSQL, "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique")
|
require.Contains(t, followupSQL, "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique")
|
||||||
require.NotContains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique")
|
require.NotContains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique")
|
||||||
require.Contains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no")
|
require.Contains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no")
|
||||||
@@ -76,6 +78,18 @@ func TestMigration119DefersPaymentIndexRolloutToOnlineFollowup(t *testing.T) {
|
|||||||
require.Contains(t, alignmentSQL, "RENAME TO paymentorder_out_trade_no")
|
require.Contains(t, alignmentSQL, "RENAME TO paymentorder_out_trade_no")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigration110SeedsAuthSourceSignupGrantsDisabledByDefault(t *testing.T) {
|
||||||
|
content, err := FS.ReadFile("110_pending_auth_and_provider_default_grants.sql")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sql := string(content)
|
||||||
|
require.Contains(t, sql, "('auth_source_default_email_grant_on_signup', 'false')")
|
||||||
|
require.Contains(t, sql, "('auth_source_default_linuxdo_grant_on_signup', 'false')")
|
||||||
|
require.Contains(t, sql, "('auth_source_default_oidc_grant_on_signup', 'false')")
|
||||||
|
require.Contains(t, sql, "('auth_source_default_wechat_grant_on_signup', 'false')")
|
||||||
|
require.NotContains(t, sql, "('auth_source_default_email_grant_on_signup', 'true')")
|
||||||
|
}
|
||||||
|
|
||||||
func TestMigration122ScrubsPendingOAuthCompletionTokensAtRest(t *testing.T) {
|
func TestMigration122ScrubsPendingOAuthCompletionTokensAtRest(t *testing.T) {
|
||||||
content, err := FS.ReadFile("122_pending_auth_completion_token_cleanup.sql")
|
content, err := FS.ReadFile("122_pending_auth_completion_token_cleanup.sql")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -94,7 +108,10 @@ func TestMigration123BackfillsLegacyAuthSourceGrantDefaultsSafely(t *testing.T)
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
sql := string(content)
|
sql := string(content)
|
||||||
require.Contains(t, sql, "Intentionally left as a no-op")
|
require.Contains(t, sql, "110_pending_auth_and_provider_default_grants.sql")
|
||||||
require.NotContains(t, sql, "UPDATE settings")
|
require.Contains(t, sql, "schema_migrations")
|
||||||
require.NotContains(t, sql, "value = 'false'")
|
require.Contains(t, sql, "updated_at")
|
||||||
|
require.Contains(t, sql, "'_grant_on_signup'")
|
||||||
|
require.Contains(t, sql, "value = 'false'")
|
||||||
|
require.Contains(t, sql, "auth_identity_migration_reports")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ onMounted(async () => {
|
|||||||
const routeOrderId = Number(readRouteQueryString('order_id')) || 0
|
const routeOrderId = Number(readRouteQueryString('order_id')) || 0
|
||||||
let outTradeNo = readRouteQueryString('out_trade_no')
|
let outTradeNo = readRouteQueryString('out_trade_no')
|
||||||
let orderId = 0
|
let orderId = 0
|
||||||
|
let resumeTokenLookupFailed = false
|
||||||
|
|
||||||
const restored = restoreRecoverySnapshot({
|
const restored = restoreRecoverySnapshot({
|
||||||
resumeToken,
|
resumeToken,
|
||||||
@@ -312,24 +313,17 @@ onMounted(async () => {
|
|||||||
orderId = resolvedOrder.id
|
orderId = resolvedOrder.id
|
||||||
}
|
}
|
||||||
} else if (routeOrderId > 0) {
|
} else if (routeOrderId > 0) {
|
||||||
|
resumeTokenLookupFailed = true
|
||||||
orderId = routeOrderId
|
orderId = routeOrderId
|
||||||
|
} else {
|
||||||
|
resumeTokenLookupFailed = true
|
||||||
}
|
}
|
||||||
} else if (routeOrderId > 0) {
|
} else if (routeOrderId > 0) {
|
||||||
orderId = routeOrderId
|
orderId = routeOrderId
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasLegacyFallbackContext = readRouteQueryString('trade_status').trim() !== ''
|
const hasLegacyFallbackContext = readRouteQueryString('trade_status').trim() !== ''
|
||||||
const shouldUsePublicOutTradeNo = !resumeToken && outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0)
|
const shouldUsePublicOutTradeNo = outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0)
|
||||||
|
|
||||||
if (!order.value && shouldUsePublicOutTradeNo) {
|
|
||||||
const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo)
|
|
||||||
if (legacyOrder) {
|
|
||||||
order.value = legacyOrder
|
|
||||||
if (!orderId) {
|
|
||||||
orderId = legacyOrder.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) {
|
if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) {
|
||||||
try {
|
try {
|
||||||
@@ -339,7 +333,17 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) {
|
if (!order.value && shouldUsePublicOutTradeNo && (!resumeToken || resumeTokenLookupFailed)) {
|
||||||
|
const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo)
|
||||||
|
if (legacyOrder) {
|
||||||
|
order.value = legacyOrder
|
||||||
|
if (!orderId) {
|
||||||
|
orderId = legacyOrder.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.value && !orderId && outTradeNo && hasLegacyFallbackContext) {
|
||||||
returnInfo.value = {
|
returnInfo.value = {
|
||||||
outTradeNo,
|
outTradeNo,
|
||||||
money: String(route.query.money || ''),
|
money: String(route.query.money || ''),
|
||||||
@@ -350,17 +354,24 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const refreshOrder = async (): Promise<PaymentOrder | null> => {
|
const refreshOrder = async (): Promise<PaymentOrder | null> => {
|
||||||
if (resumeToken) {
|
if (resumeToken) {
|
||||||
return await resolveOrderFromResumeToken(resumeToken)
|
const resolvedOrder = await resolveOrderFromResumeToken(resumeToken)
|
||||||
|
if (resolvedOrder) {
|
||||||
|
return resolvedOrder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderId) {
|
||||||
|
try {
|
||||||
|
return await paymentStore.pollOrderStatus(orderId)
|
||||||
|
} catch (_err: unknown) {
|
||||||
|
// Fall through to legacy public verification when order polling is unavailable.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldUsePublicOutTradeNo) {
|
if (shouldUsePublicOutTradeNo) {
|
||||||
return await resolveOrderFromOutTradeNo(outTradeNo)
|
return await resolveOrderFromOutTradeNo(outTradeNo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orderId) {
|
|
||||||
return await paymentStore.pollOrderStatus(orderId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -740,18 +740,23 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (decision.kind === 'wechat_jsapi' && decision.jsapi) {
|
if (decision.kind === 'wechat_jsapi' && decision.jsapi) {
|
||||||
const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record<string, unknown>)
|
try {
|
||||||
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
|
const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record<string, unknown>)
|
||||||
if (errMsg.includes('cancel')) {
|
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
|
||||||
appStore.showInfo(t('payment.qr.cancelled'))
|
if (errMsg.includes('cancel')) {
|
||||||
|
appStore.showInfo(t('payment.qr.cancelled'))
|
||||||
|
resetPayment()
|
||||||
|
} else if (errMsg && !errMsg.includes('ok')) {
|
||||||
|
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
|
||||||
|
resetPayment()
|
||||||
|
} else {
|
||||||
|
const resultState = { ...decision.paymentState }
|
||||||
|
resetPayment()
|
||||||
|
await redirectToPaymentResult(resultState)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
resetPayment()
|
resetPayment()
|
||||||
} else if (errMsg && !errMsg.includes('ok')) {
|
throw err
|
||||||
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
|
|
||||||
resetPayment()
|
|
||||||
} else {
|
|
||||||
const resultState = { ...decision.paymentState }
|
|
||||||
resetPayment()
|
|
||||||
await redirectToPaymentResult(resultState)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,14 +255,21 @@ describe('PaymentResultView', () => {
|
|||||||
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
|
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => {
|
it('falls back to public out_trade_no verification when resume_token recovery fails in legacy return flows', async () => {
|
||||||
routeState.query = {
|
routeState.query = {
|
||||||
resume_token: 'resume-fail',
|
resume_token: 'resume-fail',
|
||||||
out_trade_no: 'legacy-should-not-run',
|
out_trade_no: 'legacy-should-not-run',
|
||||||
trade_status: 'TRADE_SUCCESS',
|
trade_status: 'TRADE_SUCCESS',
|
||||||
}
|
}
|
||||||
resolveOrderPublicByResumeToken.mockRejectedValueOnce(new Error('resume failed'))
|
resolveOrderPublicByResumeToken.mockRejectedValueOnce(new Error('resume failed'))
|
||||||
mount(PaymentResultView, {
|
verifyOrderPublic.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
...orderFactory('PAID'),
|
||||||
|
out_trade_no: 'legacy-should-not-run',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(PaymentResultView, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
OrderStatusBadge: true,
|
OrderStatusBadge: true,
|
||||||
@@ -273,7 +280,9 @@ describe('PaymentResultView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-fail')
|
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-fail')
|
||||||
expect(verifyOrderPublic).not.toHaveBeenCalled()
|
expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-should-not-run')
|
||||||
|
expect(pollOrderStatus).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('payment.result.success')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores a stale global recovery snapshot when legacy return markers do not identify the order', async () => {
|
it('ignores a stale global recovery snapshot when legacy return markers do not identify the order', async () => {
|
||||||
|
|||||||
@@ -252,6 +252,33 @@ describe('PaymentView WeChat JSAPI flow', () => {
|
|||||||
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
|
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clears stale recovery state when JSAPI never becomes available', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-missing-bridge'))
|
||||||
|
;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined
|
||||||
|
|
||||||
|
const wrapper = shallowMount(PaymentView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Teleport: true,
|
||||||
|
Transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
await vi.advanceTimersByTimeAsync(4000)
|
||||||
|
await flushPromises()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(showError).toHaveBeenCalledWith(
|
||||||
|
'payment.errors.wechatJsapiUnavailable payment.errors.wechatOpenInWeChatHint',
|
||||||
|
)
|
||||||
|
expect(routerPush).not.toHaveBeenCalled()
|
||||||
|
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
|
||||||
|
expect(wrapper.html()).not.toContain('payment-status-panel-stub')
|
||||||
|
})
|
||||||
|
|
||||||
it('clears a stale recovery snapshot before handling wechat resume callback params', async () => {
|
it('clears a stale recovery snapshot before handling wechat resume callback params', async () => {
|
||||||
createOrder.mockRejectedValueOnce(new Error('resume failed'))
|
createOrder.mockRejectedValueOnce(new Error('resume failed'))
|
||||||
window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({
|
window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user