fix(profile): stabilize binding compatibility and frontend checks
This commit is contained in:
117
frontend/src/api/__tests__/admin.users.spec.ts
Normal file
117
frontend/src/api/__tests__/admin.users.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post,
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
bindUserAuthIdentity,
|
||||
type AdminBindAuthIdentityRequest,
|
||||
type AdminBoundAuthIdentity,
|
||||
} from '@/api/admin/users'
|
||||
|
||||
type Assert<T extends true> = T
|
||||
type IsExact<T, U> = (
|
||||
(<G>() => G extends T ? 1 : 2) extends (<G>() => G extends U ? 1 : 2)
|
||||
? ((<G>() => G extends U ? 1 : 2) extends (<G>() => G extends T ? 1 : 2) ? true : false)
|
||||
: false
|
||||
)
|
||||
|
||||
type ExpectedAdminBindAuthIdentityRequest = {
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
issuer?: string
|
||||
metadata?: Record<string, unknown>
|
||||
channel?: {
|
||||
channel: string
|
||||
channel_app_id: string
|
||||
channel_subject: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type ExpectedAdminBoundAuthIdentity = {
|
||||
user_id: number
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
verified_at?: string | null
|
||||
issuer?: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
channel?: {
|
||||
channel: string
|
||||
channel_app_id: string
|
||||
channel_subject: string
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
} | null
|
||||
}
|
||||
|
||||
const requestContractExact: Assert<
|
||||
IsExact<AdminBindAuthIdentityRequest, ExpectedAdminBindAuthIdentityRequest>
|
||||
> = true
|
||||
const responseContractExact: Assert<
|
||||
IsExact<AdminBoundAuthIdentity, ExpectedAdminBoundAuthIdentity>
|
||||
> = true
|
||||
|
||||
describe('admin users api auth identity binding', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
})
|
||||
|
||||
it('posts the backend-compatible auth identity bind payload and returns the backend response shape', async () => {
|
||||
const payload: AdminBindAuthIdentityRequest = {
|
||||
provider_type: 'wechat',
|
||||
provider_key: 'wechat-main',
|
||||
provider_subject: 'union-123',
|
||||
metadata: { source: 'admin-repair' },
|
||||
channel: {
|
||||
channel: 'open',
|
||||
channel_app_id: 'wx-open',
|
||||
channel_subject: 'openid-123',
|
||||
metadata: { scene: 'migration' },
|
||||
},
|
||||
}
|
||||
|
||||
const response: AdminBoundAuthIdentity = {
|
||||
user_id: 9,
|
||||
provider_type: 'wechat',
|
||||
provider_key: 'wechat-main',
|
||||
provider_subject: 'union-123',
|
||||
verified_at: '2026-04-22T00:00:00Z',
|
||||
issuer: null,
|
||||
metadata: { source: 'admin-repair' },
|
||||
created_at: '2026-04-22T00:00:00Z',
|
||||
updated_at: '2026-04-22T00:00:00Z',
|
||||
channel: {
|
||||
channel: 'open',
|
||||
channel_app_id: 'wx-open',
|
||||
channel_subject: 'openid-123',
|
||||
metadata: { scene: 'migration' },
|
||||
created_at: '2026-04-22T00:00:00Z',
|
||||
updated_at: '2026-04-22T00:00:00Z',
|
||||
},
|
||||
}
|
||||
post.mockResolvedValue({ data: response })
|
||||
|
||||
const result = await bindUserAuthIdentity(9, payload)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/users/9/auth-identities', payload)
|
||||
expect(result).toEqual(response)
|
||||
})
|
||||
|
||||
it('keeps bind auth identity request and response types aligned with the backend contract', () => {
|
||||
expect(requestContractExact).toBe(true)
|
||||
expect(responseContractExact).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -91,6 +91,22 @@ describe('API Client', () => {
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.params?.timezone).toBeUndefined()
|
||||
})
|
||||
|
||||
it('请求默认带 withCredentials 以支持跨域 cookie', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.post('/auth/oauth/bind-token')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.withCredentials).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 响应拦截器 ---
|
||||
|
||||
32
frontend/src/api/__tests__/user.spec.ts
Normal file
32
frontend/src/api/__tests__/user.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('user api oauth binding urls', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/api/v1')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('builds third-party bind urls against the bind start endpoint', async () => {
|
||||
const { buildOAuthBindingStartURL } = await import('@/api/user')
|
||||
|
||||
expect(buildOAuthBindingStartURL('linuxdo', { redirectTo: '/settings/profile' })).toBe(
|
||||
'https://api.example.com/api/v1/auth/oauth/linuxdo/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user'
|
||||
)
|
||||
expect(
|
||||
buildOAuthBindingStartURL('wechat', {
|
||||
redirectTo: '/settings/profile',
|
||||
wechatOAuthSettings: {
|
||||
wechat_oauth_open_enabled: true,
|
||||
wechat_oauth_mp_enabled: false,
|
||||
wechat_oauth_mobile_enabled: false
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
'https://api.example.com/api/v1/auth/oauth/wechat/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user&mode=open'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,26 +8,40 @@ import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/
|
||||
|
||||
export interface AdminBindAuthIdentityChannelRequest {
|
||||
channel: string
|
||||
channel_app_id?: string
|
||||
channel_app_id: string
|
||||
channel_subject: string
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface AdminBindAuthIdentityRequest {
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
issuer?: string
|
||||
metadata?: Record<string, unknown>
|
||||
issuer?: string | null
|
||||
metadata?: Record<string, unknown> | null
|
||||
channel?: AdminBindAuthIdentityChannelRequest
|
||||
}
|
||||
|
||||
export interface AdminBoundAuthIdentityChannel {
|
||||
channel: string
|
||||
channel_app_id: string
|
||||
channel_subject: string
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminBoundAuthIdentity {
|
||||
identity_id: number
|
||||
user_id: number
|
||||
provider_type: string
|
||||
provider_key: string
|
||||
provider_subject: string
|
||||
channel_id?: number | null
|
||||
verified_at?: string | null
|
||||
issuer?: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
channel?: AdminBoundAuthIdentityChannel | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -194,6 +194,7 @@ export interface OAuthTokenResponse {
|
||||
}
|
||||
|
||||
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
|
||||
auth_result?: string
|
||||
redirect?: string
|
||||
error?: string
|
||||
requires_2fa?: boolean
|
||||
@@ -206,7 +207,9 @@ export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenRespons
|
||||
|
||||
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
|
||||
|
||||
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {}
|
||||
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {
|
||||
auth_result?: string
|
||||
}
|
||||
|
||||
export interface PendingOAuthSendVerifyCodeResponse extends SendVerifyCodeResponse {
|
||||
auth_result?: string
|
||||
|
||||
@@ -13,6 +13,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
|
||||
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
withCredentials: true,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
|
||||
@@ -150,7 +150,7 @@ export function buildOAuthBindingStartURL(
|
||||
params.set('mode', mode)
|
||||
}
|
||||
|
||||
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
||||
return `${normalized}/auth/oauth/${provider}/bind/start?${params.toString()}`
|
||||
}
|
||||
|
||||
export async function startOAuthBinding(
|
||||
|
||||
@@ -299,20 +299,42 @@ const emailSubmitActionLabel = computed(() =>
|
||||
: t('profile.authBindings.confirmEmailBindAction')
|
||||
)
|
||||
|
||||
function resolveLegacyCompatibleWeChatSettings(
|
||||
settings: WeChatOAuthPublicSettings | null | undefined
|
||||
): (WeChatOAuthPublicSettings & {
|
||||
wechat_oauth_open_enabled: boolean
|
||||
wechat_oauth_mp_enabled: boolean
|
||||
}) | null {
|
||||
if (!settings) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (hasExplicitWeChatOAuthCapabilities(settings)) {
|
||||
return settings
|
||||
}
|
||||
|
||||
if (typeof settings.wechat_oauth_enabled !== 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...settings,
|
||||
wechat_oauth_open_enabled: settings.wechat_oauth_enabled,
|
||||
wechat_oauth_mp_enabled: settings.wechat_oauth_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
const wechatOAuthSettings = computed<WeChatOAuthPublicSettings | null>(() => {
|
||||
if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) {
|
||||
return appStore.cachedPublicSettings
|
||||
const cachedSettings = resolveLegacyCompatibleWeChatSettings(appStore.cachedPublicSettings)
|
||||
if (cachedSettings) {
|
||||
return cachedSettings
|
||||
}
|
||||
|
||||
if (typeof props.wechatOpenEnabled === 'boolean' && typeof props.wechatMpEnabled === 'boolean') {
|
||||
return {
|
||||
wechat_oauth_enabled: props.wechatEnabled,
|
||||
wechat_oauth_open_enabled: props.wechatOpenEnabled,
|
||||
wechat_oauth_mp_enabled: props.wechatMpEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return resolveLegacyCompatibleWeChatSettings({
|
||||
wechat_oauth_enabled: props.wechatEnabled,
|
||||
wechat_oauth_open_enabled: props.wechatOpenEnabled,
|
||||
wechat_oauth_mp_enabled: props.wechatMpEnabled,
|
||||
})
|
||||
})
|
||||
|
||||
const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStartStrict(wechatOAuthSettings.value))
|
||||
@@ -362,6 +384,17 @@ function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus |
|
||||
return binding
|
||||
}
|
||||
|
||||
function getDisplayableEmail(user: User | null | undefined): string {
|
||||
const email = user?.email?.trim() || ''
|
||||
if (!email) {
|
||||
return ''
|
||||
}
|
||||
if (email.endsWith('.invalid') && !getBindingStatusForUser(user, 'email')) {
|
||||
return ''
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
function isProviderEnabledForBinding(provider: BindableProvider): boolean {
|
||||
if (provider === 'linuxdo') {
|
||||
return props.linuxdoEnabled
|
||||
@@ -444,14 +477,7 @@ function providerIconClass(provider: UserAuthProvider): string {
|
||||
|
||||
function providerSummary(provider: UserAuthProvider): string {
|
||||
if (provider === 'email') {
|
||||
const email = currentUser.value?.email?.trim() || ''
|
||||
if (!email) {
|
||||
return ''
|
||||
}
|
||||
if (currentUser.value?.email_bound === false && email.endsWith('.invalid')) {
|
||||
return ''
|
||||
}
|
||||
return email
|
||||
return getDisplayableEmail(currentUser.value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import ProfileAvatarCard from '@/components/user/profile/ProfileAvatarCard.vue'
|
||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||
import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue'
|
||||
import type { User, UserAuthProvider, UserProfileSourceContext } from '@/types'
|
||||
import type { User, UserAuthBindingStatus, UserAuthProvider, UserProfileSourceContext } from '@/types'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
user: User | null
|
||||
@@ -206,6 +206,29 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function normalizeBindingStatus(binding: boolean | UserAuthBindingStatus | undefined): boolean | null {
|
||||
if (typeof binding === 'boolean') {
|
||||
return binding
|
||||
}
|
||||
if (!binding) {
|
||||
return null
|
||||
}
|
||||
if (typeof binding.bound === 'boolean') {
|
||||
return binding.bound
|
||||
}
|
||||
return Boolean(binding.provider_subject || binding.issuer || binding.provider_key)
|
||||
}
|
||||
|
||||
function isEmailBound(user: User | null | undefined): boolean {
|
||||
if (typeof user?.email_bound === 'boolean') {
|
||||
return user.email_bound
|
||||
}
|
||||
|
||||
const nested = user?.auth_bindings?.email ?? user?.identity_bindings?.email
|
||||
const normalized = normalizeBindingStatus(nested)
|
||||
return normalized ?? false
|
||||
}
|
||||
|
||||
const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '')
|
||||
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user'))
|
||||
const primaryEmailDisplay = computed(() => {
|
||||
@@ -213,7 +236,7 @@ const primaryEmailDisplay = computed(() => {
|
||||
if (!email) {
|
||||
return ''
|
||||
}
|
||||
if (props.user?.email_bound === false && email.endsWith('.invalid')) {
|
||||
if (email.endsWith('.invalid') && !isEmailBound(props.user)) {
|
||||
return ''
|
||||
}
|
||||
return email
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('ProfileIdentityBindingsSection', () => {
|
||||
expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the WeChat bind action when only the legacy aggregate setting is present', () => {
|
||||
it('keeps the WeChat bind action visible when only the legacy aggregate setting is present', () => {
|
||||
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
@@ -201,7 +201,28 @@ describe('ProfileIdentityBindingsSection', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('starts the WeChat bind flow when only the legacy aggregate setting is present', async () => {
|
||||
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
props: {
|
||||
user: createUser(),
|
||||
linuxdoEnabled: false,
|
||||
oidcEnabled: false,
|
||||
wechatEnabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-testid="profile-binding-wechat-action"]').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
|
||||
expect(locationState.current.href).toContain('mode=open')
|
||||
expect(locationState.current.href).toContain('intent=bind_current_user')
|
||||
expect(locationState.current.href).toContain('redirect=%2Fprofile')
|
||||
})
|
||||
|
||||
it('uses explicit cached WeChat capabilities and ignores legacy prop fallbacks', () => {
|
||||
@@ -358,6 +379,28 @@ describe('ProfileIdentityBindingsSection', () => {
|
||||
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound')
|
||||
})
|
||||
|
||||
it('does not show a synthetic oauth-only email when only fallback auth bindings mark email as unbound', () => {
|
||||
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
props: {
|
||||
user: createUser({
|
||||
email: 'legacy-user@wechat-connect.invalid',
|
||||
auth_bindings: {
|
||||
email: { bound: false },
|
||||
},
|
||||
}),
|
||||
linuxdoEnabled: false,
|
||||
oidcEnabled: false,
|
||||
wechatEnabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('legacy-user@wechat-connect.invalid')
|
||||
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound')
|
||||
})
|
||||
|
||||
it('keeps the email form available for replacing a bound primary email', async () => {
|
||||
userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined)
|
||||
userApiMocks.bindEmailIdentity.mockResolvedValue(
|
||||
|
||||
@@ -152,6 +152,26 @@ describe('ProfileInfoCard', () => {
|
||||
expect(wrapper.text()).not.toContain('legacy-user@oidc-connect.invalid')
|
||||
})
|
||||
|
||||
it('does not display synthetic oauth-only emails when only legacy identity bindings mark email as unbound', () => {
|
||||
const wrapper = mount(ProfileInfoCard, {
|
||||
props: {
|
||||
user: createUser({
|
||||
email: 'legacy-user@wechat-connect.invalid',
|
||||
identity_bindings: {
|
||||
email: { bound: false }
|
||||
}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Icon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('legacy-user@wechat-connect.invalid')
|
||||
})
|
||||
|
||||
it('renders the approved overview hero and two-column content shell', () => {
|
||||
const wrapper = mount(ProfileInfoCard, {
|
||||
props: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -613,8 +613,12 @@ async function handleBindCurrentAccount() {
|
||||
return
|
||||
}
|
||||
|
||||
await 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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -621,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,
|
||||
|
||||
Reference in New Issue
Block a user