0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

refactor(core, connector): fix code to fit new ESlint rule (#942)

* refactor(core): separate/refactor connector route UTs

* refactor(connector-wechat*): fix wechat connectors error handler to fit new eslint rule

* refactor(connector-wechat*): fix error handler
This commit is contained in:
Darcy Ye 2022-05-27 21:21:23 +08:00 committed by GitHub
parent 545a3929e4
commit ae97e7bd27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 369 additions and 489 deletions

View file

@ -108,6 +108,17 @@ describe('getUserInfo', () => {
});
});
it('throws error if `openid` is missing', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(0, {
unionid: 'this_is_an_arbitrary_wechat_union_id',
headimgurl: 'https://github.com/images/error/octocat_happy.gif',
nickname: 'wechat bot',
});
await expect(
weChatNativeMethods.getUserInfo({ accessToken: 'accessToken' })
).rejects.toMatchError(new Error('`openid` is required by WeChat API.'));
});
it('throws SocialAccessTokenInvalid error if errcode is 40001', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
@ -138,10 +149,10 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
.query(new URLSearchParams({ access_token: 'accessToken' }))
.query(new URLSearchParams({ access_token: 'wrongAccessToken', openid: 'openid' }))
.reply(401);
await expect(
weChatNativeMethods.getUserInfo({ accessToken: 'accessToken' })
weChatNativeMethods.getUserInfo({ accessToken: 'wrongAccessToken', openid: 'openid' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
});
});

View file

@ -80,11 +80,17 @@ export default class WeChatNativeConnector implements SocialConnector {
return { accessToken, openid };
};
// FIXME:
// eslint-disable-next-line complexity
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
const { accessToken, openid } = accessTokenObject;
// 'openid' is defined as a required input argument in WeChat API doc, but it does not necessarily have to
// be the return value from getAccessToken per testing.
// In other words, 'openid' is required but the response of getUserInfo is consistent as long as
// access_token is valid.
// We are expecting to get 41009 'missing openid' response according to the developers doc, but the
// fact is that we still got 40001 'invalid credentials' response.
assert(openid, new Error('`openid` is required by WeChat API.'));
try {
const { unionid, headimgurl, nickname, errcode, errmsg } = await got
.get(userInfoEndpoint, {
@ -93,25 +99,19 @@ export default class WeChatNativeConnector implements SocialConnector {
})
.json<UserInfoResponse>();
if (!openid || errcode || errmsg) {
// 'openid' is defined as a required input argument in WeChat API doc, but it does not necessarily to
// be the return value from getAccessToken per testing.
// In another word, 'openid' is required but the response of getUserInfo is consistent as long as
// access_token is valid.
// We are expecting to get 41009 'missing openid' response according to the developers doc, but the
// fact is that we still got 40001 'invalid credentials' response.
if (errcode === 40_001) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
// Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}.
// These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa.
// 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty.
throw new Error(errmsg);
}
assert(errcode !== 40_001, new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
assert(!errcode, new Error(errmsg));
return { id: unionid ?? openid, avatar: headimgurl, name: nickname };
} catch (error: unknown) {
if (error instanceof GotRequestError && error.response?.statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
assert(
!(error instanceof GotRequestError && error.response?.statusCode === 401),
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
throw error;
}

View file

@ -108,6 +108,17 @@ describe('getUserInfo', () => {
});
});
it('throws error if `openid` is missing', async () => {
nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(0, {
unionid: 'this_is_an_arbitrary_wechat_union_id',
headimgurl: 'https://github.com/images/error/octocat_happy.gif',
nickname: 'wechat bot',
});
await expect(weChatMethods.getUserInfo({ accessToken: 'accessToken' })).rejects.toMatchError(
new Error('`openid` is required by WeChat API.')
);
});
it('throws SocialAccessTokenInvalid error if errcode is 40001', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
@ -138,10 +149,10 @@ describe('getUserInfo', () => {
it('throws SocialAccessTokenInvalid error if response code is 401', async () => {
nock(userInfoEndpointUrl.origin)
.get(userInfoEndpointUrl.pathname)
.query(new URLSearchParams({ access_token: 'accessToken' }))
.query(new URLSearchParams({ access_token: 'wrongAccessToken', openid: 'openid' }))
.reply(401);
await expect(weChatMethods.getUserInfo({ accessToken: 'accessToken' })).rejects.toMatchError(
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
await expect(
weChatMethods.getUserInfo({ accessToken: 'wrongAccessToken', openid: 'openid' })
).rejects.toMatchError(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
});
});

View file

@ -79,11 +79,17 @@ export default class WeChatConnector implements SocialConnector {
return { accessToken, openid };
};
// FIXME:
// eslint-disable-next-line complexity
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
const { accessToken, openid } = accessTokenObject;
// 'openid' is defined as a required input argument in WeChat API doc, but it does not necessarily have to
// be the return value from getAccessToken per testing.
// In other words, 'openid' is required but the response of getUserInfo is consistent as long as
// access_token is valid.
// We are expecting to get 41009 'missing openid' response according to the developers doc, but the
// fact is that we still got 40001 'invalid credentials' response.
assert(openid, new Error('`openid` is required by WeChat API.'));
try {
const { unionid, headimgurl, nickname, errcode, errmsg } = await got
.get(userInfoEndpoint, {
@ -92,25 +98,19 @@ export default class WeChatConnector implements SocialConnector {
})
.json<UserInfoResponse>();
if (!openid || errcode || errmsg) {
// 'openid' is defined as a required input argument in WeChat API doc, but it does not necessarily to
// be the return value from getAccessToken per testing.
// In another word, 'openid' is required but the response of getUserInfo is consistent as long as
// access_token is valid.
// We are expecting to get 41009 'missing openid' response according to the developers doc, but the
// fact is that we still got 40001 'invalid credentials' response.
if (errcode === 40_001) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
// Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}.
// These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa.
// 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty.
throw new Error(errmsg);
}
assert(errcode !== 40_001, new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
assert(!errcode, new Error(errmsg));
return { id: unionid ?? openid, avatar: headimgurl, name: nickname };
} catch (error: unknown) {
if (error instanceof GotRequestError && error.response?.statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
assert(
!(error instanceof GotRequestError && error.response?.statusCode === 401),
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
);
throw error;
}

View file

@ -1,47 +1,18 @@
/* eslint-disable max-lines, max-nested-callbacks */
import {
ConnectorError,
ConnectorErrorCodes,
EmailMessageTypes,
SmsMessageTypes,
ValidateConfig,
} from '@logto/connector-types';
import { EmailMessageTypes, ValidateConfig } from '@logto/connector-types';
import { Connector, ConnectorType } from '@logto/schemas';
import { mockConnectorInstanceList, mockConnectorList } from '@/__mocks__';
import { mockConnectorInstanceList, mockMetadata, mockConnector } from '@/__mocks__';
import {
ConnectorMetadata,
EmailConnectorInstance,
SmsConnectorInstance,
} from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { updateConnector } from '@/queries/connector';
import assertThat from '@/utils/assert-that';
import { createRequester } from '@/utils/test-utils';
import connectorRoutes from './connector';
const mockMetadata: ConnectorMetadata = {
id: 'id0',
target: 'connector_0',
type: ConnectorType.Social,
platform: null,
name: {
en: 'Connector',
'zh-CN': '连接器',
},
logo: './logo.png',
description: { en: 'Connector', 'zh-CN': '连接器' },
readme: 'README.md',
configTemplate: 'config-template.md',
};
const mockConnector: Connector = {
id: 'id0',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
};
type ConnectorInstance = {
connector: Connector;
metadata: ConnectorMetadata;
@ -49,21 +20,26 @@ type ConnectorInstance = {
sendMessage?: unknown;
};
const getConnectorInstanceByIdPlaceHolder = jest.fn() as jest.MockedFunction<
(connectorId: string) => Promise<ConnectorInstance>
>;
const getConnectorInstancesPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<ConnectorInstance[]>
>;
jest.mock('@/queries/connector', () => ({
findAllConnectors: jest.fn(),
updateConnector: jest.fn(),
}));
jest.mock('@/connectors', () => ({
getConnectorInstanceById: async (connectorId: string) =>
getConnectorInstanceByIdPlaceHolder(connectorId),
getConnectorInstances: async () => getConnectorInstancesPlaceHolder(),
getConnectorInstanceById: async (connectorId: string) => {
const connectorInstances = await getConnectorInstancesPlaceHolder();
const connector = connectorInstances.find(({ connector }) => connector.id === connectorId);
assertThat(
connector,
new RequestError({
code: 'entity.not_found',
connectorId,
status: 404,
})
);
return connector;
},
}));
describe('connector route', () => {
@ -107,353 +83,20 @@ describe('connector route', () => {
});
it('throws when connector can not be found by given connectorId (locally)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
const found = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.connector.id === 'connector'
);
assertThat(found, new RequestError({ code: 'entity.not_found', status: 404 }));
return found;
});
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList.slice(2));
const response = await connectorRequest.get('/connectors/findConnector').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('throws when connector can not be found by given connectorId (remotely)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (id: string) => {
const foundConnectorInstance = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.connector.id === id
);
assertThat(
foundConnectorInstance,
new RequestError({ code: 'entity.not_found', status: 404 })
);
const foundConnector = mockConnectorList.find((connector) => connector.id === 'connector0');
assertThat(foundConnector, 'entity.not_found');
return { foundConnector, ...foundConnectorInstance };
});
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 400);
expect(response).toHaveProperty('statusCode', 404);
});
it('shows found connector information', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (id: string) => {
const foundConnectorInstance = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.connector.id === id
);
assertThat(
foundConnectorInstance,
new RequestError({ code: 'entity.not_found', status: 404 })
);
const foundConnector = mockConnectorList.find((connector) => connector.id === id);
assertThat(foundConnector, 'entity.not_found');
return { foundConnector, ...foundConnectorInstance };
});
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('PATCH /connectors/:id/enabled', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws if connector can not be found (locally)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
const found = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.connector.id === 'connector'
);
assertThat(found, new RequestError({ code: 'entity.not_found', status: 404 }));
return found;
});
const response = await connectorRequest
.patch('/connectors/findConnector/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 404);
});
it('throws if connector can not be found (remotely)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (id: string) => {
const foundConnectorInstance = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.connector.id === id
);
assertThat(
foundConnectorInstance,
new RequestError({ code: 'entity.not_found', status: 404 })
);
const foundConnector = mockConnectorList.find((connector) => connector.id === 'connector0');
assertThat(foundConnector, 'entity.not_found');
return { foundConnector, ...foundConnectorInstance };
});
const response = await connectorRequest
.patch('/connectors/id0/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 400);
});
it('enables one of the social connectors (with valid config)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: mockConnector,
metadata: mockMetadata,
validateConfig: jest.fn(),
};
});
const response = await connectorRequest
.patch('/connectors/connector_0/enabled')
.send({ enabled: true });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'connector_0' },
set: { enabled: true },
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the social connectors (with invalid config)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: mockConnector,
metadata: mockMetadata,
validateConfig: async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
};
});
const response = await connectorRequest
.patch('/connectors/connector_0/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 500);
});
it('disables one of the social connectors', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: mockConnector,
metadata: mockMetadata,
validateConfig: async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
};
});
const response = await connectorRequest
.patch('/connectors/connector_0/enabled')
.send({ enabled: false });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'connector_0' },
set: { enabled: false },
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the email/sms connectors (with valid config)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList);
const mockedMetadata = {
...mockMetadata,
id: 'id1',
type: ConnectorType.SMS,
};
const mockedConnector = {
...mockConnector,
id: 'id1',
name: 'connector_1',
platform: null,
type: ConnectorType.SMS,
metadata: mockedMetadata,
};
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: mockedConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
};
});
const response = await connectorRequest
.patch('/connectors/id1/enabled')
.send({ enabled: true });
expect(updateConnector).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { id: 'id1' },
set: { enabled: false },
})
);
expect(updateConnector).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: { id: 'id5' },
set: { enabled: false },
})
);
expect(updateConnector).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
where: { id: 'id1' },
set: { enabled: true },
})
);
expect(response.body).toMatchObject({
metadata: mockedMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the email/sms connectors (with invalid config)', async () => {
const mockedMetadata = {
...mockMetadata,
id: 'connector_1',
type: ConnectorType.SMS,
};
const mockedConnector = {
...mockConnector,
id: 'connector_1',
name: 'connector_1',
platform: null,
type: ConnectorType.SMS,
metadata: mockedMetadata,
};
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: mockedConnector,
metadata: mockedMetadata,
validateConfig: async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
};
});
const response = await connectorRequest
.patch('/connectors/connector_1/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 500);
});
it('disables one of the email/sms connectors', async () => {
const mockedMetadata = {
...mockMetadata,
id: 'connector_4',
type: ConnectorType.Email,
platform: null,
};
const mockedConnector = {
...mockConnector,
id: 'connector_4',
name: 'connector_4',
platform: null,
type: ConnectorType.Email,
metadata: mockedMetadata,
};
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: mockedConnector,
metadata: mockedMetadata,
};
});
const response = await connectorRequest
.patch('/connectors/connector_4/enabled')
.send({ enabled: false });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'connector_4' },
set: { enabled: false },
})
);
expect(response.body).toMatchObject({
metadata: mockedMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('PATCH /connectors/:id', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws when connector can not be found by given connectorId (locally)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
const found = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.connector.id === 'connector'
);
assertThat(found, new RequestError({ code: 'entity.not_found', status: 404 }));
return found;
});
const response = await connectorRequest.patch('/connectors/findConnector').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('throws when connector can not be found by given connectorId (remotely)', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (id: string) => {
const foundConnectorInstance = mockConnectorInstanceList.find(
(connectorInstance) => connectorInstance.connector.id === id
);
assertThat(
foundConnectorInstance,
new RequestError({ code: 'entity.not_found', status: 404 })
);
const foundConnector = mockConnectorList.find((connector) => connector.id === 'id_0');
assertThat(foundConnector, 'entity.not_found');
return { foundConnector, ...foundConnectorInstance };
});
const response = await connectorRequest.patch('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('config validation fails', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: mockConnector,
metadata: mockMetadata,
validateConfig: async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
};
});
const response = await connectorRequest
.patch('/connectors/connector_0')
.send({ config: { cliend_id: 'client_id', client_secret: 'client_secret' } });
expect(response).toHaveProperty('statusCode', 500);
});
it('successfully updates connector configs', async () => {
getConnectorInstanceByIdPlaceHolder.mockImplementationOnce(async (_id: string) => {
return {
connector: mockConnector,
metadata: mockMetadata,
validateConfig: jest.fn(),
};
});
const response = await connectorRequest
.patch('/connectors/connector_0')
.send({ config: { cliend_id: 'client_id', client_secret: 'client_secret' } });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'connector_0' },
set: { config: { cliend_id: 'client_id', client_secret: 'client_secret' } },
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 200);
});
});
@ -464,18 +107,9 @@ describe('connector route', () => {
});
it('should get email connector and send message', async () => {
const mockedMetadata = {
...mockMetadata,
type: ConnectorType.Email,
};
const mockedConnector = {
...mockConnector,
type: ConnectorType.Email,
metadata: mockedMetadata,
};
const mockedEmailConnector: EmailConnectorInstance = {
connector: mockedConnector,
metadata: mockedMetadata,
connector: mockConnector,
metadata: mockMetadata,
validateConfig: jest.fn(),
getConfig: jest.fn(),
sendMessage: async (
@ -485,9 +119,7 @@ describe('connector route', () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
): Promise<any> => {},
};
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([mockedEmailConnector]);
const sendMessageSpy = jest.spyOn(mockedEmailConnector, 'sendMessage');
const response = await connectorRequest
.post('/connectors/test/email')
@ -500,29 +132,7 @@ describe('connector route', () => {
});
it('should throw when email connector is not found', async () => {
const mockedMetadata = {
...mockMetadata,
type: ConnectorType.SMS,
};
const mockedConnector = {
...mockConnector,
type: ConnectorType.SMS,
};
const mockedSmsConnector: SmsConnectorInstance = {
connector: mockedConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
getConfig: jest.fn(),
sendMessage: async (
address: string,
type: keyof SmsMessageTypes,
_payload: SmsMessageTypes[typeof type]
// eslint-disable-next-line @typescript-eslint/no-empty-function
): Promise<any> => {},
};
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([mockedSmsConnector]);
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/test/email')
.send({ email: 'test@email.com' });
@ -540,13 +150,8 @@ describe('connector route', () => {
...mockMetadata,
type: ConnectorType.SMS,
};
const mockedConnector = {
...mockConnector,
type: ConnectorType.SMS,
metadata: mockedMetadata,
};
const mockedSmsConnectorInstance: SmsConnectorInstance = {
connector: mockedConnector,
connector: mockConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
getConfig: jest.fn(),
@ -557,9 +162,7 @@ describe('connector route', () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
): Promise<any> => {},
};
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([mockedSmsConnectorInstance]);
const sendMessageSpy = jest.spyOn(mockedSmsConnectorInstance, 'sendMessage');
const response = await connectorRequest
.post('/connectors/test/sms')
@ -572,30 +175,7 @@ describe('connector route', () => {
});
it('should throw when sms connector is not found', async () => {
const mockedMetadata = {
...mockMetadata,
type: ConnectorType.Email,
};
const mockedConnector = {
...mockConnector,
type: ConnectorType.Email,
metadata: mockedMetadata,
};
const mockedEmailConnectorInstance: EmailConnectorInstance = {
connector: mockedConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
getConfig: jest.fn(),
sendMessage: async (
address: string,
type: keyof EmailMessageTypes,
_payload: EmailMessageTypes[typeof type]
// eslint-disable-next-line @typescript-eslint/no-empty-function
): Promise<any> => {},
};
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([mockedEmailConnectorInstance]);
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/test/sms')
.send({ phone: '12345678901' });
@ -603,4 +183,3 @@ describe('connector route', () => {
});
});
});
/* eslint-enable max-lines, max-nested-callbacks */

View file

@ -124,9 +124,7 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
'/connectors/:id',
koaGuard({
params: object({ id: string().min(1) }),
body: Connectors.createGuard
.omit({ id: true, type: true, enabled: true, createdAt: true })
.partial(),
body: Connectors.createGuard.omit({ id: true, enabled: true, createdAt: true }).partial(),
}),
async (ctx, next) => {
const {

View file

@ -0,0 +1,281 @@
import { ConnectorError, ConnectorErrorCodes, ValidateConfig } from '@logto/connector-types';
import { Connector, ConnectorType } from '@logto/schemas';
import { mockConnectorInstanceList, mockMetadata, mockConnector } from '@/__mocks__';
import { ConnectorMetadata } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { updateConnector } from '@/queries/connector';
import assertThat from '@/utils/assert-that';
import { createRequester } from '@/utils/test-utils';
import connectorRoutes from './connector';
type ConnectorInstance = {
connector: Connector;
metadata: ConnectorMetadata;
validateConfig?: ValidateConfig;
sendMessage?: unknown;
};
const getConnectorInstancesPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<ConnectorInstance[]>
>;
const getConnectorInstanceByIdPlaceHolder = jest.fn(async (connectorId: string) => {
const connectorInstances = await getConnectorInstancesPlaceHolder();
const connector = connectorInstances.find(({ connector }) => connector.id === connectorId);
assertThat(
connector,
new RequestError({
code: 'entity.not_found',
connectorId,
status: 404,
})
);
return {
...connector,
validateConfig: validateConfigPlaceHolder,
sendMessage: sendMessagePlaceHolder,
};
});
const validateConfigPlaceHolder = jest.fn();
const sendMessagePlaceHolder = jest.fn();
jest.mock('@/queries/connector', () => ({
updateConnector: jest.fn(),
}));
jest.mock('@/connectors', () => ({
getConnectorInstances: async () => getConnectorInstancesPlaceHolder(),
getConnectorInstanceById: async (connectorId: string) =>
getConnectorInstanceByIdPlaceHolder(connectorId),
}));
describe('connector PATCH routes', () => {
const connectorRequest = createRequester({ authedRoutes: connectorRoutes });
describe('PATCH /connectors/:id/enabled', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws if connector can not be found (locally)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList.slice(1));
const response = await connectorRequest
.patch('/connectors/findConnector/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 404);
});
it('throws if connector can not be found (remotely)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
const response = await connectorRequest
.patch('/connectors/id0/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 404);
});
it('enables one of the social connectors (with valid config)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
{
connector: mockConnector,
metadata: { ...mockMetadata, type: ConnectorType.Social },
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: true });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { enabled: true },
})
);
expect(response.body).toMatchObject({
metadata: { ...mockMetadata, type: ConnectorType.Social },
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the social connectors (with invalid config)', async () => {
validateConfigPlaceHolder.mockImplementationOnce(async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
});
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
{
connector: mockConnector,
metadata: mockMetadata,
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 500);
});
it('disables one of the social connectors', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
{
connector: mockConnector,
metadata: mockMetadata,
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: false });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { enabled: false },
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the email/sms connectors (with valid config)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList);
const mockedMetadata = {
...mockMetadata,
id: 'id1',
type: ConnectorType.SMS,
};
const mockedConnector = {
...mockConnector,
id: 'id1',
};
getConnectorInstanceByIdPlaceHolder.mockResolvedValueOnce({
connector: mockedConnector,
metadata: mockedMetadata,
validateConfig: jest.fn(),
sendMessage: jest.fn(),
});
const response = await connectorRequest
.patch('/connectors/id1/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 200);
expect(updateConnector).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { id: 'id1' },
set: { enabled: false },
})
);
expect(updateConnector).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: { id: 'id5' },
set: { enabled: false },
})
);
expect(updateConnector).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
where: { id: 'id1' },
set: { enabled: true },
})
);
expect(response.body).toMatchObject({
metadata: mockedMetadata,
});
});
it('enables one of the email/sms connectors (with invalid config)', async () => {
validateConfigPlaceHolder.mockImplementationOnce(async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
});
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
{
connector: mockConnector,
metadata: {
...mockMetadata,
type: ConnectorType.SMS,
},
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 500);
});
it('disables one of the email/sms connectors', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
{
connector: mockConnector,
metadata: mockMetadata,
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: false });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { enabled: false },
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('PATCH /connectors/:id', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws when connector can not be found by given connectorId (locally)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce(mockConnectorInstanceList.slice(0, 1));
const response = await connectorRequest.patch('/connectors/findConnector').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('throws when connector can not be found by given connectorId (remotely)', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([]);
const response = await connectorRequest.patch('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('config validation fails', async () => {
validateConfigPlaceHolder.mockImplementationOnce(async () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
});
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
{
connector: mockConnector,
metadata: mockMetadata,
},
]);
const response = await connectorRequest
.patch('/connectors/id')
.send({ config: { cliend_id: 'client_id', client_secret: 'client_secret' } });
expect(response).toHaveProperty('statusCode', 500);
});
it('successfully updates connector configs', async () => {
getConnectorInstancesPlaceHolder.mockResolvedValueOnce([
{
connector: mockConnector,
metadata: mockMetadata,
},
]);
const response = await connectorRequest
.patch('/connectors/id')
.send({ config: { cliend_id: 'client_id', client_secret: 'client_secret' } });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { config: { cliend_id: 'client_id', client_secret: 'client_secret' } },
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
});
});