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:
parent
3f8cc6af69
commit
82d104a0d3
5 changed files with 129 additions and 7 deletions
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 *
|
||||||
|
`);
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue