mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(core,test): add PUT /users/:userId/identities/:target API (#5410)
This commit is contained in:
parent
52f4e578a5
commit
b6233a1a58
6 changed files with 167 additions and 1 deletions
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<T extends AuthedRouter>(
|
|||
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({
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
Identities,
|
||||
Identity,
|
||||
MfaFactor,
|
||||
MfaVerification,
|
||||
Role,
|
||||
|
@ -93,6 +94,9 @@ export const postUserIdentity = async (
|
|||
})
|
||||
.json<Identities>();
|
||||
|
||||
export const putUserIdentity = async (userId: string, target: string, identity: Identity) =>
|
||||
authedAdminApi.put(`users/${userId}/identities/${target}`, { json: identity }).json<Identities>();
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string) =>
|
||||
authedAdminApi.post(`users/${userId}/password/verify`, { json: { password } });
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue