From 0dc6e56aae10f9eaacbfa12dbb477fdad9e93eef Mon Sep 17 00:00:00 2001 From: Albert Coady Date: Mon, 6 Jul 2026 09:37:11 +0800 Subject: [PATCH] fix: harden easypay custom method validation --- .../service/payment_config_providers.go | 11 ++- .../service/payment_config_providers_test.go | 18 ++++ frontend/src/views/user/PaymentView.vue | 1 + .../views/user/__tests__/PaymentView.spec.ts | 82 +++++++++++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go index a00eb9c32f..d1bf2de7aa 100644 --- a/backend/internal/service/payment_config_providers.go +++ b/backend/internal/service/payment_config_providers.go @@ -226,7 +226,7 @@ func validateProviderRequest(providerKey, name, supportedTypes string) error { var easyPayCustomMethodCodePattern = regexp.MustCompile(`^[a-z0-9_-]+$`) type easyPayCustomMethodConfig struct { - Type string `json:"type"` + Type string `json:"type"` UpstreamType string `json:"upstreamType"` DisplayName string `json:"displayName"` } @@ -245,8 +245,8 @@ func validateEasyPayCustomMethods(config map[string]string, supportedTypes strin customTypes := make(map[string]struct{}, len(methods)) for _, method := range methods { - method.Type = strings.TrimSpace(strings.ToLower(method.Type)) - method.UpstreamType = strings.TrimSpace(strings.ToLower(method.UpstreamType)) + method.Type = strings.TrimSpace(method.Type) + method.UpstreamType = strings.TrimSpace(method.UpstreamType) if method.Type == "" || method.UpstreamType == "" { return infraerrors.BadRequest("VALIDATION_ERROR", "customMethods upstreamType is required") } @@ -266,10 +266,13 @@ func validateEasyPayCustomMethods(config map[string]string, supportedTypes strin } for _, supportedType := range splitTypes(supportedTypes) { - supportedType = strings.TrimSpace(strings.ToLower(supportedType)) + supportedType = strings.TrimSpace(supportedType) if supportedType == "" || supportedType == payment.TypeAlipay || supportedType == payment.TypeWxpay { continue } + if !easyPayCustomMethodCodePattern.MatchString(supportedType) { + return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s may only contain lowercase letters, digits, underscores, and hyphens", supportedType)) + } if _, exists := customTypes[supportedType]; !exists { return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("supported EasyPay custom type %s has no customMethods mapping", supportedType)) } diff --git a/backend/internal/service/payment_config_providers_test.go b/backend/internal/service/payment_config_providers_test.go index 5ff9bfe159..74fd2a3467 100644 --- a/backend/internal/service/payment_config_providers_test.go +++ b/backend/internal/service/payment_config_providers_test.go @@ -146,6 +146,18 @@ func TestValidateEasyPayCustomMethods(t *testing.T) { supportedTypes: "alipay,wxpay,ldc", wantErr: "duplicate customMethods type", }, + { + name: "custom type must already be lowercase", + config: map[string]string{"customMethods": `[{"type":"LDC","upstreamType":"epay"}]`}, + supportedTypes: "alipay,wxpay,ldc", + wantErr: "customMethods type may only contain lowercase letters", + }, + { + name: "upstream type must already be lowercase", + config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"ALIPAY"}]`}, + supportedTypes: "alipay,wxpay,ldc", + wantErr: "customMethods upstreamType may only contain lowercase letters", + }, { name: "custom type uses alipay prefix", config: map[string]string{"customMethods": `[{"type":"alipay_hk","upstreamType":"hkpay"}]`}, @@ -164,6 +176,12 @@ func TestValidateEasyPayCustomMethods(t *testing.T) { supportedTypes: "alipay,wxpay,ldc,usdt_trc20", wantErr: "supported EasyPay custom type usdt_trc20 has no customMethods mapping", }, + { + name: "supported custom type must already be lowercase", + config: map[string]string{"customMethods": `[{"type":"ldc","upstreamType":"epay"}]`}, + supportedTypes: "alipay,wxpay,LDC", + wantErr: "supported EasyPay custom type LDC may only contain lowercase letters", + }, } for _, tc := range tests { diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index ed3288a550..6e29061013 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -1119,6 +1119,7 @@ onMounted(async () => { paymentState.value = restored paymentPhase.value = 'paying' const restoredMethod = normalizeVisibleMethod(restored.paymentType) + || (visibleMethods.value[restored.paymentType] ? restored.paymentType : '') if (restoredMethod) { selectedMethod.value = restoredMethod } diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts index 7591db37a0..aa7c401349 100644 --- a/frontend/src/views/user/__tests__/PaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts @@ -326,6 +326,88 @@ describe('PaymentView subscription confirmation amounts', () => { }) }) +describe('PaymentView payment recovery', () => { + beforeEach(() => { + vi.useRealTimers() + routeState.path = '/purchase' + routeState.query = {} + routerReplace.mockReset().mockResolvedValue(undefined) + routerPush.mockReset().mockResolvedValue(undefined) + routerResolve.mockClear() + createOrder.mockReset() + refreshUser.mockReset() + fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined) + showError.mockReset() + showInfo.mockReset() + showWarning.mockReset() + bridgeInvoke.mockReset() + window.localStorage.clear() + ;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined + }) + + it('restores a custom EasyPay method as the selected payment method', async () => { + getCheckoutInfo.mockResolvedValue(checkoutInfoFixture({ + methods: { + wxpay: checkoutInfoFixture().data.methods.wxpay, + ldc: { + daily_limit: 0, + daily_used: 0, + daily_remaining: 0, + single_min: 0, + single_max: 0, + fee_rate: 0, + available: true, + display_name: 'LDC Pay', + }, + }, + })) + window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({ + orderId: 888, + amount: 66, + qrCode: 'ldc-qr', + expiresAt: '2099-01-01T00:10:00.000Z', + paymentType: 'ldc', + payUrl: 'https://pay.example.com/ldc', + outTradeNo: 'sub2_ldc_888', + clientSecret: '', + intentId: '', + currency: '', + countryCode: '', + paymentEnv: '', + payAmount: 66, + orderType: 'balance', + paymentMode: 'popup', + resumeToken: '', + createdAt: Date.now(), + })) + + const wrapper = shallowMount(PaymentView, { + global: { + stubs: { + AppLayout: { + template: '
', + }, + PaymentStatusPanel: { + template: '