Commit c229f33e authored by IanShaw027's avatar IanShaw027
Browse files

fix(review): harden payment, oauth, and migration paths

parent 7fbd5177
package migrations
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestMigration112UsesIdempotentAddColumn(t *testing.T) {
content, err := FS.ReadFile("112_add_payment_order_provider_key_snapshot.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS provider_key VARCHAR(30)")
require.NotContains(t, sql, "ADD COLUMN provider_key VARCHAR(30);")
}
func TestMigration118DoesNotForceOverwriteAuthSourceGrantDefaults(t *testing.T) {
content, err := FS.ReadFile("118_wechat_dual_mode_and_auth_source_defaults.sql")
require.NoError(t, err)
sql := string(content)
require.NotContains(t, sql, "UPDATE settings")
require.NotContains(t, sql, "SET value = 'false'")
require.True(t, strings.Contains(sql, "ON CONFLICT (key) DO NOTHING"))
}
func TestMigration119EnforcesOutTradeNoPartialUniqueIndex(t *testing.T) {
content, err := FS.ReadFile("119_enforce_payment_orders_out_trade_no_unique.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "DROP INDEX IF EXISTS paymentorder_out_trade_no")
require.Contains(t, sql, "CREATE UNIQUE INDEX IF NOT EXISTS paymentorder_out_trade_no")
require.Contains(t, sql, "WHERE out_trade_no <> ''")
}
...@@ -173,20 +173,12 @@ describe('oauth adoption auth api', () => { ...@@ -173,20 +173,12 @@ describe('oauth adoption auth api', () => {
expect(hasPendingOAuthSuggestedProfile({})).toBe(false) 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') 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') const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth')
prepareOAuthBindAccessTokenCookie() await prepareOAuthBindAccessTokenCookie()
expect(setCookie).toHaveBeenCalledTimes(1) expect(post).toHaveBeenCalledWith('/auth/oauth/bind-token')
expect(setCookie.mock.calls[0]?.[0]).toContain('oauth_bind_access_token=access-token-value')
}) })
}) })
...@@ -278,33 +278,11 @@ export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): v ...@@ -278,33 +278,11 @@ export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): v
} }
} }
export function prepareOAuthBindAccessTokenCookie(): void { export async function prepareOAuthBindAccessTokenCookie(): Promise<void> {
if (typeof document === 'undefined' || typeof window === 'undefined') { if (!getAuthToken()) {
return return
} }
await apiClient.post('/auth/oauth/bind-token')
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'
}
} }
/** /**
......
...@@ -153,10 +153,10 @@ export function buildOAuthBindingStartURL( ...@@ -153,10 +153,10 @@ export function buildOAuthBindingStartURL(
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}` return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
} }
export function startOAuthBinding( export async function startOAuthBinding(
provider: BindableOAuthProvider, provider: BindableOAuthProvider,
options: BuildOAuthBindingStartURLOptions = {} options: BuildOAuthBindingStartURLOptions = {}
): void { ): Promise<void> {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return return
} }
...@@ -164,7 +164,7 @@ export function startOAuthBinding( ...@@ -164,7 +164,7 @@ export function startOAuthBinding(
if (!startURL) { if (!startURL) {
return return
} }
prepareOAuthBindAccessTokenCookie() await prepareOAuthBindAccessTokenCookie()
window.location.href = startURL window.location.href = startURL
} }
......
...@@ -83,7 +83,8 @@ function simulateGuard( ...@@ -83,7 +83,8 @@ function simulateGuard(
'/auth/callback', '/auth/callback',
'/auth/linuxdo/callback', '/auth/linuxdo/callback',
'/auth/oidc/callback', '/auth/oidc/callback',
'/auth/wechat/callback' '/auth/wechat/callback',
'/auth/wechat/payment/callback',
] ]
const pendingAuthPaths = ['/register', '/email-verify'] const pendingAuthPaths = ['/register', '/email-verify']
const isAllowed = const isAllowed =
...@@ -131,7 +132,8 @@ function simulateGuard( ...@@ -131,7 +132,8 @@ function simulateGuard(
'/auth/callback', '/auth/callback',
'/auth/linuxdo/callback', '/auth/linuxdo/callback',
'/auth/oidc/callback', '/auth/oidc/callback',
'/auth/wechat/callback' '/auth/wechat/callback',
'/auth/wechat/payment/callback',
] ]
const pendingAuthPaths = ['/register', '/email-verify'] const pendingAuthPaths = ['/register', '/email-verify']
const isAllowed = const isAllowed =
...@@ -448,6 +450,18 @@ describe('路由守卫逻辑', () => { ...@@ -448,6 +450,18 @@ describe('路由守卫逻辑', () => {
expect(redirect).toBeNull() 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', () => { it('unauthenticated: /register is allowed when a pending auth session exists', () => {
const authState: MockAuthState = { const authState: MockAuthState = {
isAuthenticated: false, isAuthenticated: false,
......
...@@ -52,4 +52,13 @@ describe('router WeChat OAuth route', () => { ...@@ -52,4 +52,13 @@ describe('router WeChat OAuth route', () => {
expect(route?.meta.requiresAuth).toBe(false) expect(route?.meta.requiresAuth).toBe(false)
expect(route?.meta.title).toBe('WeChat OAuth Callback') 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 = [ ...@@ -547,7 +547,8 @@ const BACKEND_MODE_CALLBACK_PATHS = [
'/auth/callback', '/auth/callback',
'/auth/linuxdo/callback', '/auth/linuxdo/callback',
'/auth/oidc/callback', '/auth/oidc/callback',
'/auth/wechat/callback' '/auth/wechat/callback',
'/auth/wechat/payment/callback',
] ]
const BACKEND_MODE_PENDING_AUTH_PATHS = ['/register', '/email-verify'] const BACKEND_MODE_PENDING_AUTH_PATHS = ['/register', '/email-verify']
......
...@@ -613,7 +613,7 @@ async function handleBindCurrentAccount() { ...@@ -613,7 +613,7 @@ async function handleBindCurrentAccount() {
return return
} }
prepareOAuthBindAccessTokenCookie() await prepareOAuthBindAccessTokenCookie()
window.location.href = startURL window.location.href = startURL
} }
......
...@@ -101,7 +101,11 @@ import { ref, computed, onBeforeUnmount, onMounted } from 'vue' ...@@ -101,7 +101,11 @@ import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue' 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 { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import type { PaymentOrder } from '@/types/payment' import type { PaymentOrder } from '@/types/payment'
...@@ -193,6 +197,18 @@ function clearStatusRefreshTimer(): void { ...@@ -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 { function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>) | null): void {
clearStatusRefreshTimer() clearStatusRefreshTimer()
if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) { if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) {
...@@ -204,6 +220,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null> ...@@ -204,6 +220,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
const refreshedOrder = await refreshOrder() const refreshedOrder = await refreshOrder()
if (refreshedOrder) { if (refreshedOrder) {
order.value = refreshedOrder order.value = refreshedOrder
clearRecoverySnapshotForTerminalStatus(refreshedOrder.status)
} }
if (isPendingStatus(order.value?.status)) { if (isPendingStatus(order.value?.status)) {
...@@ -285,6 +302,10 @@ onMounted(async () => { ...@@ -285,6 +302,10 @@ onMounted(async () => {
if (isPendingStatus(order.value?.status)) { if (isPendingStatus(order.value?.status)) {
scheduleStatusRefresh(refreshOrder) scheduleStatusRefresh(refreshOrder)
} else if (order.value) {
clearRecoverySnapshotForTerminalStatus(order.value.status)
} else if (returnInfo.value) {
clearRecoverySnapshot()
} }
loading.value = false loading.value = false
}) })
......
...@@ -391,6 +391,20 @@ function resetPayment() { ...@@ -391,6 +391,20 @@ function resetPayment() {
removeRecoverySnapshot() 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() { function onPaymentDone() {
const wasSubscription = paymentState.value.orderType === 'subscription' const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment() resetPayment()
...@@ -684,8 +698,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n ...@@ -684,8 +698,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
const errMsg = String(jsapiResult.err_msg || '').toLowerCase() const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
if (errMsg.includes('cancel')) { if (errMsg.includes('cancel')) {
appStore.showInfo(t('payment.qr.cancelled')) appStore.showInfo(t('payment.qr.cancelled'))
resetPayment()
} else if (errMsg && !errMsg.includes('ok')) { } else if (errMsg && !errMsg.includes('ok')) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod) applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
resetPayment()
} else {
const resultState = { ...decision.paymentState }
resetPayment()
await redirectToPaymentResult(resultState)
} }
return return
} }
......
...@@ -60,6 +60,21 @@ const orderFactory = (status: string) => ({ ...@@ -60,6 +60,21 @@ const orderFactory = (status: string) => ({
refund_amount: 0, 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', () => { describe('PaymentResultView', () => {
beforeEach(() => { beforeEach(() => {
routeState.query = {} routeState.query = {}
...@@ -162,6 +177,7 @@ describe('PaymentResultView', () => { ...@@ -162,6 +177,7 @@ describe('PaymentResultView', () => {
expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).toContain('103.00') expect(wrapper.text()).toContain('103.00')
expect(wrapper.text()).toContain('100.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 () => { it('refreshes a pending resume-token result until the order becomes paid', async () => {
...@@ -169,6 +185,10 @@ describe('PaymentResultView', () => { ...@@ -169,6 +185,10 @@ describe('PaymentResultView', () => {
routeState.query = { routeState.query = {
resume_token: 'resume-77', resume_token: 'resume-77',
} }
window.localStorage.setItem(
PAYMENT_RECOVERY_STORAGE_KEY,
JSON.stringify(recoverySnapshotFactory('resume-77')),
)
resolveOrderPublicByResumeToken resolveOrderPublicByResumeToken
.mockResolvedValueOnce({ .mockResolvedValueOnce({
data: orderFactory('PENDING'), data: orderFactory('PENDING'),
...@@ -189,6 +209,7 @@ describe('PaymentResultView', () => { ...@@ -189,6 +209,7 @@ describe('PaymentResultView', () => {
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1) expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('payment.result.processing') expect(wrapper.text()).toContain('payment.result.processing')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).not.toBeNull()
await vi.advanceTimersByTimeAsync(2000) await vi.advanceTimersByTimeAsync(2000)
await flushPromises() await flushPromises()
...@@ -196,6 +217,7 @@ describe('PaymentResultView', () => { ...@@ -196,6 +217,7 @@ describe('PaymentResultView', () => {
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2) expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).not.toContain('payment.result.failed') 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 () => { it('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => {
......
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', () => { ...@@ -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', () => { it('maps missing WeixinJSBridge to a JSAPI-specific prompt', () => {
expect(describePaymentScenarioError( expect(describePaymentScenarioError(
new Error('WeixinJSBridge is unavailable'), new Error('WeixinJSBridge is unavailable'),
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment