mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 21:50:44 +08:00
feat: add oauth callback email binding ui
This commit is contained in:
@@ -9,6 +9,7 @@ const showError = vi.fn()
|
||||
const setToken = vi.fn()
|
||||
const exchangePendingOAuthCompletion = vi.fn()
|
||||
const completeLinuxDoOAuthRegistration = vi.fn()
|
||||
const apiClientPost = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
@@ -39,6 +40,12 @@ vi.mock('@/stores', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post: (...args: any[]) => apiClientPost(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||
return {
|
||||
@@ -56,6 +63,7 @@ describe('LinuxDoCallbackView', () => {
|
||||
setToken.mockReset()
|
||||
exchangePendingOAuthCompletion.mockReset()
|
||||
completeLinuxDoOAuthRegistration.mockReset()
|
||||
apiClientPost.mockReset()
|
||||
})
|
||||
|
||||
it('does not send adoption decisions during the initial exchange', async () => {
|
||||
@@ -239,4 +247,101 @@ describe('LinuxDoCallbackView', () => {
|
||||
adoptAvatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
redirect: '/welcome',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'LinuxDo Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||
})
|
||||
apiClientPost.mockResolvedValue({
|
||||
data: {
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
await checkboxes[1].setValue(false)
|
||||
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue(' new@example.com ')
|
||||
await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
||||
email: 'new@example.com',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false
|
||||
})
|
||||
expect(setToken).toHaveBeenCalledWith('new-access-token')
|
||||
expect(replace).toHaveBeenCalledWith('/welcome')
|
||||
})
|
||||
|
||||
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'bind_login_required',
|
||||
redirect: '/profile/security',
|
||||
email: 'existing@example.com',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'LinuxDo Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||
})
|
||||
apiClientPost.mockResolvedValue({
|
||||
data: {
|
||||
access_token: 'bind-access-token',
|
||||
refresh_token: 'bind-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
await checkboxes[0].setValue(false)
|
||||
await wrapper.get('[data-testid="linuxdo-bind-login-email"]').setValue('existing@example.com')
|
||||
await wrapper.get('[data-testid="linuxdo-bind-login-password"]').setValue('secret-password')
|
||||
await wrapper.get('[data-testid="linuxdo-bind-login-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/bind-login', {
|
||||
email: 'existing@example.com',
|
||||
password: 'secret-password',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true
|
||||
})
|
||||
expect(setToken).toHaveBeenCalledWith('bind-access-token')
|
||||
expect(replace).toHaveBeenCalledWith('/profile/security')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -184,6 +184,77 @@ describe('OidcCallbackView', () => {
|
||||
expect(replace).toHaveBeenCalledWith('/profile')
|
||||
})
|
||||
|
||||
it('renders pending email collection ui and routes to register with the entered email', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
redirect: '/profile',
|
||||
provider_fallback: 'ExampleID'
|
||||
})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('Continue with email')
|
||||
|
||||
await wrapper.get('input[type="email"]').setValue('alice@example.com')
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(replace).toHaveBeenCalledWith({
|
||||
path: '/register',
|
||||
query: {
|
||||
email: 'alice@example.com',
|
||||
redirect: '/profile',
|
||||
provider: 'ExampleID'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('renders existing-account binding ui and routes to login', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'existing_account_binding_required',
|
||||
redirect: '/profile',
|
||||
existing_account_email: 'alice@example.com'
|
||||
})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('alice@example.com')
|
||||
expect(wrapper.text()).toContain('Sign in to bind')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(replace).toHaveBeenCalledWith({
|
||||
path: '/login',
|
||||
query: {
|
||||
email: 'alice@example.com',
|
||||
redirect: '/profile',
|
||||
provider: 'ExampleID'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
|
||||
@@ -5,14 +5,19 @@ import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
|
||||
const {
|
||||
exchangePendingOAuthCompletionMock,
|
||||
completeWeChatOAuthRegistrationMock,
|
||||
prepareOAuthBindAccessTokenCookieMock,
|
||||
getAuthTokenMock,
|
||||
replaceMock,
|
||||
setTokenMock,
|
||||
showSuccessMock,
|
||||
showErrorMock,
|
||||
routeState,
|
||||
locationState,
|
||||
} = vi.hoisted(() => ({
|
||||
exchangePendingOAuthCompletionMock: vi.fn(),
|
||||
completeWeChatOAuthRegistrationMock: vi.fn(),
|
||||
prepareOAuthBindAccessTokenCookieMock: vi.fn(),
|
||||
getAuthTokenMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
setTokenMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
@@ -20,6 +25,14 @@ const {
|
||||
routeState: {
|
||||
query: {} as Record<string, unknown>,
|
||||
},
|
||||
locationState: {
|
||||
current: {
|
||||
href: 'http://localhost/auth/wechat/callback',
|
||||
hash: '',
|
||||
search: '',
|
||||
pathname: '/auth/wechat/callback'
|
||||
} as { href: string; hash: string; search: string; pathname: string },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -94,6 +107,8 @@ vi.mock('@/api/auth', async () => {
|
||||
...actual,
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
|
||||
completeWeChatOAuthRegistration: (...args: any[]) => completeWeChatOAuthRegistrationMock(...args),
|
||||
prepareOAuthBindAccessTokenCookie: (...args: any[]) => prepareOAuthBindAccessTokenCookieMock(...args),
|
||||
getAuthToken: (...args: any[]) => getAuthTokenMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -105,8 +120,24 @@ describe('WechatCallbackView', () => {
|
||||
setTokenMock.mockReset()
|
||||
showSuccessMock.mockReset()
|
||||
showErrorMock.mockReset()
|
||||
prepareOAuthBindAccessTokenCookieMock.mockReset()
|
||||
getAuthTokenMock.mockReset()
|
||||
routeState.query = {}
|
||||
localStorage.clear()
|
||||
locationState.current = {
|
||||
href: 'http://localhost/auth/wechat/callback',
|
||||
hash: '',
|
||||
search: '',
|
||||
pathname: '/auth/wechat/callback'
|
||||
}
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: locationState.current,
|
||||
})
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not send adoption decisions during the initial exchange', async () => {
|
||||
@@ -269,4 +300,61 @@ describe('WechatCallbackView', () => {
|
||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-invite-token')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
||||
})
|
||||
|
||||
it('offers existing-account email collection during invitation flow', async () => {
|
||||
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
redirect: '/usage',
|
||||
})
|
||||
getAuthTokenMock.mockReturnValue(null)
|
||||
|
||||
const wrapper = mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const emailInput = wrapper.get('[data-testid="existing-account-email"]')
|
||||
await emailInput.setValue('user@example.com')
|
||||
await wrapper.get('[data-testid="existing-account-submit"]').trigger('click')
|
||||
|
||||
expect(replaceMock).toHaveBeenCalledTimes(1)
|
||||
expect(replaceMock.mock.calls[0]?.[0]).toContain('/login?')
|
||||
expect(replaceMock.mock.calls[0]?.[0]).toContain('wechat_bind_existing%3D1')
|
||||
expect(replaceMock.mock.calls[0]?.[0]).toContain('email=user%40example.com')
|
||||
})
|
||||
|
||||
it('restarts the current-user bind flow after returning from login', async () => {
|
||||
routeState.query = {
|
||||
wechat_bind_existing: '1',
|
||||
redirect: '/profile'
|
||||
}
|
||||
getAuthTokenMock.mockReturnValue('existing-auth-token')
|
||||
|
||||
mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled()
|
||||
expect(prepareOAuthBindAccessTokenCookieMock).toHaveBeenCalledTimes(1)
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
|
||||
expect(locationState.current.href).toContain('intent=bind_current_user')
|
||||
expect(locationState.current.href).toContain('redirect=%2Fprofile')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user