test(admin): constrain payment visible method sources

This commit is contained in:
IanShaw027
2026-04-21 00:03:27 +08:00
parent 0fa47f18ed
commit 4ebdfcd13a
4 changed files with 631 additions and 13 deletions

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
getPaymentVisibleMethodSourceOptions,
normalizePaymentVisibleMethodSource,
} from '@/api/admin/settings'
describe('admin settings payment visible method helpers', () => {
it('normalizes aliases into canonical source keys per visible method', () => {
expect(normalizePaymentVisibleMethodSource('alipay', 'official')).toBe('official_alipay')
expect(normalizePaymentVisibleMethodSource('alipay', 'alipay_direct')).toBe('official_alipay')
expect(normalizePaymentVisibleMethodSource('alipay', 'easypay')).toBe('easypay_alipay')
expect(normalizePaymentVisibleMethodSource('wxpay', 'official')).toBe('official_wxpay')
expect(normalizePaymentVisibleMethodSource('wxpay', 'wechat')).toBe('official_wxpay')
expect(normalizePaymentVisibleMethodSource('wxpay', 'easypay')).toBe('easypay_wxpay')
})
it('rejects unknown or cross-method source values', () => {
expect(normalizePaymentVisibleMethodSource('alipay', 'official_wxpay')).toBe('')
expect(normalizePaymentVisibleMethodSource('wxpay', 'official_alipay')).toBe('')
expect(normalizePaymentVisibleMethodSource('alipay', 'unknown')).toBe('')
expect(normalizePaymentVisibleMethodSource('wxpay', null)).toBe('')
})
it('exposes method-scoped source options instead of arbitrary strings', () => {
expect(getPaymentVisibleMethodSourceOptions('alipay')).toEqual([
{
value: '',
labelZh: '自动路由',
labelEn: 'Automatic routing',
},
{
value: 'official_alipay',
labelZh: '支付宝官方',
labelEn: 'Official Alipay',
},
{
value: 'easypay_alipay',
labelZh: '易支付支付宝',
labelEn: 'EasyPay Alipay',
},
])
expect(getPaymentVisibleMethodSourceOptions('wxpay')).toEqual([
{
value: '',
labelZh: '自动路由',
labelEn: 'Automatic routing',
},
{
value: 'official_wxpay',
labelZh: '微信官方',
labelEn: 'Official WeChat Pay',
},
{
value: 'easypay_wxpay',
labelZh: '易支付微信',
labelEn: 'EasyPay WeChat Pay',
},
])
})
})

View File

@@ -22,10 +22,60 @@ export interface AuthSourceDefaultsValue {
}
export type AuthSourceDefaultsState = Record<AuthSourceType, AuthSourceDefaultsValue>
export type PaymentVisibleMethod = 'alipay' | 'wxpay'
export type PaymentVisibleMethodSource =
| ''
| 'official_alipay'
| 'easypay_alipay'
| 'official_wxpay'
| 'easypay_wxpay'
export interface PaymentVisibleMethodSourceOption {
value: PaymentVisibleMethodSource
labelZh: string
labelEn: string
}
const AUTH_SOURCE_TYPES: AuthSourceType[] = ['email', 'linuxdo', 'oidc', 'wechat']
const AUTH_SOURCE_DEFAULT_BALANCE = 0
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5
const PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS: Record<
PaymentVisibleMethod,
PaymentVisibleMethodSourceOption[]
> = {
alipay: [
{ value: '', labelZh: '自动路由', labelEn: 'Automatic routing' },
{ value: 'official_alipay', labelZh: '支付宝官方', labelEn: 'Official Alipay' },
{ value: 'easypay_alipay', labelZh: '易支付支付宝', labelEn: 'EasyPay Alipay' },
],
wxpay: [
{ value: '', labelZh: '自动路由', labelEn: 'Automatic routing' },
{ value: 'official_wxpay', labelZh: '微信官方', labelEn: 'Official WeChat Pay' },
{ value: 'easypay_wxpay', labelZh: '易支付微信', labelEn: 'EasyPay WeChat Pay' },
],
}
const PAYMENT_VISIBLE_METHOD_SOURCE_ALIASES: Record<
PaymentVisibleMethod,
Record<string, PaymentVisibleMethodSource>
> = {
alipay: {
official_alipay: 'official_alipay',
alipay: 'official_alipay',
alipay_direct: 'official_alipay',
official: 'official_alipay',
easypay_alipay: 'easypay_alipay',
easypay: 'easypay_alipay',
},
wxpay: {
official_wxpay: 'official_wxpay',
wxpay: 'official_wxpay',
wxpay_direct: 'official_wxpay',
wechat: 'official_wxpay',
official: 'official_wxpay',
easypay_wxpay: 'easypay_wxpay',
easypay: 'easypay_wxpay',
},
}
export function normalizeDefaultSubscriptionSettings(
subscriptions: DefaultSubscriptionSetting[] | null | undefined
@@ -86,6 +136,24 @@ export function appendAuthSourceDefaultsToUpdateRequest(
return payload
}
export function getPaymentVisibleMethodSourceOptions(
method: PaymentVisibleMethod
): PaymentVisibleMethodSourceOption[] {
return PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS[method]
}
export function normalizePaymentVisibleMethodSource(
method: PaymentVisibleMethod,
source: unknown
): PaymentVisibleMethodSource {
if (typeof source !== 'string') return ''
const normalized = source.trim().toLowerCase()
if (!normalized) return ''
return PAYMENT_VISIBLE_METHOD_SOURCE_ALIASES[method][normalized] ?? ''
}
/**
* System settings interface
*/

View File

@@ -2717,20 +2717,19 @@
<div class="mt-4">
<label class="input-label">
{{ localText('来源', 'Source key') }}
{{ localText('支付来源', 'Payment source') }}
</label>
<input
:value="getPaymentVisibleMethodSource(visibleMethod.key)"
@input="setPaymentVisibleMethodSource(visibleMethod.key, ($event.target as HTMLInputElement).value)"
type="text"
class="input"
<Select
:model-value="getPaymentVisibleMethodSource(visibleMethod.key)"
:options="getPaymentVisibleMethodSourceSelectOptions(visibleMethod.key)"
@update:model-value="setPaymentVisibleMethodSource(visibleMethod.key, $event)"
:placeholder="visibleMethod.key"
/>
<p class="mt-1.5 text-xs text-gray-400">
{{
localText(
'留空表示由后端使用默认来源;可填 easypay、alipay、wxpay 等来源标识。',
'Leave blank to let the backend decide. Typical values are easypay, alipay, or wxpay.'
'留空表示自动路由;仅允许当前系统支持的官方或易支付来源。',
'Leave blank for automatic routing. Only supported official or EasyPay sources are allowed.'
)
}}
</p>
@@ -3117,11 +3116,14 @@ import { adminAPI } from '@/api'
import {
appendAuthSourceDefaultsToUpdateRequest,
buildAuthSourceDefaultsState,
getPaymentVisibleMethodSourceOptions,
normalizePaymentVisibleMethodSource,
normalizeDefaultSubscriptionSettings,
} from '@/api/admin/settings'
import type {
AuthSourceDefaultsState,
AuthSourceType,
PaymentVisibleMethod,
SystemSettings,
UpdateSettingsRequest,
DefaultSubscriptionSetting,
@@ -3429,12 +3431,23 @@ function getPaymentVisibleMethodSource(method: 'alipay' | 'wxpay'): string {
: form.payment_visible_method_wxpay_source
}
function setPaymentVisibleMethodSource(method: 'alipay' | 'wxpay', source: string) {
function getPaymentVisibleMethodSourceSelectOptions(method: PaymentVisibleMethod) {
return getPaymentVisibleMethodSourceOptions(method).map((option) => ({
value: option.value,
label: localText(option.labelZh, option.labelEn),
}))
}
function setPaymentVisibleMethodSource(
method: 'alipay' | 'wxpay',
source: string | number | boolean | null
) {
const normalized = normalizePaymentVisibleMethodSource(method, source)
if (method === 'alipay') {
form.payment_visible_method_alipay_source = source
form.payment_visible_method_alipay_source = normalized
return
}
form.payment_visible_method_wxpay_source = source
form.payment_visible_method_wxpay_source = normalized
}
// Proxies for web search emulation ProxySelector
@@ -3805,6 +3818,14 @@ async function loadSettings() {
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings))
form.backend_mode_enabled = settings.backend_mode_enabled
form.default_subscriptions = normalizeDefaultSubscriptionSettings(settings.default_subscriptions)
form.payment_visible_method_alipay_source = normalizePaymentVisibleMethodSource(
'alipay',
settings.payment_visible_method_alipay_source
)
form.payment_visible_method_wxpay_source = normalizePaymentVisibleMethodSource(
'wxpay',
settings.payment_visible_method_wxpay_source
)
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
settings.registration_email_suffix_whitelist
)
@@ -4070,8 +4091,14 @@ async function saveSettings() {
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
payment_visible_method_alipay_source: form.payment_visible_method_alipay_source,
payment_visible_method_wxpay_source: form.payment_visible_method_wxpay_source,
payment_visible_method_alipay_source: normalizePaymentVisibleMethodSource(
'alipay',
form.payment_visible_method_alipay_source
),
payment_visible_method_wxpay_source: normalizePaymentVisibleMethodSource(
'wxpay',
form.payment_visible_method_wxpay_source
),
payment_visible_method_alipay_enabled: form.payment_visible_method_alipay_enabled,
payment_visible_method_wxpay_enabled: form.payment_visible_method_wxpay_enabled,
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
@@ -4092,6 +4119,14 @@ async function saveSettings() {
}
}
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated))
form.payment_visible_method_alipay_source = normalizePaymentVisibleMethodSource(
'alipay',
updated.payment_visible_method_alipay_source
)
form.payment_visible_method_wxpay_source = normalizePaymentVisibleMethodSource(
'wxpay',
updated.payment_visible_method_wxpay_source
)
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist
)

View File

@@ -0,0 +1,452 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import SettingsView from '../SettingsView.vue'
const {
getSettings,
updateSettings,
getWebSearchEmulationConfig,
updateWebSearchEmulationConfig,
getAdminApiKey,
getOverloadCooldownSettings,
getStreamTimeoutSettings,
getRectifierSettings,
getBetaPolicySettings,
getGroups,
listProxies,
getProviders,
fetchPublicSettings,
adminSettingsFetch,
showError,
showSuccess,
} = vi.hoisted(() => ({
getSettings: vi.fn(),
updateSettings: vi.fn(),
getWebSearchEmulationConfig: vi.fn(),
updateWebSearchEmulationConfig: vi.fn(),
getAdminApiKey: vi.fn(),
getOverloadCooldownSettings: vi.fn(),
getStreamTimeoutSettings: vi.fn(),
getRectifierSettings: vi.fn(),
getBetaPolicySettings: vi.fn(),
getGroups: vi.fn(),
listProxies: vi.fn(),
getProviders: vi.fn(),
fetchPublicSettings: vi.fn(),
adminSettingsFetch: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
adminAPI: {
settings: {
getSettings,
updateSettings,
getWebSearchEmulationConfig,
updateWebSearchEmulationConfig,
getAdminApiKey,
getOverloadCooldownSettings,
getStreamTimeoutSettings,
getRectifierSettings,
getBetaPolicySettings,
},
groups: {
getAll: getGroups,
},
proxies: {
list: listProxies,
},
payment: {
getProviders,
},
},
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError,
showSuccess,
showWarning: vi.fn(),
showInfo: vi.fn(),
fetchPublicSettings,
}),
}))
vi.mock('@/stores/adminSettings', () => ({
useAdminSettingsStore: () => ({
fetch: adminSettingsFetch,
}),
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard: vi.fn(),
}),
}))
vi.mock('@/utils/apiError', () => ({
extractApiErrorMessage: () => 'error',
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
locale: ref('zh-CN'),
}),
}
})
const AppLayoutStub = { template: '<div><slot /></div>' }
const ToggleStub = defineComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () =>
h('input', {
class: 'toggle-stub',
type: 'checkbox',
checked: props.modelValue,
onChange: (event: Event) => {
emit('update:modelValue', (event.target as HTMLInputElement).checked)
},
})
},
})
const SelectStub = defineComponent({
props: {
modelValue: {
type: [String, Number, Boolean, null],
default: '',
},
options: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: '',
},
},
emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
const onChange = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('update:modelValue', target.value)
const option = (props.options as Array<Record<string, unknown>>).find(
(item) => String(item.value ?? '') === target.value
) ?? null
emit('change', target.value, option)
}
return () =>
h(
'select',
{
class: 'select-stub',
value: props.modelValue ?? '',
'data-placeholder': props.placeholder,
onChange,
},
(props.options as Array<Record<string, unknown>>).map((option) =>
h(
'option',
{
key: `${String(option.value ?? '')}:${String(option.label ?? '')}`,
value: option.value as string,
},
String(option.label ?? '')
)
)
)
},
})
const baseSettingsResponse = {
registration_enabled: true,
email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true,
invitation_code_enabled: false,
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
default_balance: 0,
default_concurrency: 1,
default_subscriptions: [],
site_name: 'Sub2API',
site_logo: '',
site_subtitle: '',
api_base_url: '',
contact_info: '',
doc_url: '',
home_content: '',
hide_ccs_import_button: false,
table_default_page_size: 20,
table_page_size_options: [10, 20, 50, 100],
backend_mode_enabled: false,
custom_menu_items: [],
custom_endpoints: [],
frontend_url: '',
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password_configured: false,
smtp_from_email: '',
smtp_from_name: '',
smtp_use_tls: true,
turnstile_enabled: false,
turnstile_site_key: '',
turnstile_secret_key_configured: false,
linuxdo_connect_enabled: false,
linuxdo_connect_client_id: '',
linuxdo_connect_client_secret_configured: false,
linuxdo_connect_redirect_url: '',
oidc_connect_enabled: false,
oidc_connect_provider_name: 'OIDC',
oidc_connect_client_id: '',
oidc_connect_client_secret_configured: false,
oidc_connect_issuer_url: '',
oidc_connect_discovery_url: '',
oidc_connect_authorize_url: '',
oidc_connect_token_url: '',
oidc_connect_userinfo_url: '',
oidc_connect_jwks_url: '',
oidc_connect_scopes: 'openid email profile',
oidc_connect_redirect_url: '',
oidc_connect_frontend_redirect_url: '/auth/oidc/callback',
oidc_connect_token_auth_method: 'client_secret_post',
oidc_connect_use_pkce: true,
oidc_connect_validate_id_token: true,
oidc_connect_allowed_signing_algs: 'RS256,ES256,PS256',
oidc_connect_clock_skew_seconds: 120,
oidc_connect_require_email_verified: false,
oidc_connect_userinfo_email_path: '',
oidc_connect_userinfo_id_path: '',
oidc_connect_userinfo_username_path: '',
enable_model_fallback: false,
fallback_model_anthropic: '',
fallback_model_openai: '',
fallback_model_gemini: '',
fallback_model_antigravity: '',
enable_identity_patch: false,
identity_patch_prompt: '',
ops_monitoring_enabled: false,
ops_realtime_monitoring_enabled: false,
ops_query_mode_default: 'auto',
ops_metrics_interval_seconds: 60,
min_claude_code_version: '',
max_claude_code_version: '',
allow_ungrouped_key_scheduling: false,
enable_fingerprint_unification: true,
enable_metadata_passthrough: false,
enable_cch_signing: false,
payment_enabled: true,
payment_min_amount: 1,
payment_max_amount: 10000,
payment_daily_limit: 50000,
payment_order_timeout_minutes: 30,
payment_max_pending_orders: 3,
payment_enabled_types: [],
payment_balance_disabled: false,
payment_balance_recharge_multiplier: 1,
payment_recharge_fee_rate: 0,
payment_load_balance_strategy: 'round-robin',
payment_product_name_prefix: '',
payment_product_name_suffix: '',
payment_help_image_url: '',
payment_help_text: '',
payment_cancel_rate_limit_enabled: false,
payment_cancel_rate_limit_max: 10,
payment_cancel_rate_limit_window: 1,
payment_cancel_rate_limit_unit: 'day',
payment_cancel_rate_limit_window_mode: 'rolling',
payment_visible_method_alipay_source: 'alipay_direct',
payment_visible_method_wxpay_source: 'invalid-source',
payment_visible_method_alipay_enabled: true,
payment_visible_method_wxpay_enabled: true,
openai_advanced_scheduler_enabled: false,
balance_low_notify_enabled: false,
balance_low_notify_threshold: 0,
balance_low_notify_recharge_url: '',
account_quota_notify_enabled: false,
account_quota_notify_emails: [],
}
function mountView() {
return mount(SettingsView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
Select: SelectStub,
Toggle: ToggleStub,
Icon: true,
ConfirmDialog: true,
PaymentProviderList: true,
PaymentProviderDialog: true,
GroupBadge: true,
GroupOptionItem: true,
ProxySelector: true,
ImageUpload: true,
BackupSettings: true,
},
},
})
}
async function openPaymentTab(wrapper: ReturnType<typeof mountView>) {
const paymentTabButton = wrapper
.findAll('button')
.find((node) => node.text().includes('admin.settings.tabs.payment'))
expect(paymentTabButton).toBeDefined()
await paymentTabButton?.trigger('click')
await flushPromises()
}
describe('admin SettingsView payment visible method controls', () => {
beforeEach(() => {
getSettings.mockReset()
updateSettings.mockReset()
getWebSearchEmulationConfig.mockReset()
updateWebSearchEmulationConfig.mockReset()
getAdminApiKey.mockReset()
getOverloadCooldownSettings.mockReset()
getStreamTimeoutSettings.mockReset()
getRectifierSettings.mockReset()
getBetaPolicySettings.mockReset()
getGroups.mockReset()
listProxies.mockReset()
getProviders.mockReset()
fetchPublicSettings.mockReset()
adminSettingsFetch.mockReset()
showError.mockReset()
showSuccess.mockReset()
getSettings.mockResolvedValue({ ...baseSettingsResponse })
updateSettings.mockImplementation(async (payload) => ({
...baseSettingsResponse,
...payload,
}))
getWebSearchEmulationConfig.mockResolvedValue({
enabled: false,
providers: [],
})
updateWebSearchEmulationConfig.mockResolvedValue({
enabled: false,
providers: [],
})
getAdminApiKey.mockResolvedValue({
exists: false,
masked_key: '',
})
getOverloadCooldownSettings.mockResolvedValue({
enabled: true,
cooldown_minutes: 10,
})
getStreamTimeoutSettings.mockResolvedValue({
enabled: true,
action: 'temp_unsched',
temp_unsched_minutes: 5,
threshold_count: 3,
threshold_window_minutes: 10,
})
getRectifierSettings.mockResolvedValue({
enabled: true,
thinking_signature_enabled: true,
thinking_budget_enabled: true,
apikey_signature_enabled: false,
apikey_signature_patterns: [],
})
getBetaPolicySettings.mockResolvedValue({
rules: [],
})
getGroups.mockResolvedValue([])
listProxies.mockResolvedValue({
items: [],
})
getProviders.mockResolvedValue({
data: [],
})
fetchPublicSettings.mockResolvedValue(undefined)
adminSettingsFetch.mockResolvedValue(undefined)
})
it('loads canonical source options and normalizes existing values', async () => {
const wrapper = mountView()
await flushPromises()
await openPaymentTab(wrapper)
const paymentSourceSelects = wrapper
.findAll('select.select-stub')
.filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder')))
expect(paymentSourceSelects).toHaveLength(2)
const alipaySelect = paymentSourceSelects.find(
(node) => node.attributes('data-placeholder') === 'alipay'
)
const wxpaySelect = paymentSourceSelects.find(
(node) => node.attributes('data-placeholder') === 'wxpay'
)
expect(alipaySelect?.element.value).toBe('official_alipay')
expect(alipaySelect?.findAll('option').map((option) => option.element.value)).toEqual([
'',
'official_alipay',
'easypay_alipay',
])
expect(wxpaySelect?.element.value).toBe('')
expect(wxpaySelect?.findAll('option').map((option) => option.element.value)).toEqual([
'',
'official_wxpay',
'easypay_wxpay',
])
})
it('saves canonical source keys selected from the dropdowns', async () => {
const wrapper = mountView()
await flushPromises()
await openPaymentTab(wrapper)
const paymentSourceSelects = wrapper
.findAll('select.select-stub')
.filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder')))
const alipaySelect = paymentSourceSelects.find(
(node) => node.attributes('data-placeholder') === 'alipay'
)
const wxpaySelect = paymentSourceSelects.find(
(node) => node.attributes('data-placeholder') === 'wxpay'
)
await alipaySelect?.setValue('easypay_alipay')
await wxpaySelect?.setValue('official_wxpay')
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(updateSettings).toHaveBeenCalledTimes(1)
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
payment_visible_method_alipay_source: 'easypay_alipay',
payment_visible_method_wxpay_source: 'official_wxpay',
payment_visible_method_alipay_enabled: true,
payment_visible_method_wxpay_enabled: true,
})
)
})
})