From b6233a1a589b072dc535f4866cc3ece63bd69027 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Mon, 19 Feb 2024 22:27:32 +0800 Subject: [PATCH] feat(core,test): add PUT /users/:userId/identities/:target API (#5410) --- .../src/routes/admin-user/social.openapi.json | 29 +++++++++ .../core/src/routes/admin-user/social.test.ts | 64 +++++++++++++++++++ packages/core/src/routes/admin-user/social.ts | 43 +++++++++++++ .../integration-tests/src/api/admin-user.ts | 4 ++ .../src/tests/api/admin-user.test.ts | 26 ++++++++ .../src/foundations/jsonb-types/users.ts | 2 +- 6 files changed, 167 insertions(+), 1 deletion(-) diff --git a/packages/core/src/routes/admin-user/social.openapi.json b/packages/core/src/routes/admin-user/social.openapi.json index 82c2d1b53..4dde9e3c1 100644 --- a/packages/core/src/routes/admin-user/social.openapi.json +++ b/packages/core/src/routes/admin-user/social.openapi.json @@ -38,6 +38,35 @@ }, "summary": "Delete social identity from user", "description": "Delete a social identity from the user." + }, + "put": { + "parameters": [], + "responses": { + "200": { + "description": "The identity is updated." + }, + "201": { + "description": "The identity is created." + } + }, + "summary": "Update social identity of user", + "description": "Directly update a social identity of the user.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "userId": { + "description": "The user's social identity ID." + }, + "details": { + "description": "The user's social identity details." + } + } + } + } + } + } } } } diff --git a/packages/core/src/routes/admin-user/social.test.ts b/packages/core/src/routes/admin-user/social.test.ts index 1f0a1dbc5..0d6e4677a 100644 --- a/packages/core/src/routes/admin-user/social.test.ts +++ b/packages/core/src/routes/admin-user/social.test.ts @@ -18,6 +18,11 @@ import { createRequester } from '#src/utils/test-utils.js'; const { jest } = import.meta; const notExistedUserId = 'notExistedUserId'; +const newTarget = 'newTarget'; +const newIdentity = { + userId: 'newUserId', +}; + const mockHasUserWithIdentity = jest.fn(async () => false); const mockedQueries = { users: { @@ -79,6 +84,65 @@ describe('Admin user social identities APIs', () => { const userRequest = createRequester({ authedRoutes: adminUserSocialRoutes, tenantContext }); + describe('PUT /users/:userId/identities/:target', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should throw if user cannot be found', async () => { + await expect( + userRequest.put(`/users/${notExistedUserId}/identities/${newTarget}`).send(newIdentity) + ).resolves.toHaveProperty('status', 404); + expect(updateUserById).not.toHaveBeenCalled(); + }); + + it('should throw if user already has the social identity', async () => { + mockHasUserWithIdentity.mockResolvedValueOnce(true); + const mockedFindUserById = findUserById as jest.Mock; + mockedFindUserById.mockImplementationOnce(() => ({ + ...mockUser, + identities: {}, + })); + await expect( + userRequest.put(`/users/foo/identities/${newTarget}`).send(newIdentity) + ).resolves.toHaveProperty('status', 422); + expect(updateUserById).not.toHaveBeenCalled(); + }); + + it('should update user with new social identity (response status 200)', async () => { + const mockedFindUserById = findUserById as jest.Mock; + mockedFindUserById.mockImplementationOnce(() => ({ + ...mockUser, + identities: { [newTarget]: { userId: 'socialIdForTarget1' } }, + })); + await expect( + userRequest.put(`/users/foo/identities/${newTarget}`).send(newIdentity) + ).resolves.toHaveProperty('status', 200); + expect(updateUserById).toHaveBeenCalledWith('foo', { + identities: { + [newTarget]: newIdentity, + }, + }); + }); + + it('should add new social identity to the user (response status 201)', async () => { + const mockedFindUserById = findUserById as jest.Mock; + mockedFindUserById.mockImplementationOnce(() => ({ + ...mockUser, + identities: { connectorTarget1: { userId: 'socialIdForTarget1' } }, + })); + await expect( + userRequest.put(`/users/foo/identities/${newTarget}`).send(newIdentity) + ).resolves.toHaveProperty('status', 201); + expect(updateUserById).toHaveBeenCalledWith('foo', { + identities: { + connectorTarget1: { userId: 'socialIdForTarget1' }, + [newTarget]: newIdentity, + }, + }); + }); + }); + describe('POST /users/:userId/identities', () => { it('should throw if user cannot be found', async () => { // Mock connector with id 'id0' is declared in mockLogtoConnectorList diff --git a/packages/core/src/routes/admin-user/social.ts b/packages/core/src/routes/admin-user/social.ts index a62741000..1302f9976 100644 --- a/packages/core/src/routes/admin-user/social.ts +++ b/packages/core/src/routes/admin-user/social.ts @@ -1,6 +1,7 @@ import { notImplemented } from '@logto/cli/lib/connector/consts.js'; import { ConnectorType, + identityGuard, identitiesGuard, userInfoSelectFields, userProfileResponseGuard, @@ -24,6 +25,48 @@ export default function adminUserSocialRoutes( connectors: { getLogtoConnectorById }, } = tenant; + router.put( + '/users/:userId/identities/:target', + koaGuard({ + params: object({ userId: string(), target: string() }), + body: identityGuard, + response: identitiesGuard, + status: [200, 201, 404, 422], + }), + async (ctx, next) => { + const { + params: { userId, target }, + body: identity, + } = ctx.guard; + + const user = await findUserById(userId); + + assertThat( + !(await hasUserWithIdentity(target, identity.userId, userId)), + new RequestError({ + code: 'user.identity_already_in_use', + status: 422, + }) + ); + + // The identity is being created if the `target` does not exist in the user's identities. + if (!(target in user.identities)) { + ctx.status = 201; + } + + const updatedUser = await updateUserById(userId, { + identities: { + ...user.identities, + [target]: identity, + }, + }); + + ctx.body = updatedUser.identities; + + return next(); + } + ); + router.post( '/users/:userId/identities', koaGuard({ diff --git a/packages/integration-tests/src/api/admin-user.ts b/packages/integration-tests/src/api/admin-user.ts index 0c3c69289..4a46a5ee7 100644 --- a/packages/integration-tests/src/api/admin-user.ts +++ b/packages/integration-tests/src/api/admin-user.ts @@ -1,5 +1,6 @@ import type { Identities, + Identity, MfaFactor, MfaVerification, Role, @@ -93,6 +94,9 @@ export const postUserIdentity = async ( }) .json(); +export const putUserIdentity = async (userId: string, target: string, identity: Identity) => + authedAdminApi.put(`users/${userId}/identities/${target}`, { json: identity }).json(); + export const verifyUserPassword = async (userId: string, password: string) => authedAdminApi.post(`users/${userId}/password/verify`, { json: { password } }); diff --git a/packages/integration-tests/src/tests/api/admin-user.test.ts b/packages/integration-tests/src/tests/api/admin-user.test.ts index 12de031a6..01be84c3f 100644 --- a/packages/integration-tests/src/tests/api/admin-user.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.test.ts @@ -16,6 +16,7 @@ import { deleteConnectorById, postUserIdentity, verifyUserPassword, + putUserIdentity, } from '#src/api/index.js'; import { createUserByAdmin, expectRejects } from '#src/helpers/index.js'; import { createNewSocialUserWithUsernameAndPassword } from '#src/helpers/interactions.js'; @@ -141,6 +142,15 @@ describe('admin console user management', () => { const code = 'random_code_from_social'; const socialUserId = 'social_platform_user_id'; const socialUserEmail = 'johndoe@gmail.com'; + const anotherSocialUserId = 'another_social_platform_user_id'; + const socialTarget = 'social_target'; + const socialIdentity = { + userId: 'social_identity_user_id', + details: { + age: 21, + email: 'foo@logto.io', + }, + }; const { id: userId } = await createUserByAdmin(); const { redirectTo } = await getConnectorAuthorizationUri(connectorId, state, redirectUri); @@ -164,6 +174,22 @@ describe('admin console user management', () => { }, }); + const updatedIdentity = await putUserIdentity(userId, mockSocialConnectorTarget, { + userId: anotherSocialUserId, + }); + expect(updatedIdentity).toHaveProperty(mockSocialConnectorTarget); + expect(updatedIdentity[mockSocialConnectorTarget]).toMatchObject({ + userId: anotherSocialUserId, + }); + + const updatedIdentities = await putUserIdentity(userId, socialTarget, socialIdentity); + expect(updatedIdentities).toMatchObject({ + [mockSocialConnectorTarget]: { + userId: anotherSocialUserId, + }, + [socialTarget]: socialIdentity, + }); + await deleteConnectorById(connectorId); }); diff --git a/packages/schemas/src/foundations/jsonb-types/users.ts b/packages/schemas/src/foundations/jsonb-types/users.ts index 96923f422..9fdb2070f 100644 --- a/packages/schemas/src/foundations/jsonb-types/users.ts +++ b/packages/schemas/src/foundations/jsonb-types/users.ts @@ -4,7 +4,7 @@ import { MfaFactor } from './sign-in-experience.js'; export const roleNamesGuard = z.string().array(); -const identityGuard = z.object({ +export const identityGuard = z.object({ userId: z.string(), details: z.record(z.unknown()).optional(), // Connector's userinfo details, schemaless });