mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3912 from superman2003/fix/audit-concurrency-and-recovery
fix: harden billing concurrency and payment recovery
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type NavigationGuard = (
|
||||
to: Record<string, any>,
|
||||
from: Record<string, any>,
|
||||
next: ReturnType<typeof vi.fn>
|
||||
) => Promise<void>
|
||||
|
||||
const routerHarness = vi.hoisted(() => ({
|
||||
guard: null as NavigationGuard | null,
|
||||
}))
|
||||
|
||||
const authStore = vi.hoisted(() => ({
|
||||
checkAuth: vi.fn(),
|
||||
isAuthenticated: true,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
hasPendingAuthSession: false,
|
||||
}))
|
||||
|
||||
const appStore = vi.hoisted(() => ({
|
||||
siteName: 'Sub2API',
|
||||
backendModeEnabled: false,
|
||||
publicSettingsLoaded: false,
|
||||
cachedPublicSettings: null as null | {
|
||||
payment_enabled?: boolean
|
||||
risk_control_enabled?: boolean
|
||||
custom_menu_items?: []
|
||||
},
|
||||
fetchPublicSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
createWebHistory: vi.fn(() => ({})),
|
||||
createRouter: vi.fn(() => ({
|
||||
beforeEach: vi.fn((guard: NavigationGuard) => {
|
||||
routerHarness.guard = guard
|
||||
}),
|
||||
afterEach: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => authStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => appStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/adminSettings', () => ({
|
||||
useAdminSettingsStore: () => ({ customMenuItems: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/adminCompliance', () => ({
|
||||
useAdminComplianceStore: () => ({
|
||||
initialized: true,
|
||||
fetchStatus: vi.fn(),
|
||||
requireAcknowledgement: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNavigationLoading', () => ({
|
||||
useNavigationLoadingState: () => ({
|
||||
startNavigation: vi.fn(),
|
||||
endNavigation: vi.fn(),
|
||||
isLoading: { value: false },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useRoutePrefetch', () => ({
|
||||
useRoutePrefetch: () => ({
|
||||
triggerPrefetch: vi.fn(),
|
||||
cancelPendingPrefetch: vi.fn(),
|
||||
resetPrefetchState: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function runGuard(meta: Record<string, unknown>, path: string) {
|
||||
if (!routerHarness.guard) {
|
||||
throw new Error('router guard was not registered')
|
||||
}
|
||||
|
||||
const next = vi.fn()
|
||||
const navigation = routerHarness.guard(
|
||||
{
|
||||
path,
|
||||
fullPath: path,
|
||||
name: 'FeatureRoute',
|
||||
params: {},
|
||||
meta: { requiresAuth: true, ...meta },
|
||||
},
|
||||
{},
|
||||
next
|
||||
)
|
||||
return { navigation, next }
|
||||
}
|
||||
|
||||
describe('feature route guard', () => {
|
||||
beforeAll(async () => {
|
||||
await import('@/router')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
authStore.isAuthenticated = true
|
||||
authStore.isAdmin = false
|
||||
authStore.isSimpleMode = false
|
||||
appStore.publicSettingsLoaded = false
|
||||
appStore.cachedPublicSettings = null
|
||||
appStore.fetchPublicSettings.mockReset()
|
||||
})
|
||||
|
||||
it('waits for the first public-settings request before deciding payment access', async () => {
|
||||
const deferred = createDeferred<{ payment_enabled: boolean }>()
|
||||
appStore.fetchPublicSettings.mockImplementation(async () => {
|
||||
const settings = await deferred.promise
|
||||
appStore.cachedPublicSettings = settings
|
||||
appStore.publicSettingsLoaded = true
|
||||
return settings
|
||||
})
|
||||
|
||||
const { navigation, next } = runGuard({ requiresPayment: true }, '/purchase')
|
||||
|
||||
await vi.waitFor(() => expect(appStore.fetchPublicSettings).toHaveBeenCalledTimes(1))
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
|
||||
deferred.resolve({ payment_enabled: true })
|
||||
await navigation
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['payment', { requiresPayment: true }, '/purchase'],
|
||||
['risk control', { requiresRiskControl: true }, '/admin/risk-control'],
|
||||
])('does not treat a failed %s settings load as explicitly disabled', async (_name, meta, path) => {
|
||||
authStore.isAdmin = meta.requiresRiskControl === true
|
||||
appStore.fetchPublicSettings.mockResolvedValue(null)
|
||||
|
||||
const { navigation, next } = runGuard(meta, path)
|
||||
await navigation
|
||||
|
||||
expect(appStore.publicSettingsLoaded).toBe(false)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['payment', { requiresPayment: true }, { payment_enabled: false }, '/dashboard'],
|
||||
[
|
||||
'risk control',
|
||||
{ requiresRiskControl: true },
|
||||
{ risk_control_enabled: false },
|
||||
'/admin/settings',
|
||||
],
|
||||
])('redirects when loaded settings explicitly disable %s', async (_name, meta, settings, target) => {
|
||||
authStore.isAdmin = meta.requiresRiskControl === true
|
||||
appStore.cachedPublicSettings = settings
|
||||
appStore.publicSettingsLoaded = true
|
||||
|
||||
const { navigation, next } = runGuard(meta, '/feature')
|
||||
await navigation
|
||||
|
||||
expect(appStore.fetchPublicSettings).not.toHaveBeenCalled()
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(next).toHaveBeenCalledWith(target)
|
||||
})
|
||||
})
|
||||
@@ -837,21 +837,24 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check payment requirement (internal payment system only)
|
||||
if (to.meta.requiresPayment) {
|
||||
const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled
|
||||
if (!paymentEnabled) {
|
||||
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
|
||||
return
|
||||
}
|
||||
// Only an explicit value from successfully loaded settings can disable a route.
|
||||
// A transient settings failure is unknown state, not a confirmed feature toggle.
|
||||
if (
|
||||
to.meta.requiresPayment &&
|
||||
appStore.publicSettingsLoaded &&
|
||||
appStore.cachedPublicSettings?.payment_enabled === false
|
||||
) {
|
||||
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
if (to.meta.requiresRiskControl) {
|
||||
const riskControlEnabled = appStore.cachedPublicSettings?.risk_control_enabled === true
|
||||
if (!riskControlEnabled) {
|
||||
next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
|
||||
return
|
||||
}
|
||||
if (
|
||||
to.meta.requiresRiskControl &&
|
||||
appStore.publicSettingsLoaded &&
|
||||
appStore.cachedPublicSettings?.risk_control_enabled === false
|
||||
) {
|
||||
next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
// 简易模式下限制访问某些页面
|
||||
|
||||
@@ -2,6 +2,63 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import type { PublicSettings } from '@/types'
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function createPublicSettings(overrides: Partial<PublicSettings> = {}): PublicSettings {
|
||||
return {
|
||||
registration_enabled: false,
|
||||
email_verify_enabled: false,
|
||||
force_email_on_third_party_signup: false,
|
||||
registration_email_suffix_whitelist: [],
|
||||
promo_code_enabled: true,
|
||||
password_reset_enabled: false,
|
||||
invitation_code_enabled: false,
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
site_name: 'Test Site',
|
||||
site_logo: '',
|
||||
site_subtitle: '',
|
||||
api_base_url: '',
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
payment_enabled: false,
|
||||
risk_control_enabled: false,
|
||||
table_default_page_size: 20,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
custom_menu_items: [],
|
||||
custom_endpoints: [],
|
||||
linuxdo_oauth_enabled: false,
|
||||
wechat_oauth_enabled: false,
|
||||
oidc_oauth_enabled: false,
|
||||
oidc_oauth_provider_name: 'OIDC',
|
||||
github_oauth_enabled: false,
|
||||
google_oauth_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
version: '1.0.0',
|
||||
balance_low_notify_enabled: false,
|
||||
account_quota_notify_enabled: false,
|
||||
balance_low_notify_threshold: 0,
|
||||
channel_monitor_enabled: true,
|
||||
channel_monitor_default_interval_seconds: 60,
|
||||
available_channels_enabled: false,
|
||||
service_quota_enabled: false,
|
||||
affiliate_enabled: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Mock API 模块
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
@@ -17,6 +74,7 @@ describe('useAppStore', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
localStorage.clear()
|
||||
vi.mocked(getPublicSettings).mockReset()
|
||||
// 清除 window.__APP_CONFIG__
|
||||
delete (window as any).__APP_CONFIG__
|
||||
})
|
||||
@@ -263,6 +321,75 @@ describe('useAppStore', () => {
|
||||
// --- 公开设置 ---
|
||||
|
||||
describe('公开设置加载', () => {
|
||||
it('并发调用复用并等待同一个请求,包括 force 调用', async () => {
|
||||
const deferred = createDeferred<PublicSettings>()
|
||||
vi.mocked(getPublicSettings).mockReturnValue(deferred.promise)
|
||||
const settings = createPublicSettings({ payment_enabled: true })
|
||||
const store = useAppStore()
|
||||
|
||||
const first = store.fetchPublicSettings()
|
||||
const second = store.fetchPublicSettings()
|
||||
const forced = store.fetchPublicSettings(true)
|
||||
|
||||
expect(getPublicSettings).toHaveBeenCalledTimes(1)
|
||||
|
||||
const settled = vi.fn()
|
||||
void first.then(settled)
|
||||
void second.then(settled)
|
||||
void forced.then(settled)
|
||||
await Promise.resolve()
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
|
||||
deferred.resolve(settings)
|
||||
await expect(Promise.all([first, second, forced])).resolves.toEqual([
|
||||
settings,
|
||||
settings,
|
||||
settings,
|
||||
])
|
||||
expect(store.publicSettingsLoaded).toBe(true)
|
||||
expect(store.cachedPublicSettings?.payment_enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('force 在无活动请求时绕过缓存,刷新期间的普通调用等待刷新结果', async () => {
|
||||
const initial = createPublicSettings({ site_name: 'Initial Site' })
|
||||
vi.mocked(getPublicSettings).mockResolvedValueOnce(initial)
|
||||
const store = useAppStore()
|
||||
await store.fetchPublicSettings()
|
||||
|
||||
const deferred = createDeferred<PublicSettings>()
|
||||
const updated = createPublicSettings({ site_name: 'Updated Site' })
|
||||
vi.mocked(getPublicSettings).mockReturnValueOnce(deferred.promise)
|
||||
|
||||
const refresh = store.fetchPublicSettings(true)
|
||||
const duringRefresh = store.fetchPublicSettings()
|
||||
|
||||
expect(getPublicSettings).toHaveBeenCalledTimes(2)
|
||||
|
||||
deferred.resolve(updated)
|
||||
await expect(Promise.all([refresh, duringRefresh])).resolves.toEqual([updated, updated])
|
||||
expect(store.siteName).toBe('Updated Site')
|
||||
|
||||
await expect(store.fetchPublicSettings()).resolves.toEqual(updated)
|
||||
expect(getPublicSettings).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('并发请求失败时所有调用得到 null,且不会标记设置已加载', async () => {
|
||||
const deferred = createDeferred<PublicSettings>()
|
||||
vi.mocked(getPublicSettings).mockReturnValue(deferred.promise)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const store = useAppStore()
|
||||
|
||||
const first = store.fetchPublicSettings()
|
||||
const second = store.fetchPublicSettings()
|
||||
deferred.reject(new Error('network unavailable'))
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([null, null])
|
||||
expect(getPublicSettings).toHaveBeenCalledTimes(1)
|
||||
expect(store.publicSettingsLoaded).toBe(false)
|
||||
expect(store.cachedPublicSettings).toBeNull()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('从 window.__APP_CONFIG__ 初始化', () => {
|
||||
const windowAny = window as any
|
||||
windowAny.__APP_CONFIG__ = {
|
||||
|
||||
+34
-15
@@ -33,6 +33,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
const apiBaseUrl = ref<string>('')
|
||||
const docUrl = ref<string>('')
|
||||
const cachedPublicSettings = ref<PublicSettings | null>(null)
|
||||
let publicSettingsRequest: Promise<PublicSettings | null> | null = null
|
||||
|
||||
// Version cache state
|
||||
const versionLoaded = ref<boolean>(false)
|
||||
@@ -306,19 +307,25 @@ export const useAppStore = defineStore('app', () => {
|
||||
* Fetch public settings (uses cache unless force=true)
|
||||
* @param force - Force refresh from API
|
||||
*/
|
||||
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
|
||||
function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
|
||||
// An active request always wins over cache/force semantics so every caller observes
|
||||
// the same refresh result and no older request can overwrite a newer one.
|
||||
if (publicSettingsRequest) {
|
||||
return publicSettingsRequest
|
||||
}
|
||||
|
||||
// Check for injected config from server (eliminates flash)
|
||||
if (!publicSettingsLoaded.value && !force && window.__APP_CONFIG__) {
|
||||
applySettings(window.__APP_CONFIG__)
|
||||
return window.__APP_CONFIG__
|
||||
return Promise.resolve(window.__APP_CONFIG__)
|
||||
}
|
||||
|
||||
// Return cached data if available and not forcing refresh
|
||||
if (publicSettingsLoaded.value && !force) {
|
||||
if (cachedPublicSettings.value) {
|
||||
return { ...cachedPublicSettings.value }
|
||||
return Promise.resolve({ ...cachedPublicSettings.value })
|
||||
}
|
||||
return {
|
||||
return Promise.resolve({
|
||||
registration_enabled: false,
|
||||
email_verify_enabled: false,
|
||||
force_email_on_third_party_signup: false,
|
||||
@@ -362,25 +369,37 @@ export const useAppStore = defineStore('app', () => {
|
||||
service_quota_enabled: false,
|
||||
affiliate_enabled: false,
|
||||
allow_user_view_error_requests: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent duplicate requests
|
||||
if (publicSettingsLoading.value) {
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
publicSettingsLoading.value = true
|
||||
let apiRequest: Promise<PublicSettings>
|
||||
try {
|
||||
const data = await fetchPublicSettingsAPI()
|
||||
applySettings(data)
|
||||
return data
|
||||
apiRequest = fetchPublicSettingsAPI()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch public settings:', error)
|
||||
return null
|
||||
} finally {
|
||||
publicSettingsLoading.value = false
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const request = apiRequest
|
||||
.then((data) => {
|
||||
applySettings(data)
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch public settings:', error)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
if (publicSettingsRequest === request) {
|
||||
publicSettingsRequest = null
|
||||
publicSettingsLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
publicSettingsRequest = request
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user