Unverified Commit ddf80f5e authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1799 from IanShaw027/rebuild/auth-identity-foundation

fix(auth,payment,profile): 修复认证身份和支付系统的后续问题
parents 4d0483f5 c048ca80
......@@ -78,12 +78,13 @@ function simulateGuard(
return authState.isAdmin ? '/admin/dashboard' : '/dashboard'
}
if (authState.backendModeEnabled && !authState.isAuthenticated) {
const allowed = ['/login', '/key-usage', '/setup']
const allowed = ['/login', '/key-usage', '/setup', '/payment/result']
const callbackPaths = [
'/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 =
......@@ -126,12 +127,13 @@ function simulateGuard(
if (authState.isAuthenticated && authState.isAdmin) {
return null
}
const allowed = ['/login', '/key-usage', '/setup']
const allowed = ['/login', '/key-usage', '/setup', '/payment/result']
const callbackPaths = [
'/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,30 @@ 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: /payment/result is allowed', () => {
const authState: MockAuthState = {
isAuthenticated: false,
isAdmin: false,
isSimpleMode: false,
backendModeEnabled: true,
hasPendingAuthSession: false,
}
const redirect = simulateGuard('/payment/result', { 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')
})
})
......@@ -542,12 +542,13 @@ let authInitialized = false
const navigationLoading = useNavigationLoadingState()
// 延迟初始化预加载,传入 router 实例
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup']
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result']
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']
......
......@@ -2032,7 +2032,7 @@
</div>
<Toggle
v-model="form.oidc_connect_use_pkce"
:disabled="true"
data-testid="oidc-connect-use-pkce"
/>
</div>
......@@ -2046,7 +2046,7 @@
</div>
<Toggle
v-model="form.oidc_connect_validate_id_token"
:disabled="true"
data-testid="oidc-connect-validate-id-token"
/>
</div>
......@@ -3763,11 +3763,7 @@
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.payment.description") }}
<a
:href="
locale === 'zh'
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md'
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md'
"
:href="paymentGuideHref"
target="_blank"
rel="noopener noreferrer"
class="ml-2 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
......@@ -4140,11 +4136,7 @@
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.payment.enabledPaymentTypesHint") }}
<a
:href="
locale === 'zh'
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E6%94%AF%E4%BB%98%E6%96%B9%E5%BC%8F'
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods'
"
:href="paymentGuideHref"
target="_blank"
rel="noopener noreferrer"
class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300"
......@@ -4729,6 +4721,12 @@ function localText(zh: string, en: string): string {
return locale.value.startsWith("zh") ? zh : en;
}
const paymentGuideHref = computed(() =>
locale.value.startsWith("zh")
? "https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98"
: "https://github.com/Wei-Shaw/sub2api/blob/main/README.md#payment",
);
type SettingsTab =
| "general"
| "security"
......@@ -4961,8 +4959,8 @@ const form = reactive<SettingsForm>({
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_use_pkce: false,
oidc_connect_validate_id_token: false,
oidc_connect_allowed_signing_algs: "RS256,ES256,PS256",
oidc_connect_clock_skew_seconds: 120,
oidc_connect_require_email_verified: false,
......@@ -5846,8 +5844,8 @@ async function saveSettings() {
oidc_connect_frontend_redirect_url:
form.oidc_connect_frontend_redirect_url,
oidc_connect_token_auth_method: form.oidc_connect_token_auth_method,
oidc_connect_use_pkce: true,
oidc_connect_validate_id_token: true,
oidc_connect_use_pkce: form.oidc_connect_use_pkce,
oidc_connect_validate_id_token: form.oidc_connect_validate_id_token,
oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs,
oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds,
oidc_connect_require_email_verified:
......
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, ref } from "vue";
import { defineComponent, h } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import SettingsView from "../SettingsView.vue";
......@@ -46,6 +46,8 @@ const {
showSuccess: vi.fn(),
}));
const localeRef = vi.hoisted(() => ({ value: "zh-CN" }));
vi.mock("@/api", () => ({
adminAPI: {
settings: {
......@@ -149,6 +151,8 @@ vi.mock("vue-i18n", async () => {
"admin.settings.paymentVisibleMethods.sourceLabel": "支付来源",
"admin.settings.paymentVisibleMethods.sourceHint": "启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。",
"admin.settings.paymentVisibleMethods.sourceRequiredError": "{title} 已启用,请先选择支付来源。",
"admin.settings.payment.configGuide": "查看支付配置说明",
"admin.settings.payment.findProvider": "查看支持的支付方式",
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
};
......@@ -157,7 +161,7 @@ vi.mock("vue-i18n", async () => {
useI18n: () => ({
t: (key: string, params?: Record<string, string>) =>
(translations[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => params?.[token] ?? `{${token}}`),
locale: ref("zh-CN"),
locale: localeRef,
}),
};
});
......@@ -429,6 +433,7 @@ describe("admin SettingsView payment visible method controls", () => {
adminSettingsFetch.mockReset();
showError.mockReset();
showSuccess.mockReset();
localeRef.value = "zh-CN";
getSettings.mockResolvedValue({ ...baseSettingsResponse });
updateSettings.mockImplementation(async (payload) => ({
......@@ -489,6 +494,30 @@ describe("admin SettingsView payment visible method controls", () => {
expect(wrapper.text()).not.toContain("支付来源");
});
it("links payment guidance to README sections instead of removed payment docs", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
const paymentLinks = wrapper
.findAll("a")
.filter((node) =>
["查看支付配置说明", "查看支持的支付方式"].includes(node.text()),
);
expect(paymentLinks).toHaveLength(2);
expect(paymentLinks[0]?.attributes("href")).toBe(
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
);
expect(paymentLinks[1]?.attributes("href")).toBe(
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
);
for (const link of paymentLinks) {
expect(link.attributes("href")).not.toContain("docs/PAYMENT");
}
});
it("does not submit legacy visible payment method settings", async () => {
const wrapper = mountView();
......@@ -776,4 +805,28 @@ describe("admin SettingsView wechat connect controls", () => {
).toBe(true);
expect(wrapper.text()).toContain("首次绑定时授权");
});
it("preserves optional OIDC compatibility flags instead of forcing them on save", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
oidc_connect_enabled: true,
oidc_connect_use_pkce: false,
oidc_connect_validate_id_token: false,
});
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
oidc_connect_use_pkce: false,
oidc_connect_validate_id_token: false,
}),
);
});
});
......@@ -456,7 +456,14 @@ function resolvePendingAccountAction(
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account'
}
if (raw === 'bind_login_required' || raw === 'bind_login') {
if (
raw === 'bind_login_required' ||
raw === 'bind_login' ||
raw === 'existing_account' ||
raw === 'existing_account_required' ||
raw === 'existing_account_binding_required' ||
raw === 'adopt_existing_user_by_email'
) {
return 'bind_login'
}
return 'none'
......@@ -603,6 +610,14 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe
return
}
if (completion.auth_result === 'pending_session') {
needsInvitation.value = false
needsAdoptionConfirmation.value = false
isProcessing.value = false
persistPendingAuthSession(redirect)
return
}
await finalizeCompletion(completion, redirect)
}
......@@ -612,9 +627,9 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const tokenData = legacyPendingOAuthToken.value
const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value
? (
await apiClient.post<OAuthTokenResponse>('/auth/oauth/linuxdo/complete-registration', {
await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
......@@ -624,10 +639,7 @@ async function handleSubmitInvitation() {
invitationCode.value.trim(),
currentAdoptionDecision()
)
persistOAuthTokenContext(tokenData)
await authStore.setToken(tokenData.access_token)
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
invitationError.value =
......
......@@ -632,6 +632,14 @@ async function finalizePendingAccountResponse(completion: PendingOidcCompletion)
return
}
if (completion.auth_result === 'pending_session') {
needsInvitation.value = false
needsAdoptionConfirmation.value = false
isProcessing.value = false
persistPendingAuthSession(redirect)
return
}
await finalizeCompletion(completion, redirect)
}
......@@ -641,9 +649,9 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const tokenData = legacyPendingOAuthToken.value
const completion: PendingOidcCompletion = legacyPendingOAuthToken.value
? (
await apiClient.post<OAuthTokenResponse>('/auth/oauth/oidc/complete-registration', {
await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
......@@ -653,10 +661,7 @@ async function handleSubmitInvitation() {
invitationCode.value.trim(),
currentAdoptionDecision()
)
persistOAuthTokenContext(tokenData)
await authStore.setToken(tokenData.access_token)
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
invitationError.value =
......
......@@ -613,8 +613,12 @@ async function handleBindCurrentAccount() {
return
}
prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
try {
await prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
} catch (e: unknown) {
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
}
}
async function handleExistingAccountBinding() {
......@@ -840,6 +844,14 @@ async function finalizePendingAccountResponse(completion: PendingWeChatCompletio
return
}
if (completion.auth_result === 'pending_session') {
needsInvitation.value = false
needsAdoptionConfirmation.value = false
isProcessing.value = false
persistPendingAuthSession(redirect)
return
}
await finalizeCompletion(completion, redirect)
}
......@@ -849,9 +861,9 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const tokenData = legacyPendingOAuthToken.value
const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value
? (
await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
await apiClient.post<PendingWeChatCompletion>('/auth/oauth/wechat/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
......@@ -861,10 +873,7 @@ async function handleSubmitInvitation() {
invitationCode.value.trim(),
currentAdoptionDecision()
)
persistOAuthTokenContext(tokenData)
await authStore.setToken(tokenData.access_token)
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
invitationError.value =
......
......@@ -85,6 +85,12 @@ function normalizeRedirectPath(path: string | null | undefined): string {
return value
}
function appendQueryParam(query: Record<string, string>, key: string, value: string) {
if (value) {
query[key] = value
}
}
function goBackToPayment() {
void router.replace('/purchase')
}
......@@ -102,12 +108,19 @@ onMounted(async () => {
}
const resumeToken = readParam('wechat_resume_token')
const openid = readParam('openid')
const state = readParam('state')
const scope = readParam('scope')
const paymentType = readParam('payment_type')
const amount = readParam('amount')
const orderType = readParam('order_type')
const planId = readParam('plan_id')
const redirectURL = new URL(
normalizeRedirectPath(readParam('redirect')),
window.location.origin,
)
if (!resumeToken) {
if (!resumeToken && !openid) {
errorMessage.value = t('auth.wechatPayment.callbackMissingResumeToken')
return
}
......@@ -115,7 +128,18 @@ onMounted(async () => {
const query: Record<string, string> = {
...Object.fromEntries(redirectURL.searchParams.entries()),
wechat_resume: '1',
wechat_resume_token: resumeToken,
}
if (resumeToken) {
query.wechat_resume_token = resumeToken
} else {
query.openid = openid
appendQueryParam(query, 'state', state)
appendQueryParam(query, 'scope', scope)
appendQueryParam(query, 'payment_type', paymentType)
appendQueryParam(query, 'amount', amount)
appendQueryParam(query, 'order_type', orderType)
appendQueryParam(query, 'plan_id', planId)
}
await router.replace({
......
......@@ -336,6 +336,33 @@ describe('LinuxDoCallbackView', () => {
)
})
it('keeps rendering bind-login UI for legacy pending bind responses instead of treating them as success', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'adopt_existing_user_by_email',
redirect: '/profile/security',
email: 'existing@example.com'
})
const wrapper = mount(LinuxDoCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
expect(showSuccess).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
expect((wrapper.get('[data-testid="linuxdo-bind-login-email"]').element as HTMLInputElement).value).toBe(
'existing@example.com'
)
})
it('persists a pending auth session when the oauth flow still needs account creation', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required',
......@@ -409,6 +436,50 @@ describe('LinuxDoCallbackView', () => {
})
})
it('keeps the oauth flow active when complete-registration returns another pending step', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'invitation_required',
redirect: '/dashboard',
adoption_required: true,
suggested_display_name: 'LinuxDo Nick',
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
})
completeLinuxDoOAuthRegistration.mockResolvedValue({
auth_result: 'pending_session',
step: 'choose_account_action_required',
redirect: '/dashboard',
email: 'fresh@example.com',
resolved_email: 'fresh@example.com',
force_email_on_signup: true,
adoption_required: true
})
const wrapper = mount(LinuxDoCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
await wrapper.find('input[type="text"]').setValue('invite-code')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(completeLinuxDoOAuthRegistration).toHaveBeenCalledWith('invite-code', {
adoptDisplayName: true,
adoptAvatar: true
})
expect(setToken).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('auth.oauthFlow.bindExistingAccount')
expect(wrapper.text()).toContain('auth.oauthFlow.createNewAccount')
})
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
getPublicSettings.mockResolvedValue({
invitation_code_enabled: true,
......
......@@ -385,6 +385,50 @@ describe('OidcCallbackView', () => {
})
})
it('keeps the oauth flow active when complete-registration returns another pending step', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'invitation_required',
redirect: '/dashboard',
adoption_required: true,
suggested_display_name: 'OIDC Nick',
suggested_avatar_url: 'https://cdn.example/oidc.png'
})
completeOIDCOAuthRegistration.mockResolvedValue({
auth_result: 'pending_session',
step: 'choose_account_action_required',
redirect: '/dashboard',
email: 'fresh@example.com',
resolved_email: 'fresh@example.com',
force_email_on_signup: true,
adoption_required: true
})
const wrapper = mount(OidcCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
await wrapper.find('input[type="text"]').setValue('invite-code')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(completeOIDCOAuthRegistration).toHaveBeenCalledWith('invite-code', {
adoptDisplayName: true,
adoptAvatar: true
})
expect(setToken).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('auth.oauthFlow.bindExistingAccount')
expect(wrapper.text()).toContain('auth.oauthFlow.createNewAccount')
})
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
getPublicSettings.mockResolvedValue({
oidc_oauth_provider_name: 'ExampleID',
......
......@@ -517,6 +517,50 @@ describe('WechatCallbackView', () => {
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
})
it('keeps the oauth flow active when complete-registration returns another pending step', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'invitation_required',
redirect: '/dashboard',
adoption_required: true,
suggested_display_name: 'WeChat Nick',
suggested_avatar_url: 'https://cdn.example/wechat.png',
})
completeWeChatOAuthRegistrationMock.mockResolvedValue({
auth_result: 'pending_session',
step: 'choose_account_action_required',
redirect: '/dashboard',
email: 'fresh@example.com',
resolved_email: 'fresh@example.com',
force_email_on_signup: true,
adoption_required: true,
})
const wrapper = mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
await wrapper.find('input[type="text"]').setValue('invite-code')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(completeWeChatOAuthRegistrationMock).toHaveBeenCalledWith('invite-code', {
adoptDisplayName: true,
adoptAvatar: true,
})
expect(setTokenMock).not.toHaveBeenCalled()
expect(replaceMock).not.toHaveBeenCalled()
expect(wrapper.get('[data-testid="wechat-choice-bind-existing"]').exists()).toBe(true)
expect(wrapper.get('[data-testid="wechat-choice-create-account"]').exists()).toBe(true)
})
it('offers existing-account email collection during invitation flow', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'invitation_required',
......@@ -577,6 +621,34 @@ describe('WechatCallbackView', () => {
expect(locationState.current.href).toContain('mode=open')
})
it('shows an error and stays on the page when preparing bind-token for the current account fails', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'invitation_required',
redirect: '/usage',
})
getAuthTokenMock.mockReturnValue('current-auth-token')
prepareOAuthBindAccessTokenCookieMock.mockRejectedValue(new Error('bind token failed'))
const wrapper = mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
await wrapper.get('[data-testid="existing-account-submit"]').trigger('click').catch(() => undefined)
await flushPromises()
expect(showErrorMock).toHaveBeenCalledWith('bind token failed')
expect(locationState.current.href).toBe('http://localhost/auth/wechat/callback')
})
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
getPublicSettingsMock.mockResolvedValue({
invitation_code_enabled: true,
......
......@@ -79,6 +79,29 @@ describe('WechatPaymentCallbackView', () => {
})
})
it('redirects legacy openid callback payloads back to purchase while preserving resume context', async () => {
locationState.current.hash =
'#openid=openid-123&state=oauth-state&scope=snsapi_base&payment_type=wxpay_direct&amount=128&order_type=subscription&plan_id=7&redirect=%2Fpayment%3Ffrom%3Dwechat'
mount(WechatPaymentCallbackView)
await flushPromises()
expect(replaceMock).toHaveBeenCalledWith({
path: '/purchase',
query: {
from: 'wechat',
wechat_resume: '1',
openid: 'openid-123',
state: 'oauth-state',
scope: 'snsapi_base',
payment_type: 'wxpay_direct',
amount: '128',
order_type: 'subscription',
plan_id: '7',
},
})
})
it('shows an error when the callback payload is missing the resume token', async () => {
locationState.current.hash = '#payment_type=wxpay'
......
......@@ -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'
......@@ -177,6 +181,54 @@ function isPendingStatus(status: string | null | undefined): boolean {
return PENDING_STATUSES.has(normalizeOrderStatus(status))
}
function readRouteQueryString(key: string): string {
const value = route.query[key]
if (Array.isArray(value)) {
return typeof value[0] === 'string' ? value[0] : ''
}
return typeof value === 'string' ? value : ''
}
function restoreRecoverySnapshot(context: {
resumeToken: string
routeOrderId: number
routeOutTradeNo: string
}) {
if (typeof window === 'undefined') {
return null
}
const rawSnapshot = window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)
if (!rawSnapshot) {
return null
}
if (context.resumeToken) {
return readPaymentRecoverySnapshot(rawSnapshot, {
resumeToken: context.resumeToken,
})
}
if (!context.routeOrderId && !context.routeOutTradeNo) {
return null
}
const restored = readPaymentRecoverySnapshot(rawSnapshot)
if (!restored) {
return null
}
if (context.routeOrderId > 0 && restored.orderId !== context.routeOrderId) {
return null
}
if (context.routeOutTradeNo && restored.outTradeNo !== context.routeOutTradeNo) {
return null
}
return restored
}
async function resolveOrderFromResumeToken(resumeToken: string): Promise<PaymentOrder | null> {
try {
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
......@@ -186,6 +238,15 @@ async function resolveOrderFromResumeToken(resumeToken: string): Promise<Payment
}
}
async function resolveOrderFromOutTradeNo(outTradeNo: string): Promise<PaymentOrder | null> {
try {
const result = await paymentAPI.verifyOrderPublic(outTradeNo)
return result.data
} catch (_err: unknown) {
return null
}
}
function clearStatusRefreshTimer(): void {
if (statusRefreshTimer !== null) {
clearTimeout(statusRefreshTimer)
......@@ -193,6 +254,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 +277,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
const refreshedOrder = await refreshOrder()
if (refreshedOrder) {
order.value = refreshedOrder
clearRecoverySnapshotForTerminalStatus(refreshedOrder.status)
}
if (isPendingStatus(order.value?.status)) {
......@@ -213,29 +287,22 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
}
onMounted(async () => {
const resumeToken = typeof route.query.resume_token === 'string'
? route.query.resume_token
: ''
const routeOrderId = Number(route.query.order_id) || 0
const outTradeNo = String(route.query.out_trade_no || '')
const resumeToken = readRouteQueryString('resume_token')
const routeOrderId = Number(readRouteQueryString('order_id')) || 0
let outTradeNo = readRouteQueryString('out_trade_no')
let orderId = 0
if (resumeToken && typeof window !== 'undefined') {
const restored = readPaymentRecoverySnapshot(
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
{ resumeToken },
)
if (restored?.orderId) {
orderId = restored.orderId
}
let resumeTokenLookupFailed = false
const restored = restoreRecoverySnapshot({
resumeToken,
routeOrderId,
routeOutTradeNo: outTradeNo,
})
if (restored?.orderId) {
orderId = restored.orderId
}
if (!order.value && resumeToken && orderId) {
try {
order.value = await paymentStore.pollOrderStatus(orderId)
} catch (_err: unknown) {
// Fall through to signed resume-token recovery below.
}
if (!outTradeNo && restored?.outTradeNo) {
outTradeNo = restored.outTradeNo
}
if (resumeToken) {
......@@ -245,14 +312,20 @@ onMounted(async () => {
if (!orderId) {
orderId = resolvedOrder.id
}
} else if (routeOrderId > 0) {
resumeTokenLookupFailed = true
orderId = routeOrderId
} else {
resumeTokenLookupFailed = true
}
}
if (!resumeToken) {
} else if (routeOrderId > 0) {
orderId = routeOrderId
}
if (!order.value && !resumeToken && orderId) {
const hasLegacyFallbackContext = readRouteQueryString('trade_status').trim() !== ''
const shouldUsePublicOutTradeNo = outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0)
if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) {
try {
order.value = await paymentStore.pollOrderStatus(orderId)
} catch (_err: unknown) {
......@@ -260,9 +333,17 @@ onMounted(async () => {
}
}
const hasLegacyFallbackContext = typeof route.query.trade_status === 'string'
&& route.query.trade_status.trim() !== ''
if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) {
if (!order.value && shouldUsePublicOutTradeNo && (!resumeToken || resumeTokenLookupFailed)) {
const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo)
if (legacyOrder) {
order.value = legacyOrder
if (!orderId) {
orderId = legacyOrder.id
}
}
}
if (!order.value && !orderId && outTradeNo && hasLegacyFallbackContext) {
returnInfo.value = {
outTradeNo,
money: String(route.query.money || ''),
......@@ -273,11 +354,22 @@ onMounted(async () => {
const refreshOrder = async (): Promise<PaymentOrder | null> => {
if (resumeToken) {
return await resolveOrderFromResumeToken(resumeToken)
const resolvedOrder = await resolveOrderFromResumeToken(resumeToken)
if (resolvedOrder) {
return resolvedOrder
}
}
if (orderId) {
return await paymentStore.pollOrderStatus(orderId)
try {
return await paymentStore.pollOrderStatus(orderId)
} catch (_err: unknown) {
// Fall through to legacy public verification when order polling is unavailable.
}
}
if (shouldUsePublicOutTradeNo) {
return await resolveOrderFromOutTradeNo(outTradeNo)
}
return null
......@@ -285,6 +377,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
})
......
......@@ -276,7 +276,7 @@ import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import Icon from '@/components/icons/Icon.vue'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
import { parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
const { t } = useI18n()
const route = useRoute()
......@@ -329,6 +329,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
expiresAt: '',
paymentType: '',
payUrl: '',
outTradeNo: '',
clientSecret: '',
payAmount: 0,
orderType: '',
......@@ -391,6 +392,60 @@ 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.outTradeNo) {
query.out_trade_no = state.outTradeNo
}
if (state.resumeToken) {
query.resume_token = state.resumeToken
}
await router.push({
path: '/payment/result',
query,
})
}
function buildWechatOAuthAuthorizeUrl(
authorizeUrl: string,
context: { paymentType: string; orderType: OrderType; planId?: number; orderAmount: number },
): string {
const normalizedUrl = authorizeUrl.trim()
if (!normalizedUrl || typeof window === 'undefined') {
return normalizedUrl
}
try {
const targetUrl = new URL(normalizedUrl, window.location.origin)
const redirectPath = targetUrl.searchParams.get('redirect') || '/purchase'
const redirectUrl = new URL(redirectPath, window.location.origin)
const paymentType = normalizeVisibleMethod(context.paymentType) || context.paymentType.trim() || 'wxpay'
redirectUrl.searchParams.set('payment_type', paymentType)
redirectUrl.searchParams.set('order_type', context.orderType)
if (context.planId) {
redirectUrl.searchParams.set('plan_id', String(context.planId))
} else {
redirectUrl.searchParams.delete('plan_id')
}
if (context.orderAmount > 0) {
redirectUrl.searchParams.set('amount', String(context.orderAmount))
} else {
redirectUrl.searchParams.delete('amount')
}
targetUrl.searchParams.set('redirect', `${redirectUrl.pathname}${redirectUrl.search}`)
return targetUrl.toString()
} catch {
return normalizedUrl
}
}
function onPaymentDone() {
const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment()
......@@ -658,7 +713,12 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
})
if (decision.kind === 'wechat_oauth' && decision.oauth?.authorize_url) {
window.location.href = decision.oauth.authorize_url
window.location.href = buildWechatOAuthAuthorizeUrl(decision.oauth.authorize_url, {
paymentType: visibleMethod,
orderType,
planId,
orderAmount,
})
return
}
......@@ -680,12 +740,23 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
return
}
if (decision.kind === 'wechat_jsapi' && decision.jsapi) {
const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record<string, unknown>)
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
if (errMsg.includes('cancel')) {
appStore.showInfo(t('payment.qr.cancelled'))
} else if (errMsg && !errMsg.includes('ok')) {
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
try {
const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record<string, unknown>)
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)
}
} catch (err: unknown) {
resetPayment()
throw err
}
return
}
......@@ -789,9 +860,14 @@ onMounted(async () => {
selectedMethod.value = sorted[0]
}
if (typeof window !== 'undefined') {
if (hasWechatResumeQuery(route.query)) {
removeRecoverySnapshot()
}
const routeResumeToken = typeof route.query.resume_token === 'string'
? route.query.resume_token
: undefined
: typeof route.query.wechat_resume_token === 'string'
? route.query.wechat_resume_token
: undefined
const restored = readPaymentRecoverySnapshot(
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
{ resumeToken: routeResumeToken },
......
......@@ -7,7 +7,7 @@ const routeState = vi.hoisted(() => ({
const routerPush = vi.hoisted(() => vi.fn())
const pollOrderStatus = vi.hoisted(() => vi.fn())
const verifyOrder = vi.hoisted(() => vi.fn())
const verifyOrderPublic = vi.hoisted(() => vi.fn())
const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn())
vi.mock('vue-router', async () => {
......@@ -37,7 +37,7 @@ vi.mock('@/stores/payment', () => ({
vi.mock('@/api/payment', () => ({
paymentAPI: {
verifyOrder,
verifyOrderPublic,
resolveOrderPublicByResumeToken,
},
}))
......@@ -60,12 +60,28 @@ 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',
outTradeNo: 'sub2_20260420abcd1234',
clientSecret: '',
payAmount: 88,
orderType: 'balance',
paymentMode: 'popup',
resumeToken,
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
})
describe('PaymentResultView', () => {
beforeEach(() => {
routeState.query = {}
routerPush.mockReset()
pollOrderStatus.mockReset()
verifyOrder.mockReset()
verifyOrderPublic.mockReset()
resolveOrderPublicByResumeToken.mockReset()
window.localStorage.clear()
})
......@@ -87,6 +103,7 @@ describe('PaymentResultView', () => {
expiresAt: '2099-01-01T00:10:00.000Z',
paymentType: 'alipay',
payUrl: 'https://pay.example.com/session/42',
outTradeNo: 'sub2_20260420abcd1234',
clientSecret: '',
payAmount: 88,
orderType: 'balance',
......@@ -94,7 +111,9 @@ describe('PaymentResultView', () => {
resumeToken: 'resume-42',
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
}))
pollOrderStatus.mockResolvedValue(orderFactory('PENDING'))
resolveOrderPublicByResumeToken.mockResolvedValue({
data: orderFactory('PENDING'),
})
const wrapper = mount(PaymentResultView, {
global: {
......@@ -106,7 +125,8 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(pollOrderStatus).toHaveBeenCalledWith(42)
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-42')
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.processing')
expect(wrapper.text()).not.toContain('payment.result.success')
expect(wrapper.text()).not.toContain('payment.result.failed')
......@@ -125,6 +145,7 @@ describe('PaymentResultView', () => {
expiresAt: '2099-01-01T00:10:00.000Z',
paymentType: 'alipay',
payUrl: 'https://pay.example.com/session/42',
outTradeNo: 'sub2_20260420abcd1234',
clientSecret: '',
payAmount: 88,
orderType: 'balance',
......@@ -132,12 +153,6 @@ describe('PaymentResultView', () => {
resumeToken: 'resume-authoritative',
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
}))
pollOrderStatus.mockResolvedValue({
...orderFactory('PENDING'),
amount: 88,
pay_amount: 88,
fee_rate: 0,
})
resolveOrderPublicByResumeToken.mockResolvedValue({
data: {
...orderFactory('PAID'),
......@@ -157,11 +172,12 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(pollOrderStatus).toHaveBeenCalledWith(42)
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-authoritative')
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,17 +217,59 @@ 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('falls back to order_id polling when resume-token recovery fails', async () => {
routeState.query = {
resume_token: 'resume-fail',
order_id: '77',
}
window.localStorage.setItem(
PAYMENT_RECOVERY_STORAGE_KEY,
JSON.stringify({
...recoverySnapshotFactory('resume-fail'),
orderId: 42,
}),
)
resolveOrderPublicByResumeToken.mockRejectedValueOnce(new Error('resume failed'))
pollOrderStatus.mockResolvedValueOnce({
...orderFactory('PAID'),
id: 77,
})
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
},
},
})
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-fail')
expect(pollOrderStatus).toHaveBeenCalledWith(77)
expect(verifyOrderPublic).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.success')
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('falls back to public out_trade_no verification when resume_token recovery fails in legacy return flows', async () => {
routeState.query = {
resume_token: 'resume-fail',
out_trade_no: 'legacy-should-not-run',
trade_status: 'TRADE_SUCCESS',
}
resolveOrderPublicByResumeToken.mockRejectedValueOnce(new Error('resume failed'))
verifyOrderPublic.mockResolvedValueOnce({
data: {
...orderFactory('PAID'),
out_trade_no: 'legacy-should-not-run',
},
})
mount(PaymentResultView, {
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
......@@ -217,16 +280,47 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-fail')
expect(verifyOrder).not.toHaveBeenCalled()
expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-should-not-run')
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.success')
})
it('does not use anonymous out_trade_no verification when no signed resume context is available', async () => {
it('ignores a stale global recovery snapshot when legacy return markers do not identify the order', async () => {
routeState.query = {
trade_status: 'TRADE_SUCCESS',
}
window.localStorage.setItem(
PAYMENT_RECOVERY_STORAGE_KEY,
JSON.stringify(recoverySnapshotFactory('resume-stale')),
)
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
},
},
})
await flushPromises()
expect(resolveOrderPublicByResumeToken).not.toHaveBeenCalled()
expect(verifyOrderPublic).not.toHaveBeenCalled()
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.failed')
expect(wrapper.text()).not.toContain('sub2_20260420abcd1234')
})
it('uses public out_trade_no verification when no signed resume context is available', async () => {
routeState.query = {
out_trade_no: 'legacy-123',
trade_status: 'TRADE_SUCCESS',
}
verifyOrderPublic.mockResolvedValue({
data: orderFactory('PAID'),
})
mount(PaymentResultView, {
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
......@@ -236,7 +330,9 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(verifyOrder).not.toHaveBeenCalled()
expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-123')
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.success')
})
it('does not use public out_trade_no verification for bare order numbers without legacy return markers', async () => {
......@@ -254,7 +350,7 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(verifyOrder).not.toHaveBeenCalled()
expect(verifyOrderPublic).not.toHaveBeenCalled()
})
it('resolves order by resume token when local recovery snapshot is missing', 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 checkoutInfoWithPlansFixture() {
return {
data: {
...checkoutInfoFixture().data,
plans: [
{
id: 7,
group_id: 3,
name: 'Starter',
description: '',
price: 128,
original_price: 0,
validity_days: 30,
validity_unit: 'day',
rate_multiplier: 1,
daily_limit_usd: null,
weekly_limit_usd: null,
monthly_limit_usd: null,
features: [],
group_platform: 'openai',
sort_order: 1,
for_sale: true,
group_name: 'OpenAI',
},
],
},
}
}
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',
out_trade_no: 'sub2_jsapi_123',
result_type: 'jsapi_ready' as const,
resume_token: resumeToken,
jsapi: {
appId: 'wx123',
timeStamp: '1712345678',
nonceStr: 'nonce',
package: 'prepay_id=wx123',
signType: 'RSA',
paySign: 'signed',
},
}
}
function oauthOrderFixture() {
return {
order_id: 456,
amount: 128,
pay_amount: 128,
fee_rate: 0,
expires_at: '2099-01-01T00:10:00.000Z',
payment_type: 'wxpay',
result_type: 'oauth_required' as const,
oauth: {
authorize_url: '/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay&redirect=%2Fpurchase%3Ffrom%3Dwechat',
appid: 'wx123',
scope: 'snsapi_base',
redirect_url: '/auth/wechat/payment/callback',
},
}
}
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',
out_trade_no: 'sub2_jsapi_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()
})
it('clears stale recovery state when JSAPI never becomes available', async () => {
vi.useFakeTimers()
createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-missing-bridge'))
;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined
const wrapper = shallowMount(PaymentView, {
global: {
stubs: {
Teleport: true,
Transition: false,
},
},
})
await flushPromises()
await vi.advanceTimersByTimeAsync(4000)
await flushPromises()
await flushPromises()
expect(showError).toHaveBeenCalledWith(
'payment.errors.wechatJsapiUnavailable payment.errors.wechatOpenInWeChatHint',
)
expect(routerPush).not.toHaveBeenCalled()
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
expect(wrapper.html()).not.toContain('payment-status-panel-stub')
})
it('clears a stale recovery snapshot before handling wechat resume callback params', async () => {
createOrder.mockRejectedValueOnce(new Error('resume failed'))
window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({
orderId: 999,
amount: 66,
qrCode: 'stale-qr',
expiresAt: '2099-01-01T00:10:00.000Z',
paymentType: 'alipay',
payUrl: 'https://pay.example.com/stale',
outTradeNo: 'stale-out-trade-no',
clientSecret: '',
payAmount: 66,
orderType: 'balance',
paymentMode: 'popup',
resumeToken: '',
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
}))
shallowMount(PaymentView, {
global: {
stubs: {
Teleport: true,
Transition: false,
},
},
})
await flushPromises()
await flushPromises()
expect(createOrder).toHaveBeenCalledWith(expect.objectContaining({
wechat_resume_token: 'resume-token-123',
}))
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
})
it('keeps subscription resume context for token-only WeChat callbacks', async () => {
routeState.query = {
wechat_resume: '1',
wechat_resume_token: 'resume-subscription-7',
payment_type: 'wxpay_direct',
order_type: 'subscription',
plan_id: '7',
}
getCheckoutInfo.mockResolvedValue(checkoutInfoWithPlansFixture())
createOrder.mockResolvedValue(oauthOrderFixture())
const originalLocation = window.location
const locationState = {
href: 'http://localhost/purchase',
origin: 'http://localhost',
}
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState,
})
shallowMount(PaymentView, {
global: {
stubs: {
Teleport: true,
Transition: false,
},
},
})
await flushPromises()
await flushPromises()
expect(routerReplace).toHaveBeenCalledWith({ path: '/purchase', query: {} })
expect(createOrder).toHaveBeenCalledWith(expect.objectContaining({
payment_type: 'wxpay',
order_type: 'subscription',
plan_id: 7,
wechat_resume_token: 'resume-subscription-7',
}))
expect(locationState.href).toContain('/api/v1/auth/oauth/wechat/payment/start?')
expect(new URL(locationState.href, 'http://localhost').searchParams.get('redirect')).toBe(
'/purchase?from=wechat&payment_type=wxpay&order_type=subscription&plan_id=7',
)
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
it('shows explicit H5 authorization guidance instead of failing silently', async () => {
routeState.query = {
wechat_resume: '1',
wechat_resume_token: 'resume-token-h5',
payment_type: 'wxpay_direct',
}
createOrder.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
shallowMount(PaymentView, {
global: {
stubs: {
Teleport: true,
Transition: false,
},
},
})
await flushPromises()
await flushPromises()
expect(showError).toHaveBeenCalledWith(
'payment.errors.wechatH5NotAuthorized payment.errors.wechatOpenInWeChatHint',
)
})
})
......@@ -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'),
......
......@@ -14,8 +14,9 @@ describe('parseWechatResumeRoute', () => {
}, [], 88)).toEqual({
wechatResumeToken: 'resume-token-123',
paymentType: 'wxpay',
orderType: 'balance',
orderType: 'subscription',
orderAmount: 0,
planId: 7,
})
})
......
......@@ -19,22 +19,38 @@ function readQueryString(query: LocationQuery, key: string): string {
return typeof value === 'string' ? value : ''
}
export function hasWechatResumeQuery(query: LocationQuery): boolean {
if (readQueryString(query, 'wechat_resume') === '1') {
return true
}
return readQueryString(query, 'wechat_resume_token') !== ''
|| readQueryString(query, 'openid') !== ''
}
export function parseWechatResumeRoute(
query: LocationQuery,
plans: SubscriptionPlan[],
fallbackBalanceAmount: number,
): ParsedWechatResumeRoute | null {
if (readQueryString(query, 'wechat_resume') !== '1') {
if (!hasWechatResumeQuery(query)) {
return null
}
const wechatResumeToken = readQueryString(query, 'wechat_resume_token')
const paymentType = normalizeVisibleMethod(readQueryString(query, 'payment_type')) || 'wxpay'
const planId = Number.parseInt(readQueryString(query, 'plan_id'), 10)
const hasPlanId = Number.isFinite(planId) && planId > 0
const orderType = readQueryString(query, 'order_type') === 'subscription' || hasPlanId
? 'subscription'
: 'balance'
if (wechatResumeToken) {
return {
wechatResumeToken,
paymentType: 'wxpay',
orderType: 'balance',
paymentType,
orderType,
orderAmount: 0,
planId: hasPlanId ? planId : undefined,
}
}
......@@ -43,9 +59,6 @@ export function parseWechatResumeRoute(
return null
}
const paymentType = normalizeVisibleMethod(readQueryString(query, 'payment_type')) || 'wxpay'
const orderType = readQueryString(query, 'order_type') === 'subscription' ? 'subscription' : 'balance'
const planId = Number.parseInt(readQueryString(query, 'plan_id'), 10)
const rawAmount = Number.parseFloat(readQueryString(query, 'amount'))
const orderAmount = Number.isFinite(rawAmount) && rawAmount > 0
? rawAmount
......@@ -58,7 +71,7 @@ export function parseWechatResumeRoute(
paymentType,
orderType,
orderAmount,
planId: Number.isFinite(planId) && planId > 0 ? planId : undefined,
planId: hasPlanId ? planId : undefined,
}
}
......
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