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

feat(core): add management APIs to help with linking social identities to user (#3821)

* feat(core): add management APIs to help with linking social identities to user

* chore: add changeset

* test: add integration tests

* chore: improve wording

Co-authored-by: Darcy Ye <darcyye@silverhand.io>

* chore: improve wording

Co-authored-by: Darcy Ye <darcyye@silverhand.io>

* refactor: return user identities after update

* chore: rename test filename

* chore: add code annotation

---------

Co-authored-by: Darcy Ye <darcyye@silverhand.io>
This commit is contained in:
Charles Zhao 2023-05-15 19:25:50 +08:00 committed by GitHub
parent 30cd7727de
commit 0023dfe38a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 593 additions and 82 deletions

View file

@ -0,0 +1,8 @@
---
"@logto/core": minor
---
Provide management APIs to help link social identities to user
- POST `/users/:userId/identities` to link a social identity to a user
- POST `/connectors/:connectorId/authorization-uri` to get the authorization URI for a connector

View file

@ -0,0 +1,208 @@
import { ConnectorType, type CreateUser, type User } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import {
mockConnector0,
mockLogtoConnector,
mockLogtoConnectorList,
mockMetadata0,
} from '#src/__mocks__/index.js';
import { mockUser } from '#src/__mocks__/user.js';
import RequestError from '#src/errors/RequestError/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
import assertThat from '#src/utils/assert-that.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const notExistedUserId = 'notExistedUserId';
const mockHasUserWithIdentity = jest.fn(async () => false);
const mockedQueries = {
users: {
findUserById: jest.fn(async (id: string) => {
if (id === notExistedUserId) {
throw new RequestError({ code: 'entity.not_exists', status: 404 });
}
return mockUser;
}),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
hasUserWithIdentity: mockHasUserWithIdentity,
deleteUserById: jest.fn(),
deleteUserIdentity: jest.fn(),
},
} satisfies Partial2<Queries>;
const usersLibraries = {
generateUserId: jest.fn(async () => 'fooId'),
insertUser: jest.fn(
async (user: CreateUser): Promise<User> => ({
...mockUser,
...user,
})
),
} satisfies Partial<Libraries['users']>;
const mockGetLogtoConnectors = jest.fn(async () => mockLogtoConnectorList);
const mockedConnectors = {
getLogtoConnectors: mockGetLogtoConnectors,
getLogtoConnectorById: async (connectorId: string) => {
const connectors = await mockGetLogtoConnectors();
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
assertThat(
connector,
new RequestError({
code: 'entity.not_found',
connectorId,
status: 404,
})
);
return connector;
},
};
const { findUserById, updateUserById, deleteUserIdentity } = mockedQueries.users;
const adminUserSocialRoutes = await pickDefault(import('./admin-user-social.js'));
describe('Admin user social identities APIs', () => {
const tenantContext = new MockTenant(undefined, mockedQueries, mockedConnectors, {
users: usersLibraries,
});
const userRequest = createRequester({ authedRoutes: adminUserSocialRoutes, tenantContext });
describe('POST /users/:userId/identities', () => {
it('should throw if user cannot be found', async () => {
// Mock connector with id 'id0' is declared in mockLogtoConnectorList
await expect(
userRequest
.post(`/users/${notExistedUserId}/identities`)
.send({ connectorId: 'id0', connectorData: { code: 'random_code' } })
).resolves.toHaveProperty('status', 404);
expect(updateUserById).not.toHaveBeenCalled();
});
it('should throw if user is found but connector cannot be found', async () => {
const nonExistConnectorId = 'non_exist_connector_id';
await expect(
userRequest
.post(`/users/foo/identities`)
.send({ connectorId: nonExistConnectorId, connectorData: { code: 'random_code' } })
).resolves.toHaveProperty('status', 404);
expect(updateUserById).not.toHaveBeenCalled();
});
it('should throw if connector type is not social', async () => {
// Mock connector with id 'id1' is declared in mockLogtoConnectorList, whose type is sms (not social)
await expect(
userRequest
.post(`/users/foo/identities`)
.send({ connectorId: 'id1', connectorData: { code: 'random_code' } })
).resolves.toHaveProperty('status', 422);
expect(updateUserById).not.toHaveBeenCalled();
});
it('should throw if user already has the social identity', async () => {
mockHasUserWithIdentity.mockResolvedValueOnce(true);
mockGetLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: mockConnector0,
metadata: { ...mockMetadata0 },
type: ConnectorType.Social,
...mockLogtoConnector,
getAuthorizationUri: async () => 'http://example.com',
getUserInfo: async () => ({ id: 'foo' }),
},
]);
const mockedFindUserById = findUserById as jest.Mock;
mockedFindUserById.mockImplementationOnce(() => ({
...mockUser,
identities: { connector_0: {} }, // This value 'connector_0' is declared in mockLogtoConnectorList
}));
await expect(
userRequest
.post(`/users/foo/identities`)
.send({ connectorId: 'id0', connectorData: { code: 'random_code' } })
).resolves.toHaveProperty('status', 422);
expect(updateUserById).not.toHaveBeenCalled();
});
it('should update user with new social identity', async () => {
const mockedSocialUserInfo = { id: 'socialId' };
mockGetLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: mockConnector0,
metadata: { ...mockMetadata0 },
type: ConnectorType.Social,
...mockLogtoConnector,
getAuthorizationUri: async () => 'http://example.com',
getUserInfo: async () => mockedSocialUserInfo,
},
]);
const mockedFindUserById = findUserById as jest.Mock;
mockedFindUserById.mockImplementationOnce(() => ({
...mockUser,
identities: { connectorTarget1: { userId: 'socialIdForTarget1' } },
}));
await expect(
userRequest
.post(`/users/foo/identities`)
.send({ connectorId: 'id0', connectorData: { code: 'random_code' } })
).resolves.toHaveProperty('status', 200);
expect(updateUserById).toHaveBeenCalledWith('foo', {
identities: {
connectorTarget1: { userId: 'socialIdForTarget1' },
connector_0: { userId: 'socialId', details: mockedSocialUserInfo },
},
});
});
});
describe('DELETE /users/:userId/identities/:target', () => {
it('should throw if user cannot be found', async () => {
const arbitraryTarget = 'arbitraryTarget';
await expect(
userRequest.delete(`/users/foo/identities/${arbitraryTarget}`)
).resolves.toHaveProperty('status', 404);
expect(deleteUserIdentity).not.toHaveBeenCalled();
});
it('should throw if user is found but connector cannot be found', async () => {
const arbitraryUserId = 'arbitraryUserId';
const nonExistedTarget = 'nonExistedTarget';
const mockedFindUserById = findUserById as jest.Mock;
mockedFindUserById.mockImplementationOnce((userId) => {
if (userId === arbitraryUserId) {
return { identities: { connector1: {}, connector2: {} } };
}
});
await expect(
userRequest.delete(`/users/${arbitraryUserId}/identities/${nonExistedTarget}`)
).resolves.toHaveProperty('status', 404);
expect(deleteUserIdentity).not.toHaveBeenCalled();
});
it('should delete identity from user', async () => {
const arbitraryUserId = 'arbitraryUserId';
const arbitraryTarget = 'arbitraryTarget';
const mockedFindUserById = findUserById as jest.Mock;
mockedFindUserById.mockImplementationOnce((userId) => {
if (userId === arbitraryUserId) {
return {
identities: { connectorTarget1: {}, connectorTarget2: {}, arbitraryTarget: {} },
};
}
});
await userRequest.delete(`/users/${arbitraryUserId}/identities/${arbitraryTarget}`);
expect(deleteUserIdentity).toHaveBeenCalledWith(arbitraryUserId, arbitraryTarget);
});
});
});

View file

@ -0,0 +1,128 @@
import { notImplemented } from '@logto/cli/lib/connector/consts.js';
import {
ConnectorType,
identitiesGuard,
userInfoSelectFields,
userProfileResponseGuard,
} from '@logto/schemas';
import { has, pick } from '@silverhand/essentials';
import { object, record, string, unknown } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function adminUserSocialRoutes<T extends AuthedRouter>(
...[router, tenant]: RouterInitArgs<T>
) {
const {
queries: {
users: { findUserById, updateUserById, hasUserWithIdentity, deleteUserIdentity },
},
connectors: { getLogtoConnectorById },
} = tenant;
/**
* Link authenticated user identity from a social platform to a Logto user. The usage of this API is usually
* coupled with `POST /connectors/:connectorId/authorization-uri`. With the help of these pair of APIs, you
* can implement a user profile page with the `Link Social` feature in your application.
*
* Note: Currently due to technical limitations, this API does not support the following connectors that
* rely on Logto interaction session: `@logto/connector-apple`, `@logto/connector-saml`, `@logto/connector-oidc`
* and `@logto/connector-oauth`.
*
* @param {string} userId - The id of the Logto user
* @param {string} connectorId - The id of the connector
* @param {object} connectorData - A json object constructed from the url query params returned by the social
* platform. Typically it contains `code`, `state` and `redirectUri` fields.
*/
router.post(
'/users/:userId/identities',
koaGuard({
params: object({ userId: string() }),
body: object({
connectorId: string(),
connectorData: record(string(), unknown()),
}),
response: identitiesGuard,
status: [200, 404, 422],
}),
async (ctx, next) => {
const {
params: { userId },
body: { connectorId, connectorData },
} = ctx.guard;
const [connector, user] = await Promise.all([
getLogtoConnectorById(connectorId),
findUserById(userId),
]);
assertThat(
connector.type === ConnectorType.Social,
new RequestError({
code: 'session.invalid_connector_id',
status: 422,
connectorId,
})
);
const { target } = connector.metadata;
/**
* Same as above, passing `notImplemented` only works for connectors not relying on session storage.
* E.g. Google and GitHub
*/
const socialUserInfo = await connector.getUserInfo(connectorData, notImplemented);
assertThat(
!(await hasUserWithIdentity(target, socialUserInfo.id, userId)),
new RequestError({
code: 'user.identity_already_in_use',
status: 422,
})
);
const updatedUser = await updateUserById(userId, {
identities: {
...user.identities,
[target]: {
userId: socialUserInfo.id,
details: socialUserInfo,
},
},
});
ctx.body = updatedUser.identities;
return next();
}
);
router.delete(
'/users/:userId/identities/:target',
koaGuard({
params: object({ userId: string(), target: string() }),
response: userProfileResponseGuard,
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { userId, target },
} = ctx.guard;
const { identities } = await findUserById(userId);
if (!has(identities, target)) {
throw new RequestError({ code: 'user.identity_not_exist', status: 404 });
}
const updatedUser = await deleteUserIdentity(userId, target);
ctx.body = pick(updatedUser, ...userInfoSelectFields);
return next();
}
);
}

View file

@ -11,13 +11,6 @@ import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
const filterUsersWithSearch = (users: User[], search: string) =>
users.filter((user) =>
[user.username, user.primaryEmail, user.primaryPhone, user.name].some((value) =>
value ? !value.includes(search) : false
)
);
const mockedQueries = {
oidcModelInstances: { revokeInstanceByUserId: jest.fn() },
signInExperiences: {
@ -391,47 +384,4 @@ describe('adminUserRoutes', () => {
500
);
});
it('DELETE /users/:userId/identities/:target should throw if user cannot be found', async () => {
const notExistedUserId = 'notExistedUserId';
const arbitraryTarget = 'arbitraryTarget';
const mockedFindUserById = findUserById as jest.Mock;
mockedFindUserById.mockImplementationOnce((userId) => {
if (userId === notExistedUserId) {
throw new Error(' ');
}
});
await expect(
userRequest.delete(`/users/${notExistedUserId}/identities/${arbitraryTarget}`)
).resolves.toHaveProperty('status', 500);
expect(deleteUserIdentity).not.toHaveBeenCalled();
});
it('DELETE /users/:userId/identities/:target should throw if user is found but connector cannot be found', async () => {
const arbitraryUserId = 'arbitraryUserId';
const nonExistedTarget = 'nonExistedTarget';
const mockedFindUserById = findUserById as jest.Mock;
mockedFindUserById.mockImplementationOnce((userId) => {
if (userId === arbitraryUserId) {
return { identities: { connector1: {}, connector2: {} } };
}
});
await expect(
userRequest.delete(`/users/${arbitraryUserId}/identities/${nonExistedTarget}`)
).resolves.toHaveProperty('status', 404);
expect(deleteUserIdentity).not.toHaveBeenCalled();
});
it('DELETE /users/:userId/identities/:target', async () => {
const arbitraryUserId = 'arbitraryUserId';
const arbitraryTarget = 'arbitraryTarget';
const mockedFindUserById = findUserById as jest.Mock;
mockedFindUserById.mockImplementationOnce((userId) => {
if (userId === arbitraryUserId) {
return { identities: { connectorTarget1: {}, connectorTarget2: {}, arbitraryTarget: {} } };
}
});
await userRequest.delete(`/users/${arbitraryUserId}/identities/${arbitraryTarget}`);
expect(deleteUserIdentity).toHaveBeenCalledWith(arbitraryUserId, arbitraryTarget);
});
});

View file

@ -1,6 +1,6 @@
import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
import { jsonObjectGuard, userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas';
import { conditional, has, pick } from '@silverhand/essentials';
import { conditional, pick } from '@silverhand/essentials';
import { boolean, literal, object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
@ -17,7 +17,6 @@ export default function adminUserRoutes<T extends AuthedRouter>(
oidcModelInstances: { revokeInstanceByUserId },
users: {
deleteUserById,
deleteUserIdentity,
findUserById,
hasUser,
updateUserById,
@ -302,29 +301,4 @@ export default function adminUserRoutes<T extends AuthedRouter>(
return next();
}
);
router.delete(
'/users/:userId/identities/:target',
koaGuard({
params: object({ userId: string(), target: string() }),
response: userProfileResponseGuard,
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { userId, target },
} = ctx.guard;
const { identities } = await findUserById(userId);
if (!has(identities, target)) {
throw new RequestError({ code: 'user.identity_not_exist', status: 404 });
}
const updatedUser = await deleteUserIdentity(userId, target);
ctx.body = pick(updatedUser, ...userInfoSelectFields);
return next();
}
);
}

View file

@ -0,0 +1,100 @@
import { pickDefault } from '@logto/shared/esm';
import { mockLogtoConnectorList } from '#src/__mocks__/connector.js';
import { mockConnector0, mockLogtoConnector, mockMetadata0 } from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import assertThat from '#src/utils/assert-that.js';
import { ConnectorType, type LogtoConnector } from '#src/utils/connectors/types.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
// eslint-disable-next-line @typescript-eslint/ban-types
const getLogtoConnectors = jest.fn<Promise<LogtoConnector[]>, []>();
const tenantContext = new MockTenant(
undefined,
{},
{
getLogtoConnectors,
getLogtoConnectorById: async (connectorId: string) => {
const connectors = await getLogtoConnectors();
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
assertThat(
connector,
new RequestError({
code: 'entity.not_found',
connectorId,
status: 404,
})
);
return connector;
},
},
{}
);
const connectorAuthorizationUriRoutes = await pickDefault(import('./authorization-uri.js'));
describe('POST /connectors/:connectorId/authorization-uri', () => {
const connectorRequest = createRequester({
authedRoutes: connectorAuthorizationUriRoutes,
tenantContext,
});
it('should return 404 if connector not found', async () => {
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/non-exist-connector-id/authorization-uri')
.send({
state: 'random_state',
redirectUri: 'http://example.com/callback/random_string',
});
expect(response.status).toBe(404);
});
it('should return 400 if state is missing in payload', async () => {
const response = await connectorRequest
.post('/connectors/id0/authorization-uri')
.send({ redirectUri: 'http://example.com/callback/random_string' });
expect(response.status).toBe(400);
});
it('should return 400 if redirectUri is missing in payload', async () => {
const response = await connectorRequest
.post('/connectors/id0/authorization-uri')
.send({ state: 'random_state' });
expect(response.status).toBe(400);
});
it('should return 400 if connector type is not social', async () => {
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.post('/connectors/id1/authorization-uri').send({
state: 'random_state',
redirectUri: 'http://example.com/callback/random_string',
});
expect(response.status).toBe(400);
});
it('should return connector authorization URI successfully', async () => {
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: mockConnector0,
metadata: { ...mockMetadata0 },
type: ConnectorType.Social,
...mockLogtoConnector,
getAuthorizationUri: async () => 'http://example.com',
},
]);
const response = await connectorRequest.post('/connectors/id0/authorization-uri').send({
state: 'random_state',
redirectUri: 'http://example.com/callback/random_string',
});
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('redirectTo');
expect(response.body.redirectTo).toBe('http://example.com');
});
});

View file

@ -0,0 +1,74 @@
import { notImplemented } from '@logto/cli/lib/connector/consts.js';
import { ConnectorType } from '@logto/connector-kit';
import { object, string } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
export default function connectorAuthorizationUriRoutes<T extends AuthedRouter>(
...[router, tenant]: RouterInitArgs<T>
) {
const { getLogtoConnectorById } = tenant.connectors;
/**
* Returns the authorization uri of the social platform that bundled with the given social connector.
* With the returned authorization uri, you can redirect users to the social platform to authenticate.
* The usage of this API is usually coupled with `POST /users/:userId/identities` to link authenticated
* social identity to a Logto user.
*
* Note: Currently due to technical limitations, this API does not support the following connectors that
* rely on Logto interaction session: `@logto/apple`, `@logto/connector-saml`, `@logto/connector-oidc`
* and `@logto/connector-oauth`.
*
* @param {string} connectorId - The id of the connector
* @param {string} state - A random string generated on the client side to prevent CSRF attack
* @param {string} redirectUri - The uri to navigate back to after authenticated by the social platform
*/
router.post(
'/connectors/:connectorId/authorization-uri',
koaGuard({
params: object({ connectorId: string().min(1) }),
body: object({ state: string(), redirectUri: string() }),
response: object({ redirectTo: string().url() }),
status: [200, 400, 404],
}),
async (ctx, next) => {
const { connectorId } = ctx.guard.params;
const { state, redirectUri } = ctx.guard.body;
assertThat(state && redirectUri, 'session.insufficient_info');
const connector = await getLogtoConnectorById(connectorId);
assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type');
const {
headers: { 'user-agent': userAgent },
} = ctx.request;
const redirectTo = await connector.getAuthorizationUri(
{
state,
redirectUri,
connectorId,
connectorFactoryId: connector.metadata.id,
/**
* Passing empty jti only works for connectors not relying on session storage.
* E.g. Google and GitHub
*/
jti: '',
headers: { userAgent },
},
/**
* Same as above, passing `notImplemented` only works for connectors not relying on session storage.
* E.g. Google and GitHub
*/
notImplemented
);
ctx.body = { redirectTo };
return next();
}
);
}

View file

@ -22,6 +22,7 @@ import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/utils/conn
import type { AuthedRouter, RouterInitArgs } from '../types.js';
import connectorAuthorizationUriRoutes from './authorization-uri.js';
import connectorConfigTestingRoutes from './config-testing.js';
const generateConnectorId = buildIdGenerator(12);
@ -341,4 +342,5 @@ export default function connectorRoutes<T extends AuthedRouter>(
);
connectorConfigTestingRoutes(router, tenant);
connectorAuthorizationUriRoutes(router, tenant);
}

View file

@ -11,6 +11,7 @@ import koaAuth from '../middleware/koa-auth/index.js';
import adminUserRoleRoutes from './admin-user-role.js';
import adminUserSearchRoutes from './admin-user-search.js';
import adminUserSocialRoutes from './admin-user-social.js';
import adminUserRoutes from './admin-user.js';
import applicationRoutes from './application.js';
import authnRoutes from './authn.js';
@ -46,6 +47,7 @@ const createRouters = (tenant: TenantContext) => {
adminUserRoutes(managementRouter, tenant);
adminUserSearchRoutes(managementRouter, tenant);
adminUserRoleRoutes(managementRouter, tenant);
adminUserSocialRoutes(managementRouter, tenant);
logRoutes(managementRouter, tenant);
roleRoutes(managementRouter, tenant);
roleScopeRoutes(managementRouter, tenant);

View file

@ -1,4 +1,4 @@
import type { Role, User } from '@logto/schemas';
import type { Identities, Role, User } from '@logto/schemas';
import { authedAdminApi } from './api.js';
@ -51,3 +51,17 @@ export const getUserRoles = async (userId: string) =>
export const deleteRoleFromUser = async (userId: string, roleId: string) =>
authedAdminApi.delete(`users/${userId}/roles/${roleId}`);
export const postUserIdentity = async (
userId: string,
connectorId: string,
connectorData: Record<string, unknown>
) =>
authedAdminApi
.post(`users/${userId}/identities`, {
json: {
connectorId,
connectorData,
},
})
.json<Identities>();

View file

@ -74,3 +74,15 @@ const sendTestMessage = async (
url: `connectors/${connectorFactoryId}/test`,
json: { [receiverType]: receiver, config },
});
export const getConnectorAuthorizationUri = async (
connectorId: string,
state: string,
redirectUri: string
) =>
authedAdminApi
.post({
url: `connectors/${connectorId}/authorization-uri`,
json: { state, redirectUri },
})
.json<{ redirectTo: string }>();

View file

@ -12,7 +12,9 @@ import {
updateUserPassword,
deleteUserIdentity,
postConnector,
getConnectorAuthorizationUri,
deleteConnectorById,
postUserIdentity,
} from '#src/api/index.js';
import { createResponseWithCode } from '#src/helpers/admin-tenant.js';
import { createUserByAdmin } from '#src/helpers/index.js';
@ -93,13 +95,50 @@ describe('admin console user management', () => {
expect(userEntity).toMatchObject(user);
});
it('should delete user identities successfully', async () => {
const { id } = await postConnector({
it('should link social identity successfully', async () => {
const { id: connectorId } = await postConnector({
connectorId: mockSocialConnectorId,
config: mockSocialConnectorConfig,
});
const createdUserId = await createNewSocialUserWithUsernameAndPassword(id);
const state = 'random_state';
const redirectUri = 'http://mock.social.com/callback/random_string';
const code = 'random_code_from_social';
const socialUserId = 'social_platform_user_id';
const socialUserEmail = 'johndoe@gmail.com';
const { id: userId } = await createUserByAdmin();
const { redirectTo } = await getConnectorAuthorizationUri(connectorId, state, redirectUri);
expect(redirectTo).toBe(`http://mock.social.com/?state=${state}&redirect_uri=${redirectUri}`);
const identities = await postUserIdentity(userId, connectorId, {
code,
state,
redirectUri,
userId: socialUserId,
email: socialUserEmail,
});
expect(identities).toHaveProperty(mockSocialConnectorTarget);
expect(identities[mockSocialConnectorTarget]).toMatchObject({
userId: socialUserId,
details: {
id: socialUserId,
email: socialUserEmail,
},
});
await deleteConnectorById(connectorId);
});
it('should delete user identities successfully', async () => {
const { id: connectorId } = await postConnector({
connectorId: mockSocialConnectorId,
config: mockSocialConnectorConfig,
});
const createdUserId = await createNewSocialUserWithUsernameAndPassword(connectorId);
const userInfo = await getUser(createdUserId);
expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget);
@ -110,6 +149,6 @@ describe('admin console user management', () => {
expect(updatedUser.identities).not.toHaveProperty(mockSocialConnectorTarget);
await deleteConnectorById(id);
await deleteConnectorById(connectorId);
});
});