0
Fork 0
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:
Darcy Ye 2024-02-19 22:27:32 +08:00 committed by GitHub
parent 52f4e578a5
commit b6233a1a58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 167 additions and 1 deletions

View file

@ -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."
}
}
}
}
}
}
}
}
}

View file

@ -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

View file

@ -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({

View file

@ -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 } });

View file

@ -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);
});

View file

@ -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
});