0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-23 20:33:16 -05:00

feat(core): add DELETE /users/:userId/identities/:connectorId (#437)

* feat(core): add DELETE /users/:userId/identities/:connectorId

* feat(core): add user query methods UT cases for better testing coverage

* feat(core): rewrite deletion of connector info from user identities using postgresql operator
This commit is contained in:
Darcy Ye 2022-03-25 15:48:53 +08:00 committed by GitHub
parent 3f8cc6af69
commit 82d104a0d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 129 additions and 7 deletions

View file

@ -23,6 +23,7 @@ import {
updateUserById, updateUserById,
deleteUserById, deleteUserById,
clearUserCustomDataById, clearUserCustomDataById,
deleteUserIdentity,
} from './user'; } from './user';
const mockQuery: jest.MockedFunction<QueryType> = jest.fn(); const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
@ -375,4 +376,51 @@ describe('user query', () => {
await clearUserCustomDataById(id); 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);
});
}); });

View file

@ -137,3 +137,11 @@ export const clearUserCustomDataById = async (id: string) => {
throw new UpdateError(Users, { set: { customData: {} }, where: { id } }); throw new UpdateError(Users, { set: { customData: {} }, where: { id } });
} }
}; };
export const deleteUserIdentity = async (userId: string, connectorId: string) =>
pool.one<User>(sql`
update ${table}
set ${fields.identities}=${fields.identities}::jsonb-${connectorId}
where ${fields.id}=${userId}
returning *
`);

View file

@ -7,6 +7,7 @@ import {
hasUser, hasUser,
findUserById, findUserById,
updateUserById, updateUserById,
deleteUserIdentity,
deleteUserById, deleteUserById,
clearUserCustomDataById, clearUserCustomDataById,
} from '@/queries/user'; } from '@/queries/user';
@ -38,18 +39,15 @@ jest.mock('@/queries/user', () => ({
...data, ...data,
}) })
), ),
deleteUserById: jest.fn(async (_): Promise<void> => { deleteUserById: jest.fn(),
/* Note: Do nothing */
}),
insertUser: jest.fn( insertUser: jest.fn(
async (user: CreateUser): Promise<User> => ({ async (user: CreateUser): Promise<User> => ({
...mockUser, ...mockUser,
...user, ...user,
}) })
), ),
clearUserCustomDataById: jest.fn(async (_): Promise<void> => { clearUserCustomDataById: jest.fn(),
/* Note: Do nothing */ deleteUserIdentity: jest.fn(),
}),
})); }));
jest.mock('@/lib/user', () => ({ jest.mock('@/lib/user', () => ({
@ -372,4 +370,47 @@ describe('adminUserRoutes', () => {
expect(findUserById).toHaveBeenCalledTimes(1); expect(findUserById).toHaveBeenCalledTimes(1);
expect(clearUserCustomDataById).not.toHaveBeenCalled(); 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);
});
}); });

View file

@ -1,4 +1,5 @@
import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas'; import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas';
import { has } from '@silverhand/essentials';
import pick from 'lodash.pick'; import pick from 'lodash.pick';
import { InvalidInputError } from 'slonik'; import { InvalidInputError } from 'slonik';
import { object, string } from 'zod'; import { object, string } from 'zod';
@ -11,6 +12,7 @@ import { findRolesByRoleNames } from '@/queries/roles';
import { import {
clearUserCustomDataById, clearUserCustomDataById,
deleteUserById, deleteUserById,
deleteUserIdentity,
findUsers, findUsers,
countUsers, countUsers,
findUserById, findUserById,
@ -264,4 +266,25 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
return next(); 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();
}
);
} }

View file

@ -33,7 +33,9 @@ export const mockUser: User = {
passwordEncryptionSalt: null, passwordEncryptionSalt: null,
name: null, name: null,
avatar: null, avatar: null,
identities: {}, identities: {
connector1: { userId: 'connector1', details: {} },
},
customData: {}, customData: {},
}; };