diff --git a/packages/core/src/queries/user.test.ts b/packages/core/src/queries/user.test.ts index 81c144325..465102621 100644 --- a/packages/core/src/queries/user.test.ts +++ b/packages/core/src/queries/user.test.ts @@ -23,6 +23,7 @@ import { updateUserById, deleteUserById, clearUserCustomDataById, + deleteUserIdentity, } from './user'; const mockQuery: jest.MockedFunction = jest.fn(); @@ -375,4 +376,51 @@ describe('user query', () => { await clearUserCustomDataById(id); }); + + it('clearUserCustomDataById should throw when user can not be found by id', async () => { + const id = 'foo'; + const expectSql = sql` + update ${table} + set ${fields.customData}='{}'::jsonb + where ${fields.id}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([id]); + + return createMockQueryResult([]); + }); + + await expect(clearUserCustomDataById(id)).rejects.toThrowError(); + }); + + it('deleteUserIdentity', async () => { + const userId = 'foo'; + const connectorId = 'connector1'; + + const { connector1, ...restIdentities } = mockUser.identities; + const finalDbvalue = { + ...mockUser, + roleNames: JSON.stringify(mockUser.roleNames), + identities: JSON.stringify(restIdentities), + customData: JSON.stringify(mockUser.customData), + }; + + const expectSql = sql` + update ${table} + set ${fields.identities}=${fields.identities}::jsonb-$1 + where ${fields.id}=$2 + returning * + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([connectorId, mockUser.id]); + + return createMockQueryResult([finalDbvalue]); + }); + + await expect(deleteUserIdentity(userId, connectorId)).resolves.toEqual(finalDbvalue); + }); }); diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 0923827d5..0881f4d32 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -137,3 +137,11 @@ export const clearUserCustomDataById = async (id: string) => { throw new UpdateError(Users, { set: { customData: {} }, where: { id } }); } }; + +export const deleteUserIdentity = async (userId: string, connectorId: string) => + pool.one(sql` + update ${table} + set ${fields.identities}=${fields.identities}::jsonb-${connectorId} + where ${fields.id}=${userId} + returning * + `); diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts index a4594be94..7f49f640e 100644 --- a/packages/core/src/routes/admin-user.test.ts +++ b/packages/core/src/routes/admin-user.test.ts @@ -7,6 +7,7 @@ import { hasUser, findUserById, updateUserById, + deleteUserIdentity, deleteUserById, clearUserCustomDataById, } from '@/queries/user'; @@ -38,18 +39,15 @@ jest.mock('@/queries/user', () => ({ ...data, }) ), - deleteUserById: jest.fn(async (_): Promise => { - /* Note: Do nothing */ - }), + deleteUserById: jest.fn(), insertUser: jest.fn( async (user: CreateUser): Promise => ({ ...mockUser, ...user, }) ), - clearUserCustomDataById: jest.fn(async (_): Promise => { - /* Note: Do nothing */ - }), + clearUserCustomDataById: jest.fn(), + deleteUserIdentity: jest.fn(), })); jest.mock('@/lib/user', () => ({ @@ -372,4 +370,47 @@ describe('adminUserRoutes', () => { expect(findUserById).toHaveBeenCalledTimes(1); expect(clearUserCustomDataById).not.toHaveBeenCalled(); }); + + it('DELETE /users/:userId/identities/:connectorId should throw if user not found', async () => { + const notExistedUserId = 'notExisitedUserId'; + const arbitraryConnectorId = 'arbitraryConnectorId'; + const mockedFindUserById = findUserById as jest.Mock; + mockedFindUserById.mockImplementationOnce((userId) => { + if (userId === notExistedUserId) { + throw new Error(' '); + } + }); + await expect( + userRequest.delete(`/users/${notExistedUserId}/identities/${arbitraryConnectorId}`) + ).resolves.toHaveProperty('status', 500); + expect(deleteUserIdentity).not.toHaveBeenCalled(); + }); + + it('DELETE /users/:userId/identities/:connectorId should throw if user found and connector is not found', async () => { + const arbitraryUserId = 'arbitraryUserId'; + const notExistedConnectorId = 'notExistedConnectorId'; + const mockedFindUserById = findUserById as jest.Mock; + mockedFindUserById.mockImplementationOnce((userId) => { + if (userId === arbitraryUserId) { + return { identities: { connector1: {}, connector2: {} } }; + } + }); + await expect( + userRequest.delete(`/users/${arbitraryUserId}/identities/${notExistedConnectorId}`) + ).resolves.toHaveProperty('status', 404); + expect(deleteUserIdentity).not.toHaveBeenCalled(); + }); + + it('DELETE /users/:userId/identities/:connectorId', async () => { + const arbitraryUserId = 'arbitraryUserId'; + const arbitraryConnectorId = 'arbitraryConnectorId'; + const mockedFindUserById = findUserById as jest.Mock; + mockedFindUserById.mockImplementationOnce((userId) => { + if (userId === arbitraryUserId) { + return { identities: { connector1: {}, connector2: {}, arbitraryConnectorId: {} } }; + } + }); + await userRequest.delete(`/users/${arbitraryUserId}/identities/${arbitraryConnectorId}`); + expect(deleteUserIdentity).toHaveBeenCalledWith(arbitraryUserId, arbitraryConnectorId); + }); }); diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index 6f757bd8d..a9a0adbe1 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -1,4 +1,5 @@ import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas'; +import { has } from '@silverhand/essentials'; import pick from 'lodash.pick'; import { InvalidInputError } from 'slonik'; import { object, string } from 'zod'; @@ -11,6 +12,7 @@ import { findRolesByRoleNames } from '@/queries/roles'; import { clearUserCustomDataById, deleteUserById, + deleteUserIdentity, findUsers, countUsers, findUserById, @@ -264,4 +266,25 @@ export default function adminUserRoutes(router: T) { return next(); } ); + + router.delete( + '/users/:userId/identities/:connectorId', + koaGuard({ params: object({ userId: string(), connectorId: string() }) }), + async (ctx, next) => { + const { + params: { userId, connectorId }, + } = ctx.guard; + + const { identities } = await findUserById(userId); + + if (!has(identities, connectorId)) { + throw new RequestError({ code: 'user.identity_not_exists', status: 404 }); + } + + const updatedUser = await deleteUserIdentity(userId, connectorId); + ctx.body = pick(updatedUser, ...userInfoSelectFields); + + return next(); + } + ); } diff --git a/packages/core/src/utils/mock.ts b/packages/core/src/utils/mock.ts index e87ca89be..91e861274 100644 --- a/packages/core/src/utils/mock.ts +++ b/packages/core/src/utils/mock.ts @@ -33,7 +33,9 @@ export const mockUser: User = { passwordEncryptionSalt: null, name: null, avatar: null, - identities: {}, + identities: { + connector1: { userId: 'connector1', details: {} }, + }, customData: {}, };