fix(review): harden payment, oauth, and migration paths
This commit is contained in:
@@ -173,20 +173,12 @@ describe('oauth adoption auth api', () => {
|
||||
expect(hasPendingOAuthSuggestedProfile({})).toBe(false)
|
||||
})
|
||||
|
||||
it('prepares an oauth bind access token cookie before redirect binding', async () => {
|
||||
it('requests an HttpOnly oauth bind cookie before redirect binding', async () => {
|
||||
localStorage.setItem('auth_token', 'access-token-value')
|
||||
const setCookie = vi.fn()
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
configurable: true,
|
||||
get: () => '',
|
||||
set: setCookie
|
||||
})
|
||||
|
||||
const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth')
|
||||
|
||||
prepareOAuthBindAccessTokenCookie()
|
||||
await prepareOAuthBindAccessTokenCookie()
|
||||
|
||||
expect(setCookie).toHaveBeenCalledTimes(1)
|
||||
expect(setCookie.mock.calls[0]?.[0]).toContain('oauth_bind_access_token=access-token-value')
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/bind-token')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -278,33 +278,11 @@ export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): v
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareOAuthBindAccessTokenCookie(): void {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
export async function prepareOAuthBindAccessTokenCookie(): Promise<void> {
|
||||
if (!getAuthToken()) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = getAuthToken()
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||
const path = resolveOAuthBindCookiePath()
|
||||
document.cookie =
|
||||
`oauth_bind_access_token=${encodeURIComponent(token)}; Path=${path}/auth/oauth; Max-Age=600; SameSite=Lax${secure}`
|
||||
}
|
||||
|
||||
function resolveOAuthBindCookiePath(): string {
|
||||
const apiBase = ((import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1').replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
return new URL(apiBase, window.location.origin).pathname.replace(/\/$/, '') || '/api/v1'
|
||||
} catch {
|
||||
if (apiBase.startsWith('/')) {
|
||||
return apiBase
|
||||
}
|
||||
return '/api/v1'
|
||||
}
|
||||
await apiClient.post('/auth/oauth/bind-token')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -153,10 +153,10 @@ export function buildOAuthBindingStartURL(
|
||||
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
||||
}
|
||||
|
||||
export function startOAuthBinding(
|
||||
export async function startOAuthBinding(
|
||||
provider: BindableOAuthProvider,
|
||||
options: BuildOAuthBindingStartURLOptions = {}
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
@@ -164,7 +164,7 @@ export function startOAuthBinding(
|
||||
if (!startURL) {
|
||||
return
|
||||
}
|
||||
prepareOAuthBindAccessTokenCookie()
|
||||
await prepareOAuthBindAccessTokenCookie()
|
||||
window.location.href = startURL
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ function simulateGuard(
|
||||
'/auth/callback',
|
||||
'/auth/linuxdo/callback',
|
||||
'/auth/oidc/callback',
|
||||
'/auth/wechat/callback'
|
||||
'/auth/wechat/callback',
|
||||
'/auth/wechat/payment/callback',
|
||||
]
|
||||
const pendingAuthPaths = ['/register', '/email-verify']
|
||||
const isAllowed =
|
||||
@@ -131,7 +132,8 @@ function simulateGuard(
|
||||
'/auth/callback',
|
||||
'/auth/linuxdo/callback',
|
||||
'/auth/oidc/callback',
|
||||
'/auth/wechat/callback'
|
||||
'/auth/wechat/callback',
|
||||
'/auth/wechat/payment/callback',
|
||||
]
|
||||
const pendingAuthPaths = ['/register', '/email-verify']
|
||||
const isAllowed =
|
||||
@@ -448,6 +450,18 @@ describe('路由守卫逻辑', () => {
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('unauthenticated: WeChat payment callback route is allowed', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: false,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
backendModeEnabled: true,
|
||||
hasPendingAuthSession: false,
|
||||
}
|
||||
const redirect = simulateGuard('/auth/wechat/payment/callback', { requiresAuth: false }, authState)
|
||||
expect(redirect).toBeNull()
|
||||
})
|
||||
|
||||
it('unauthenticated: /register is allowed when a pending auth session exists', () => {
|
||||
const authState: MockAuthState = {
|
||||
isAuthenticated: false,
|
||||
|
||||
@@ -52,4 +52,13 @@ describe('router WeChat OAuth route', () => {
|
||||
expect(route?.meta.requiresAuth).toBe(false)
|
||||
expect(route?.meta.title).toBe('WeChat OAuth Callback')
|
||||
})
|
||||
|
||||
it('registers the WeChat payment callback route as a public route', async () => {
|
||||
const { default: router } = await import('@/router')
|
||||
const route = router.getRoutes().find((record) => record.name === 'WeChatPaymentOAuthCallback')
|
||||
|
||||
expect(route?.path).toBe('/auth/wechat/payment/callback')
|
||||
expect(route?.meta.requiresAuth).toBe(false)
|
||||
expect(route?.meta.title).toBe('WeChat Payment Callback')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -547,7 +547,8 @@ const BACKEND_MODE_CALLBACK_PATHS = [
|
||||
'/auth/callback',
|
||||
'/auth/linuxdo/callback',
|
||||
'/auth/oidc/callback',
|
||||
'/auth/wechat/callback'
|
||||
'/auth/wechat/callback',
|
||||
'/auth/wechat/payment/callback',
|
||||
]
|
||||
const BACKEND_MODE_PENDING_AUTH_PATHS = ['/register', '/email-verify']
|
||||
|
||||
|
||||
@@ -613,7 +613,7 @@ async function handleBindCurrentAccount() {
|
||||
return
|
||||
}
|
||||
|
||||
prepareOAuthBindAccessTokenCookie()
|
||||
await prepareOAuthBindAccessTokenCookie()
|
||||
window.location.href = startURL
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,11 @@ import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||
import { PAYMENT_RECOVERY_STORAGE_KEY, readPaymentRecoverySnapshot } from '@/components/payment/paymentFlow'
|
||||
import {
|
||||
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
clearPaymentRecoverySnapshot,
|
||||
readPaymentRecoverySnapshot,
|
||||
} from '@/components/payment/paymentFlow'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
@@ -193,6 +197,18 @@ function clearStatusRefreshTimer(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecoverySnapshot(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
clearPaymentRecoverySnapshot(window.localStorage, PAYMENT_RECOVERY_STORAGE_KEY)
|
||||
}
|
||||
|
||||
function clearRecoverySnapshotForTerminalStatus(status: string | null | undefined): void {
|
||||
if (!status) return
|
||||
if (!isPendingStatus(status)) {
|
||||
clearRecoverySnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>) | null): void {
|
||||
clearStatusRefreshTimer()
|
||||
if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) {
|
||||
@@ -204,6 +220,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
|
||||
const refreshedOrder = await refreshOrder()
|
||||
if (refreshedOrder) {
|
||||
order.value = refreshedOrder
|
||||
clearRecoverySnapshotForTerminalStatus(refreshedOrder.status)
|
||||
}
|
||||
|
||||
if (isPendingStatus(order.value?.status)) {
|
||||
@@ -285,6 +302,10 @@ onMounted(async () => {
|
||||
|
||||
if (isPendingStatus(order.value?.status)) {
|
||||
scheduleStatusRefresh(refreshOrder)
|
||||
} else if (order.value) {
|
||||
clearRecoverySnapshotForTerminalStatus(order.value.status)
|
||||
} else if (returnInfo.value) {
|
||||
clearRecoverySnapshot()
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
@@ -391,6 +391,20 @@ function resetPayment() {
|
||||
removeRecoverySnapshot()
|
||||
}
|
||||
|
||||
async function redirectToPaymentResult(state: PaymentRecoverySnapshot): Promise<void> {
|
||||
const query: Record<string, string | undefined> = {}
|
||||
if (state.orderId > 0) {
|
||||
query.order_id = String(state.orderId)
|
||||
}
|
||||
if (state.resumeToken) {
|
||||
query.resume_token = state.resumeToken
|
||||
}
|
||||
await router.push({
|
||||
path: '/payment/result',
|
||||
query,
|
||||
})
|
||||
}
|
||||
|
||||
function onPaymentDone() {
|
||||
const wasSubscription = paymentState.value.orderType === 'subscription'
|
||||
resetPayment()
|
||||
@@ -684,8 +698,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
|
||||
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)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,6 +60,21 @@ const orderFactory = (status: string) => ({
|
||||
refund_amount: 0,
|
||||
})
|
||||
|
||||
const recoverySnapshotFactory = (resumeToken: string) => ({
|
||||
orderId: 42,
|
||||
amount: 88,
|
||||
qrCode: '',
|
||||
expiresAt: '2099-01-01T00:10:00.000Z',
|
||||
paymentType: 'alipay',
|
||||
payUrl: 'https://pay.example.com/session/42',
|
||||
clientSecret: '',
|
||||
payAmount: 88,
|
||||
orderType: 'balance',
|
||||
paymentMode: 'popup',
|
||||
resumeToken,
|
||||
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
|
||||
})
|
||||
|
||||
describe('PaymentResultView', () => {
|
||||
beforeEach(() => {
|
||||
routeState.query = {}
|
||||
@@ -162,6 +177,7 @@ describe('PaymentResultView', () => {
|
||||
expect(wrapper.text()).toContain('payment.result.success')
|
||||
expect(wrapper.text()).toContain('103.00')
|
||||
expect(wrapper.text()).toContain('100.00')
|
||||
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('refreshes a pending resume-token result until the order becomes paid', async () => {
|
||||
@@ -169,6 +185,10 @@ describe('PaymentResultView', () => {
|
||||
routeState.query = {
|
||||
resume_token: 'resume-77',
|
||||
}
|
||||
window.localStorage.setItem(
|
||||
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
JSON.stringify(recoverySnapshotFactory('resume-77')),
|
||||
)
|
||||
resolveOrderPublicByResumeToken
|
||||
.mockResolvedValueOnce({
|
||||
data: orderFactory('PENDING'),
|
||||
@@ -189,6 +209,7 @@ describe('PaymentResultView', () => {
|
||||
|
||||
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('payment.result.processing')
|
||||
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).not.toBeNull()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await flushPromises()
|
||||
@@ -196,6 +217,7 @@ describe('PaymentResultView', () => {
|
||||
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('payment.result.success')
|
||||
expect(wrapper.text()).not.toContain('payment.result.failed')
|
||||
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 () => {
|
||||
|
||||
205
frontend/src/views/user/__tests__/PaymentView.spec.ts
Normal file
205
frontend/src/views/user/__tests__/PaymentView.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import PaymentView from '../PaymentView.vue'
|
||||
import { PAYMENT_RECOVERY_STORAGE_KEY } from '@/components/payment/paymentFlow'
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
path: '/purchase',
|
||||
query: {} as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
const routerReplace = vi.hoisted(() => vi.fn())
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
const routerResolve = vi.hoisted(() => vi.fn(() => ({ href: '/payment/stripe?mock=1' })))
|
||||
const createOrder = vi.hoisted(() => vi.fn())
|
||||
const refreshUser = vi.hoisted(() => vi.fn())
|
||||
const fetchActiveSubscriptions = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
const showError = vi.hoisted(() => vi.fn())
|
||||
const showInfo = vi.hoisted(() => vi.fn())
|
||||
const getCheckoutInfo = vi.hoisted(() => vi.fn())
|
||||
const bridgeInvoke = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-router')>('vue-router')
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({
|
||||
replace: routerReplace,
|
||||
push: routerPush,
|
||||
resolve: routerResolve,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
user: {
|
||||
username: 'demo-user',
|
||||
balance: 0,
|
||||
},
|
||||
refreshUser,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/payment', () => ({
|
||||
usePaymentStore: () => ({
|
||||
createOrder,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subscriptions', () => ({
|
||||
useSubscriptionStore: () => ({
|
||||
activeSubscriptions: [],
|
||||
fetchActiveSubscriptions,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showInfo,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/payment', () => ({
|
||||
paymentAPI: {
|
||||
getCheckoutInfo,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/device', () => ({
|
||||
isMobileDevice: () => true,
|
||||
}))
|
||||
|
||||
function checkoutInfoFixture() {
|
||||
return {
|
||||
data: {
|
||||
methods: {
|
||||
wxpay: {
|
||||
daily_limit: 0,
|
||||
daily_used: 0,
|
||||
daily_remaining: 0,
|
||||
single_min: 0,
|
||||
single_max: 0,
|
||||
fee_rate: 0,
|
||||
available: true,
|
||||
},
|
||||
},
|
||||
global_min: 0,
|
||||
global_max: 0,
|
||||
plans: [],
|
||||
balance_disabled: false,
|
||||
balance_recharge_multiplier: 1,
|
||||
recharge_fee_rate: 0,
|
||||
help_text: '',
|
||||
help_image_url: '',
|
||||
stripe_publishable_key: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function jsapiOrderFixture(resumeToken: string) {
|
||||
return {
|
||||
order_id: 123,
|
||||
amount: 88,
|
||||
pay_amount: 88,
|
||||
fee_rate: 0,
|
||||
expires_at: '2099-01-01T00:10:00.000Z',
|
||||
payment_type: 'wxpay',
|
||||
result_type: 'jsapi_ready' as const,
|
||||
resume_token: resumeToken,
|
||||
jsapi: {
|
||||
appId: 'wx123',
|
||||
timeStamp: '1712345678',
|
||||
nonceStr: 'nonce',
|
||||
package: 'prepay_id=wx123',
|
||||
signType: 'RSA',
|
||||
paySign: 'signed',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('PaymentView WeChat JSAPI flow', () => {
|
||||
beforeEach(() => {
|
||||
routeState.path = '/purchase'
|
||||
routeState.query = {
|
||||
wechat_resume: '1',
|
||||
wechat_resume_token: 'resume-token-123',
|
||||
}
|
||||
routerReplace.mockReset().mockResolvedValue(undefined)
|
||||
routerPush.mockReset().mockResolvedValue(undefined)
|
||||
routerResolve.mockClear()
|
||||
createOrder.mockReset()
|
||||
refreshUser.mockReset()
|
||||
fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
|
||||
showError.mockReset()
|
||||
showInfo.mockReset()
|
||||
getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture())
|
||||
bridgeInvoke.mockReset()
|
||||
window.localStorage.clear()
|
||||
;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = {
|
||||
invoke: bridgeInvoke,
|
||||
}
|
||||
})
|
||||
|
||||
it('resets payment state and redirects to /payment/result after JSAPI reports success', async () => {
|
||||
createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-123'))
|
||||
bridgeInvoke.mockImplementation((_action, _payload, callback) => {
|
||||
callback({ err_msg: 'get_brand_wcpay_request:ok' })
|
||||
})
|
||||
|
||||
shallowMount(PaymentView, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
Transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(routerReplace).toHaveBeenCalledWith({ path: '/purchase', query: {} })
|
||||
expect(routerPush).toHaveBeenCalledWith({
|
||||
path: '/payment/result',
|
||||
query: {
|
||||
order_id: '123',
|
||||
resume_token: 'resume-token-123',
|
||||
},
|
||||
})
|
||||
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('resets payment state when JSAPI reports cancellation', async () => {
|
||||
createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-cancel'))
|
||||
bridgeInvoke.mockImplementation((_action, _payload, callback) => {
|
||||
callback({ err_msg: 'get_brand_wcpay_request:cancel' })
|
||||
})
|
||||
|
||||
shallowMount(PaymentView, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
Transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(showInfo).toHaveBeenCalledWith('payment.qr.cancelled')
|
||||
expect(routerPush).not.toHaveBeenCalled()
|
||||
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,16 @@ describe('describePaymentScenarioError', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('maps WeChat H5 authorization errors when provider aliases use wxpay_direct', () => {
|
||||
expect(describePaymentScenarioError(
|
||||
{ reason: 'WECHAT_H5_NOT_AUTHORIZED' },
|
||||
{ paymentMethod: 'wxpay_direct', isMobile: true, isWechatBrowser: false },
|
||||
)).toEqual({
|
||||
messageKey: 'payment.errors.wechatH5NotAuthorized',
|
||||
hintKey: 'payment.errors.wechatOpenInWeChatHint',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps missing WeixinJSBridge to a JSAPI-specific prompt', () => {
|
||||
expect(describePaymentScenarioError(
|
||||
new Error('WeixinJSBridge is unavailable'),
|
||||
|
||||
Reference in New Issue
Block a user