diff --git a/packages/core/src/routes/session/profile.test.ts b/packages/core/src/routes/session/profile.test.ts index 165585553..e79a14c9d 100644 --- a/packages/core/src/routes/session/profile.test.ts +++ b/packages/core/src/routes/session/profile.test.ts @@ -1,9 +1,16 @@ +/* eslint-disable max-lines */ import type { CreateUser, User } from '@logto/schemas'; -import { SignUpIdentifier } from '@logto/schemas'; +import { ConnectorType, SignUpIdentifier } from '@logto/schemas'; import { getUnixTime } from 'date-fns'; import { Provider } from 'oidc-provider'; -import { mockPasswordEncrypted, mockUser, mockUserResponse } from '@/__mocks__'; +import { + mockLogtoConnectorList, + mockPasswordEncrypted, + mockUser, + mockUserResponse, +} from '@/__mocks__'; +import type { SocialUserInfo } from '@/connectors/types'; import { createRequester } from '@/utils/test-utils'; import profileRoutes, { profileRoute } from './profile'; @@ -18,6 +25,7 @@ const mockUpdateUserById = jest.fn( ...data, }) ); +const mockDeleteUserIdentity = jest.fn(); const encryptUserPassword = jest.fn(async (password: string) => ({ passwordEncrypted: password + '_user1', passwordEncryptionMethod: 'Argon2i', @@ -38,6 +46,28 @@ jest.mock('@/lib/user', () => ({ encryptUserPassword: async (password: string) => encryptUserPassword(password), })); +const mockGetLogtoConnectorById = jest.fn(async () => ({ + dbEntry: { enabled: true }, + metadata: { id: 'connectorId', target: 'mock_social' }, + type: ConnectorType.Social, + getAuthorizationUri: jest.fn(async () => ''), +})); + +jest.mock('@/connectors', () => ({ + getLogtoConnectors: jest.fn(async () => mockLogtoConnectorList), + getLogtoConnectorById: jest.fn(async () => mockGetLogtoConnectorById()), +})); + +const mockFindSocialRelatedUser = jest.fn(async () => [ + { id: 'user1', identities: {}, isSuspended: false }, +]); +const mockGetUserInfoByAuthCode = jest.fn(); +jest.mock('@/lib/social', () => ({ + ...jest.requireActual('@/lib/social'), + findSocialRelatedUser: async () => mockFindSocialRelatedUser(), + getUserInfoByAuthCode: async () => mockGetUserInfoByAuthCode(), +})); + jest.mock('@/queries/user', () => ({ ...jest.requireActual('@/queries/user'), findUserById: async () => mockFindUserById(), @@ -45,6 +75,7 @@ jest.mock('@/queries/user', () => ({ hasUserWithEmail: async () => mockHasUserWithEmail(), hasUserWithPhone: async () => mockHasUserWithPhone(), updateUserById: async (id: string, data: Partial) => mockUpdateUserById(id, data), + deleteUserIdentity: async (...args: unknown[]) => mockDeleteUserIdentity(...args), })); const mockFindDefaultSignInExperience = jest.fn(async () => ({ @@ -382,4 +413,85 @@ describe('session -> profileRoutes', () => { expect(mockUpdateUserById).not.toBeCalled(); }); }); + + describe('social identities related APIs', () => { + it('should update social identities for current user', async () => { + const mockSocialUserInfo: SocialUserInfo = { + id: 'social_user_id', + name: 'John Doe', + avatar: 'https://avatar.social.com/johndoe', + email: 'johndoe@social.com', + phone: '123456789', + }; + mockGetUserInfoByAuthCode.mockReturnValueOnce(mockSocialUserInfo); + + const response = await sessionRequest.patch(`${profileRoute}/identities`).send({ + connectorId: 'connectorId', + data: { code: '123456' }, + }); + + expect(response.statusCode).toEqual(204); + expect(mockUpdateUserById).toBeCalledWith( + 'id', + expect.objectContaining({ + identities: { + ...mockUser.identities, + mock_social: { userId: mockSocialUserInfo.id, details: mockSocialUserInfo }, + }, + }) + ); + }); + + it('should throw when the user is not authenticated', async () => { + mockGetSession.mockImplementationOnce( + jest.fn(async () => ({ + accountId: undefined, + loginTs: undefined, + })) + ); + + const response = await sessionRequest.patch(`${profileRoute}/identities`).send({ + connectorId: 'connectorId', + data: { code: '123456' }, + }); + + expect(response.statusCode).toEqual(401); + expect(mockUpdateUserById).not.toBeCalled(); + }); + + it('should throw if last authentication time is over 10 mins ago on linking email', async () => { + mockGetSession.mockImplementationOnce(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 601, + })); + + const response = await sessionRequest + .patch(`${profileRoute}/identities`) + .send({ connectorId: 'connectorId', data: { code: '123456' } }); + + expect(response.statusCode).toEqual(422); + expect(mockUpdateUserById).not.toBeCalled(); + }); + + it('should unlink social identities from user', async () => { + mockFindUserById.mockImplementationOnce(async () => ({ + ...mockUser, + identities: { + mock_social: { + userId: 'social_user_id', + details: { + id: 'social_user_id', + name: 'John Doe', + }, + }, + }, + })); + + const response = await sessionRequest.delete(`${profileRoute}/identities/mock_social`); + + expect(response.statusCode).toEqual(204); + expect(mockDeleteUserIdentity).toBeCalledWith('id', 'mock_social'); + }); + }); }); +/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/session/profile.ts b/packages/core/src/routes/session/profile.ts index d2b862826..eb4406399 100644 --- a/packages/core/src/routes/session/profile.ts +++ b/packages/core/src/routes/session/profile.ts @@ -1,15 +1,18 @@ import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit'; import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas'; +import { has } from '@silverhand/essentials'; import { argon2Verify } from 'hash-wasm'; import pick from 'lodash.pick'; import type { Provider } from 'oidc-provider'; -import { object, string } from 'zod'; +import { object, string, unknown } from 'zod'; +import { getLogtoConnectorById } from '@/connectors'; import RequestError from '@/errors/RequestError'; import { checkSessionHealth } from '@/lib/session'; +import { getUserInfoByAuthCode } from '@/lib/social'; import { encryptUserPassword } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; -import { findUserById, updateUserById } from '@/queries/user'; +import { deleteUserIdentity, findUserById, updateUserById } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import type { AnonymousRouter } from '../types'; @@ -178,4 +181,66 @@ export default function profileRoutes(router: T, prov return next(); }); + + router.patch( + `${profileRoute}/identities`, + koaGuard({ + body: object({ + connectorId: string(), + data: unknown(), + }), + }), + async (ctx, next) => { + const userId = await checkSessionHealth(ctx, provider, verificationTimeout); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { connectorId, data } = ctx.guard.body; + + const { + metadata: { target }, + } = await getLogtoConnectorById(connectorId); + + const socialUserInfo = await getUserInfoByAuthCode(connectorId, data); + const { identities } = await findUserById(userId); + + await updateUserById(userId, { + identities: { + ...identities, + [target]: { userId: socialUserInfo.id, details: socialUserInfo }, + }, + }); + + ctx.status = 204; + + return next(); + } + ); + + router.delete( + `${profileRoute}/identities/:target`, + koaGuard({ + params: object({ target: string() }), + }), + async (ctx, next) => { + const { accountId: userId } = await provider.Session.get(ctx); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { target } = ctx.guard.params; + const { identities } = await findUserById(userId); + + assertThat( + has(identities, target), + new RequestError({ code: 'user.identity_not_exists', status: 404 }) + ); + + console.log('##############shit:', userId, target); + await deleteUserIdentity(userId, target); + + ctx.status = 204; + + return next(); + } + ); }