0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

refactor(core): reorg connector routes and UTs (#3700)

This commit is contained in:
Darcy Ye 2023-04-18 14:08:52 +08:00 committed by GitHub
parent 020a811016
commit 2e036eae1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 626 additions and 406 deletions

View file

@ -0,0 +1,142 @@
import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
import { VerificationCodeType } from '@logto/connector-kit';
import type { EmailConnector, SmsConnector } from '@logto/connector-kit';
import { ConnectorType } from '@logto/schemas';
import { pickDefault, createMockUtils } from '@logto/shared/esm';
import { mockMetadata, mockConnectorFactory } 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 type { LogtoConnector } from '#src/utils/connectors/types.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
// eslint-disable-next-line @typescript-eslint/ban-types
const getLogtoConnectors = jest.fn<Promise<LogtoConnector[]>, []>();
const { loadConnectorFactories } = await mockEsmWithActual(
'#src/utils/connectors/index.js',
() => ({
loadConnectorFactories: jest.fn(),
})
);
const { buildRawConnector } = await mockEsmWithActual('@logto/cli/lib/connector/index.js', () => ({
buildRawConnector: jest.fn(),
}));
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 connectorConfigTestingRoutes = await pickDefault(import('./config-testing.js'));
describe('connector services route', () => {
const connectorRequest = createRequester({
authedRoutes: connectorConfigTestingRoutes,
tenantContext,
});
describe('POST /connectors/:factoryId/test', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should get SMS connector and send test message', async () => {
const sendMessage = jest.fn();
const mockedSmsConnectorFactory: ConnectorFactory<SmsConnector> = {
...mockConnectorFactory,
metadata: mockMetadata,
type: ConnectorType.Sms,
createConnector: jest.fn(),
};
loadConnectorFactories.mockResolvedValueOnce([mockedSmsConnectorFactory]);
buildRawConnector.mockResolvedValueOnce({ rawConnector: { sendMessage } });
const response = await connectorRequest
.post('/connectors/id/test')
.send({ phone: '12345678901', config: { test: 123 } });
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
{
to: '12345678901',
type: VerificationCodeType.Generic,
payload: {
code: '000000',
},
},
{ test: 123 }
);
expect(response).toHaveProperty('statusCode', 204);
});
it('should get email connector and send test message', async () => {
const sendMessage = jest.fn();
const mockedEmailConnectorFactory: ConnectorFactory<EmailConnector> = {
...mockConnectorFactory,
metadata: mockMetadata,
type: ConnectorType.Email,
createConnector: jest.fn(),
};
loadConnectorFactories.mockResolvedValueOnce([mockedEmailConnectorFactory]);
buildRawConnector.mockResolvedValueOnce({ rawConnector: { sendMessage } });
const response = await connectorRequest
.post('/connectors/id/test')
.send({ email: 'test@email.com', config: { test: 123 } });
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
{
to: 'test@email.com',
type: VerificationCodeType.Generic,
payload: {
code: '000000',
},
},
{ test: 123 }
);
expect(response).toHaveProperty('statusCode', 204);
});
it('should throw when neither phone nor email is provided', async () => {
const response = await connectorRequest.post('/connectors/id/test').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('should throw when sms connector is not found', async () => {
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ phone: '12345678901' });
expect(response).toHaveProperty('statusCode', 400);
});
it('should throw when email connector is not found', async () => {
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ email: 'test@email.com' });
expect(response).toHaveProperty('statusCode', 400);
});
});
});

View file

@ -0,0 +1,83 @@
import { buildRawConnector } from '@logto/cli/lib/connector/index.js';
import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
import {
type SmsConnector,
type EmailConnector,
demoConnectorIds,
VerificationCodeType,
} from '@logto/connector-kit';
import { phoneRegEx, emailRegEx } from '@logto/core-kit';
import { arbitraryObjectGuard, ConnectorType } from '@logto/schemas';
import { string, object } 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 { loadConnectorFactories } from '#src/utils/connectors/index.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
export default function connectorConfigTestingRoutes<T extends AuthedRouter>(
...[router]: RouterInitArgs<T>
) {
router.post(
'/connectors/:factoryId/test',
koaGuard({
params: object({ factoryId: string().min(1) }),
body: object({
phone: string().regex(phoneRegEx).optional(),
email: string().regex(emailRegEx).optional(),
config: arbitraryObjectGuard,
}),
}),
async (ctx, next) => {
const {
params: { factoryId },
body,
} = ctx.guard;
const { phone, email, config } = body;
const subject = phone ?? email;
assertThat(subject, new RequestError({ code: 'guard.invalid_input' }));
const connectorFactories = await loadConnectorFactories();
const connectorFactory = connectorFactories
.filter(
(factory): factory is ConnectorFactory<SmsConnector> | ConnectorFactory<EmailConnector> =>
factory.type === ConnectorType.Email || factory.type === ConnectorType.Sms
)
.find(({ metadata: { id } }) => id === factoryId && !demoConnectorIds.includes(id));
const expectType = phone ? ConnectorType.Sms : ConnectorType.Email;
assertThat(
connectorFactory,
new RequestError({
code: 'connector.not_found',
type: expectType,
factoryId,
})
);
assertThat(connectorFactory.type === expectType, 'connector.unexpected_type');
const {
rawConnector: { sendMessage },
} = await buildRawConnector<SmsConnector | EmailConnector>(connectorFactory);
await sendMessage(
{
to: subject,
type: VerificationCodeType.Generic,
payload: {
code: '000000',
},
},
config
);
ctx.status = 204;
return next();
}
);
}

View file

@ -0,0 +1,112 @@
import { ConnectorType } from '@logto/schemas';
import { pickDefault, createMockUtils } from '@logto/shared/esm';
import { mockConnector, mockConnectorFactory } from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import assertThat from '#src/utils/assert-that.js';
import type { LogtoConnector } from '#src/utils/connectors/types.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
const removeUnavailableSocialConnectorTargets = jest.fn();
const getLogtoConnectors: jest.MockedFunction<() => Promise<LogtoConnector[]>> = jest.fn();
const getLogtoConnectorById: jest.MockedFunction<(connectorId: string) => Promise<LogtoConnector>> =
jest.fn(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,
sendMessage: sendMessagePlaceHolder,
};
});
const sendMessagePlaceHolder = jest.fn();
const connectorQueries = {
findConnectorById: jest.fn(),
deleteConnectorById: jest.fn(),
} satisfies Partial<Queries['connectors']>;
const { findConnectorById, deleteConnectorById } = connectorQueries;
const { loadConnectorFactories } = await mockEsmWithActual(
'#src/utils/connectors/index.js',
() => ({
loadConnectorFactories: jest.fn(),
})
);
const tenantContext = new MockTenant(
undefined,
{ connectors: connectorQueries },
{
getLogtoConnectors,
getLogtoConnectorById,
},
{
signInExperiences: { removeUnavailableSocialConnectorTargets },
}
);
const connectorDataRoutes = await pickDefault(import('./index.js'));
describe('connector data routes', () => {
const connectorRequest = createRequester({ authedRoutes: connectorDataRoutes, tenantContext });
describe('DELETE /connectors/:id', () => {
beforeEach(() => {
jest.resetAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
it('delete connector instance and remove unavailable social connector targets', async () => {
findConnectorById.mockResolvedValueOnce(mockConnector);
loadConnectorFactories.mockResolvedValueOnce([mockConnectorFactory]);
await connectorRequest.delete('/connectors/id').send({});
expect(deleteConnectorById).toHaveBeenCalledTimes(1);
expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(1);
});
it('delete connector instance (connector factory is not social type)', async () => {
findConnectorById.mockResolvedValueOnce(mockConnector);
loadConnectorFactories.mockResolvedValueOnce([
{ ...mockConnectorFactory, type: ConnectorType.Sms },
]);
await connectorRequest.delete('/connectors/id').send({});
expect(deleteConnectorById).toHaveBeenCalledTimes(1);
expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(0);
});
it('delete connector instance (connector factory is not found)', async () => {
findConnectorById.mockResolvedValueOnce(mockConnector);
loadConnectorFactories.mockResolvedValueOnce([]);
await connectorRequest.delete('/connectors/id').send({});
expect(deleteConnectorById).toHaveBeenCalledTimes(1);
expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(0);
});
it('throws when connector not exists with `id`', async () => {
// eslint-disable-next-line unicorn/no-useless-undefined
findConnectorById.mockResolvedValueOnce(undefined);
const response = await connectorRequest.delete('/connectors/id').send({});
expect(response).toHaveProperty('statusCode', 500);
});
});
});

View file

@ -0,0 +1,158 @@
import { ConnectorType } from '@logto/schemas';
import { pickDefault, createMockUtils } from '@logto/shared/esm';
import {
mockMetadata0,
mockMetadata1,
mockMetadata2,
mockMetadata3,
mockConnectorFactory,
mockLogtoConnectorList,
} 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 type { LogtoConnector } from '#src/utils/connectors/types.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
mockEsm('#src/utils/connectors/platform.js', () => ({
checkSocialConnectorTargetAndPlatformUniqueness: jest.fn(),
}));
// eslint-disable-next-line @typescript-eslint/ban-types
const getLogtoConnectors = jest.fn<Promise<LogtoConnector[]>, []>();
const { loadConnectorFactories } = await mockEsmWithActual(
'#src/utils/connectors/index.js',
() => ({
loadConnectorFactories: jest.fn(),
})
);
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 connectorDataRoutes = await pickDefault(import('./index.js'));
describe('connector data route', () => {
const connectorRequest = createRequester({ authedRoutes: connectorDataRoutes, tenantContext });
describe('GET /connectors', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws if more than one email connector exists', async () => {
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('throws if more than one SMS connector exists', async () => {
getLogtoConnectors.mockResolvedValueOnce(
mockLogtoConnectorList.filter((connector) => connector.type !== ConnectorType.Email)
);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('shows all connectors', async () => {
getLogtoConnectors.mockResolvedValueOnce(
mockLogtoConnectorList.filter((connector) => connector.type === ConnectorType.Social)
);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('GET /connectors/:id', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws when connector can not be found by given connectorId (locally)', async () => {
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList.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 () => {
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('shows found connector information', async () => {
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('GET /connector-factories', () => {
it('show all connector factories', async () => {
loadConnectorFactories.mockResolvedValueOnce([
{ ...mockConnectorFactory, metadata: mockMetadata0, type: ConnectorType.Sms },
{ ...mockConnectorFactory, metadata: mockMetadata1, type: ConnectorType.Social },
{ ...mockConnectorFactory, metadata: mockMetadata2, type: ConnectorType.Email },
{ ...mockConnectorFactory, metadata: mockMetadata3, type: ConnectorType.Social },
]);
const response = await connectorRequest.get('/connector-factories').send({});
expect(response.body).toMatchObject([
{ ...mockMetadata0, type: ConnectorType.Sms },
{ ...mockMetadata1, type: ConnectorType.Social },
{ ...mockMetadata2, type: ConnectorType.Email },
{ ...mockMetadata3, type: ConnectorType.Social },
]);
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('GET /connector-factories/:id', () => {
it('throws when connector factory can not be found by given id', async () => {
loadConnectorFactories.mockResolvedValueOnce([
{ ...mockConnectorFactory, metadata: mockMetadata0, type: ConnectorType.Sms },
{ ...mockConnectorFactory, metadata: mockMetadata1, type: ConnectorType.Social },
{ ...mockConnectorFactory, metadata: mockMetadata2, type: ConnectorType.Email },
{ ...mockConnectorFactory, metadata: mockMetadata3, type: ConnectorType.Social },
]);
const response = await connectorRequest.get('/connector-factories/findConnector').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('show picked connector factory', async () => {
loadConnectorFactories.mockResolvedValueOnce([
{ ...mockConnectorFactory, metadata: mockMetadata0, type: ConnectorType.Sms },
{ ...mockConnectorFactory, metadata: mockMetadata1, type: ConnectorType.Social },
{ ...mockConnectorFactory, metadata: mockMetadata2, type: ConnectorType.Email },
{ ...mockConnectorFactory, metadata: mockMetadata3, type: ConnectorType.Social },
]);
const response = await connectorRequest.get('/connector-factories/id2').send({});
expect(response.body).toMatchObject({ ...mockMetadata2, type: ConnectorType.Email });
expect(response).toHaveProperty('statusCode', 200);
});
});
});

View file

@ -9,6 +9,7 @@ import {
mockLogtoConnector,
} from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import assertThat from '#src/utils/assert-that.js';
import type { LogtoConnector } from '#src/utils/connectors/types.js';
@ -16,6 +17,8 @@ import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const removeUnavailableSocialConnectorTargets = jest.fn();
const getLogtoConnectors: jest.MockedFunction<() => Promise<LogtoConnector[]>> = jest.fn();
const getLogtoConnectorById: jest.MockedFunction<(connectorId: string) => Promise<LogtoConnector>> =
jest.fn(async (connectorId: string) => {
@ -38,27 +41,30 @@ const getLogtoConnectorById: jest.MockedFunction<(connectorId: string) => Promis
});
const sendMessagePlaceHolder = jest.fn();
const updateConnector = jest.fn();
const connectorQueries = {
findConnectorById: jest.fn(),
deleteConnectorById: jest.fn(),
updateConnector: jest.fn(),
} satisfies Partial<Queries['connectors']>;
const { updateConnector } = connectorQueries;
const tenantContext = new MockTenant(
undefined,
{ connectors: { updateConnector } },
{ connectors: connectorQueries },
{
getLogtoConnectors,
getLogtoConnectorById,
},
{
signInExperiences: {
// eslint-disable-next-line @typescript-eslint/no-empty-function
removeUnavailableSocialConnectorTargets: async () => {},
},
signInExperiences: { removeUnavailableSocialConnectorTargets },
}
);
const connectorRoutes = await pickDefault(import('./connector.js'));
const connectorDataRoutes = await pickDefault(import('./index.js'));
describe('connector PATCH routes', () => {
const connectorRequest = createRequester({ authedRoutes: connectorRoutes, tenantContext });
describe('connector data routes', () => {
const connectorRequest = createRequester({ authedRoutes: connectorDataRoutes, tenantContext });
describe('PATCH /connectors/:id', () => {
afterEach(() => {
@ -131,6 +137,20 @@ describe('connector PATCH routes', () => {
expect(response).toHaveProperty('statusCode', 400);
});
it('throws when set syncProfile to `true` and with non-social connector', async () => {
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Sms,
...mockLogtoConnector,
},
]);
const response = await connectorRequest.patch('/connectors/id').send({ syncProfile: true });
expect(response).toHaveProperty('statusCode', 422);
expect(updateConnector).toHaveBeenCalledTimes(0);
});
it('successfully updates connector config', async () => {
getLogtoConnectors.mockResolvedValue([
{
@ -235,20 +255,6 @@ describe('connector PATCH routes', () => {
expect(response).toHaveProperty('statusCode', 200);
});
it('throws when set syncProfile to `true` and with non-social connector', async () => {
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Sms,
...mockLogtoConnector,
},
]);
const response = await connectorRequest.patch('/connectors/id').send({ syncProfile: true });
expect(response).toHaveProperty('statusCode', 422);
expect(updateConnector).toHaveBeenCalledTimes(0);
});
it('successfully set syncProfile to `true` and with social connector', async () => {
getLogtoConnectors.mockResolvedValue([
{

View file

@ -1,7 +1,4 @@
/* eslint-disable max-lines */
import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
import { ConnectorPlatform, VerificationCodeType } from '@logto/connector-kit';
import type { EmailConnector, SmsConnector } from '@logto/connector-kit';
import { ConnectorPlatform } from '@logto/connector-kit';
import type { Connector } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { pickDefault, createMockUtils } from '@logto/shared/esm';
@ -9,13 +6,8 @@ import { any } from 'zod';
import {
mockMetadata,
mockMetadata0,
mockMetadata1,
mockMetadata2,
mockMetadata3,
mockConnector,
mockConnectorFactory,
mockLogtoConnectorList,
mockLogtoConnector,
} from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
@ -26,28 +18,14 @@ import type { LogtoConnector } from '#src/utils/connectors/types.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
mockEsm('#src/utils/connectors/platform.js', () => ({
checkSocialConnectorTargetAndPlatformUniqueness: jest.fn(),
}));
const removeUnavailableSocialConnectorTargets = jest.fn();
const { mockEsmWithActual } = createMockUtils(jest);
const connectorQueries = {
findConnectorById: jest.fn(),
countConnectorByConnectorId: jest.fn(),
deleteConnectorById: jest.fn(),
deleteConnectorByIds: jest.fn(),
insertConnector: jest.fn(async (body) => body as Connector),
} satisfies Partial<Queries['connectors']>;
const {
findConnectorById,
countConnectorByConnectorId,
deleteConnectorById,
deleteConnectorByIds,
insertConnector,
} = connectorQueries;
const { countConnectorByConnectorId, deleteConnectorByIds, insertConnector } = connectorQueries;
// eslint-disable-next-line @typescript-eslint/ban-types
const getLogtoConnectors = jest.fn<Promise<LogtoConnector[]>, []>();
@ -87,86 +65,13 @@ const tenantContext = new MockTenant(
return connector;
},
},
{
signInExperiences: { removeUnavailableSocialConnectorTargets },
}
{}
);
const connectorRoutes = await pickDefault(import('./connector.js'));
const connectorDataRoutes = await pickDefault(import('./index.js'));
describe('connector route', () => {
const connectorRequest = createRequester({ authedRoutes: connectorRoutes, tenantContext });
describe('GET /connectors', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws if more than one email connector exists', async () => {
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('throws if more than one SMS connector exists', async () => {
getLogtoConnectors.mockResolvedValueOnce(
mockLogtoConnectorList.filter((connector) => connector.type !== ConnectorType.Email)
);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('shows all connectors', async () => {
getLogtoConnectors.mockResolvedValueOnce(
mockLogtoConnectorList.filter((connector) => connector.type === ConnectorType.Social)
);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('GET /connector-factories', () => {
it('show all connector factories', async () => {
loadConnectorFactories.mockResolvedValueOnce([
{ ...mockConnectorFactory, metadata: mockMetadata0, type: ConnectorType.Sms },
{ ...mockConnectorFactory, metadata: mockMetadata1, type: ConnectorType.Social },
{ ...mockConnectorFactory, metadata: mockMetadata2, type: ConnectorType.Email },
{ ...mockConnectorFactory, metadata: mockMetadata3, type: ConnectorType.Social },
]);
const response = await connectorRequest.get('/connector-factories').send({});
expect(response.body).toMatchObject([
{ ...mockMetadata0, type: ConnectorType.Sms },
{ ...mockMetadata1, type: ConnectorType.Social },
{ ...mockMetadata2, type: ConnectorType.Email },
{ ...mockMetadata3, type: ConnectorType.Social },
]);
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('GET /connectors/:id', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws when connector can not be found by given connectorId (locally)', async () => {
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList.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 () => {
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('shows found connector information', async () => {
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('connector data route', () => {
const connectorRequest = createRequester({ authedRoutes: connectorDataRoutes, tenantContext });
describe('POST /connectors', () => {
afterEach(() => {
@ -414,129 +319,4 @@ describe('connector route', () => {
expect(response).toHaveProperty('statusCode', 422);
});
});
describe('POST /connectors/:id/test', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should get SMS connector and send test message', async () => {
const sendMessage = jest.fn();
const mockedSmsConnectorFactory: ConnectorFactory<SmsConnector> = {
...mockConnectorFactory,
metadata: mockMetadata,
type: ConnectorType.Sms,
createConnector: jest.fn(),
};
loadConnectorFactories.mockResolvedValueOnce([mockedSmsConnectorFactory]);
buildRawConnector.mockResolvedValueOnce({ rawConnector: { sendMessage } });
const response = await connectorRequest
.post('/connectors/id/test')
.send({ phone: '12345678901', config: { test: 123 } });
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
{
to: '12345678901',
type: VerificationCodeType.Generic,
payload: {
code: '000000',
},
},
{ test: 123 }
);
expect(response).toHaveProperty('statusCode', 204);
});
it('should get email connector and send test message', async () => {
const sendMessage = jest.fn();
const mockedEmailConnectorFactory: ConnectorFactory<EmailConnector> = {
...mockConnectorFactory,
metadata: mockMetadata,
type: ConnectorType.Email,
createConnector: jest.fn(),
};
loadConnectorFactories.mockResolvedValueOnce([mockedEmailConnectorFactory]);
buildRawConnector.mockResolvedValueOnce({ rawConnector: { sendMessage } });
const response = await connectorRequest
.post('/connectors/id/test')
.send({ email: 'test@email.com', config: { test: 123 } });
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
{
to: 'test@email.com',
type: VerificationCodeType.Generic,
payload: {
code: '000000',
},
},
{ test: 123 }
);
expect(response).toHaveProperty('statusCode', 204);
});
it('should throw when neither phone nor email is provided', async () => {
const response = await connectorRequest.post('/connectors/id/test').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('should throw when sms connector is not found', async () => {
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ phone: '12345678901' });
expect(response).toHaveProperty('statusCode', 400);
});
it('should throw when email connector is not found', async () => {
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ email: 'test@email.com' });
expect(response).toHaveProperty('statusCode', 400);
});
});
describe('DELETE /connectors/:id', () => {
beforeEach(() => {
jest.resetAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
it('delete connector instance and remove unavailable social connector targets', async () => {
findConnectorById.mockResolvedValueOnce(mockConnector);
loadConnectorFactories.mockResolvedValueOnce([mockConnectorFactory]);
await connectorRequest.delete('/connectors/id').send({});
expect(deleteConnectorById).toHaveBeenCalledTimes(1);
expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(1);
});
it('delete connector instance (connector factory is not social type)', async () => {
findConnectorById.mockResolvedValueOnce(mockConnector);
loadConnectorFactories.mockResolvedValueOnce([
{ ...mockConnectorFactory, type: ConnectorType.Sms },
]);
await connectorRequest.delete('/connectors/id').send({});
expect(deleteConnectorById).toHaveBeenCalledTimes(1);
expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(0);
});
it('delete connector instance (connector factory is not found)', async () => {
findConnectorById.mockResolvedValueOnce(mockConnector);
loadConnectorFactories.mockResolvedValueOnce([]);
await connectorRequest.delete('/connectors/id').send({});
expect(deleteConnectorById).toHaveBeenCalledTimes(1);
expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(0);
});
it('throws when connector not exists with `id`', async () => {
// eslint-disable-next-line unicorn/no-useless-undefined
findConnectorById.mockResolvedValueOnce(undefined);
const response = await connectorRequest.delete('/connectors/id').send({});
expect(response).toHaveProperty('statusCode', 500);
});
});
});
/* eslint-enable max-lines */

View file

@ -1,15 +1,6 @@
/* eslint-disable max-lines */
import { buildRawConnector } from '@logto/cli/lib/connector/index.js';
import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
import {
type SmsConnector,
type EmailConnector,
demoConnectorIds,
VerificationCodeType,
validateConfig,
} from '@logto/connector-kit';
import { phoneRegEx, emailRegEx } from '@logto/core-kit';
import { arbitraryObjectGuard, Connectors, ConnectorType } from '@logto/schemas';
import { demoConnectorIds, validateConfig } from '@logto/connector-kit';
import { Connectors, ConnectorType } from '@logto/schemas';
import { buildIdGenerator } from '@logto/shared';
import cleanDeep from 'clean-deep';
import { string, object } from 'zod';
@ -24,12 +15,14 @@ import {
} from '#src/utils/connectors/index.js';
import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/utils/connectors/platform.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
import connectorConfigTestingRoutes from './config-testing.js';
const generateConnectorId = buildIdGenerator(12);
export default function connectorRoutes<T extends AuthedRouter>(
...[router, { queries, connectors, libraries }]: RouterInitArgs<T>
...[router, tenant]: RouterInitArgs<T>
) {
const {
findConnectorById,
@ -38,88 +31,11 @@ export default function connectorRoutes<T extends AuthedRouter>(
deleteConnectorByIds,
insertConnector,
updateConnector,
} = queries.connectors;
const { getLogtoConnectorById, getLogtoConnectors } = connectors;
} = tenant.queries.connectors;
const { getLogtoConnectorById, getLogtoConnectors } = tenant.connectors;
const {
signInExperiences: { removeUnavailableSocialConnectorTargets },
} = libraries;
router.get(
'/connectors',
koaGuard({
query: object({
target: string().optional(),
}),
}),
async (ctx, next) => {
const { target: filterTarget } = ctx.query;
const connectors = await getLogtoConnectors();
checkSocialConnectorTargetAndPlatformUniqueness(connectors);
assertThat(
connectors.filter((connector) => connector.type === ConnectorType.Email).length <= 1,
'connector.more_than_one_email'
);
assertThat(
connectors.filter((connector) => connector.type === ConnectorType.Sms).length <= 1,
'connector.more_than_one_sms'
);
const filteredConnectors = filterTarget
? connectors.filter(({ metadata: { target } }) => target === filterTarget)
: connectors;
ctx.body = filteredConnectors.map((connector) => transpileLogtoConnector(connector));
return next();
}
);
router.get('/connector-factories', async (ctx, next) => {
const connectorFactories = await loadConnectorFactories();
ctx.body = connectorFactories.map((connectorFactory) =>
transpileConnectorFactory(connectorFactory)
);
return next();
});
router.get(
'/connector-factories/:id',
koaGuard({ params: object({ id: string().min(1) }) }),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const connectorFactories = await loadConnectorFactories();
const connectorFactory = connectorFactories.find((factory) => factory.metadata.id === id);
assertThat(connectorFactory, 'entity.not_found');
ctx.body = transpileConnectorFactory(connectorFactory);
return next();
}
);
router.get(
'/connectors/:id',
koaGuard({ params: object({ id: string().min(1) }) }),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const connector = await getLogtoConnectorById(id);
// Hide demo connector
assertThat(!demoConnectorIds.includes(connector.metadata.id), 'connector.not_found');
ctx.body = transpileLogtoConnector(connector);
return next();
}
);
} = tenant.libraries;
router.post(
'/connectors',
@ -234,6 +150,89 @@ export default function connectorRoutes<T extends AuthedRouter>(
}
);
router.get(
'/connectors',
koaGuard({
query: object({
target: string().optional(),
}),
}),
async (ctx, next) => {
const { target: filterTarget } = ctx.query;
const connectors = await getLogtoConnectors();
checkSocialConnectorTargetAndPlatformUniqueness(connectors);
assertThat(
connectors.filter((connector) => connector.type === ConnectorType.Email).length <= 1,
'connector.more_than_one_email'
);
assertThat(
connectors.filter((connector) => connector.type === ConnectorType.Sms).length <= 1,
'connector.more_than_one_sms'
);
const filteredConnectors = filterTarget
? connectors.filter(({ metadata: { target } }) => target === filterTarget)
: connectors;
ctx.body = filteredConnectors.map((connector) => transpileLogtoConnector(connector));
return next();
}
);
router.get(
'/connectors/:id',
koaGuard({ params: object({ id: string().min(1) }) }),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const connector = await getLogtoConnectorById(id);
// Hide demo connector
assertThat(!demoConnectorIds.includes(connector.metadata.id), 'connector.not_found');
ctx.body = transpileLogtoConnector(connector);
return next();
}
);
router.get('/connector-factories', async (ctx, next) => {
const connectorFactories = await loadConnectorFactories();
ctx.body = connectorFactories.map((connectorFactory) =>
transpileConnectorFactory(connectorFactory)
);
return next();
});
router.get(
'/connector-factories/:id',
koaGuard({ params: object({ id: string().min(1) }) }),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const connectorFactories = await loadConnectorFactories();
const connectorFactory = connectorFactories.find((factory) => factory.metadata.id === id);
assertThat(
connectorFactory,
new RequestError({
code: 'entity.not_found',
status: 404,
})
);
ctx.body = transpileConnectorFactory(connectorFactory);
return next();
}
);
router.patch(
'/connectors/:id',
koaGuard({
@ -288,67 +287,6 @@ export default function connectorRoutes<T extends AuthedRouter>(
}
);
router.post(
'/connectors/:factoryId/test',
koaGuard({
params: object({ factoryId: string().min(1) }),
body: object({
phone: string().regex(phoneRegEx).optional(),
email: string().regex(emailRegEx).optional(),
config: arbitraryObjectGuard,
}),
}),
async (ctx, next) => {
const {
params: { factoryId },
body,
} = ctx.guard;
const { phone, email, config } = body;
const subject = phone ?? email;
assertThat(subject, new RequestError({ code: 'guard.invalid_input' }));
const connectorFactories = await loadConnectorFactories();
const connectorFactory = connectorFactories
.filter(
(factory): factory is ConnectorFactory<SmsConnector> | ConnectorFactory<EmailConnector> =>
factory.type === ConnectorType.Email || factory.type === ConnectorType.Sms
)
.find(({ metadata: { id } }) => id === factoryId && !demoConnectorIds.includes(id));
const expectType = phone ? ConnectorType.Sms : ConnectorType.Email;
assertThat(
connectorFactory,
new RequestError({
code: 'connector.not_found',
type: expectType,
factoryId,
})
);
assertThat(connectorFactory.type === expectType, 'connector.unexpected_type');
const {
rawConnector: { sendMessage },
} = await buildRawConnector<SmsConnector | EmailConnector>(connectorFactory);
await sendMessage(
{
to: subject,
type: VerificationCodeType.Generic,
payload: {
code: '000000',
},
},
config
);
ctx.status = 204;
return next();
}
);
router.delete(
'/connectors/:id',
koaGuard({ params: object({ id: string().min(1) }) }),
@ -375,5 +313,6 @@ export default function connectorRoutes<T extends AuthedRouter>(
return next();
}
);
connectorConfigTestingRoutes(router, tenant);
}
/* eslint-enable max-lines */

View file

@ -13,7 +13,7 @@ import adminUserRoleRoutes from './admin-user-role.js';
import adminUserRoutes from './admin-user.js';
import applicationRoutes from './application.js';
import authnRoutes from './authn.js';
import connectorRoutes from './connector.js';
import connectorRoutes from './connector/index.js';
import customPhraseRoutes from './custom-phrase.js';
import dashboardRoutes from './dashboard.js';
import hookRoutes from './hook.js';