diff --git a/packages/core/src/routes/connector/config-testing.test.ts b/packages/core/src/routes/connector/config-testing.test.ts new file mode 100644 index 000000000..24d444775 --- /dev/null +++ b/packages/core/src/routes/connector/config-testing.test.ts @@ -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, []>(); + +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 = { + ...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 = { + ...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); + }); + }); +}); diff --git a/packages/core/src/routes/connector/config-testing.ts b/packages/core/src/routes/connector/config-testing.ts new file mode 100644 index 000000000..035103b4b --- /dev/null +++ b/packages/core/src/routes/connector/config-testing.ts @@ -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( + ...[router]: RouterInitArgs +) { + 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 | ConnectorFactory => + 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(connectorFactory); + + await sendMessage( + { + to: subject, + type: VerificationCodeType.Generic, + payload: { + code: '000000', + }, + }, + config + ); + + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/connector/index.delete.test.ts b/packages/core/src/routes/connector/index.delete.test.ts new file mode 100644 index 000000000..b0c2d19b0 --- /dev/null +++ b/packages/core/src/routes/connector/index.delete.test.ts @@ -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> = jest.fn(); +const getLogtoConnectorById: jest.MockedFunction<(connectorId: string) => Promise> = + 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; +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); + }); + }); +}); diff --git a/packages/core/src/routes/connector/index.get.test.ts b/packages/core/src/routes/connector/index.get.test.ts new file mode 100644 index 000000000..cf09ed79c --- /dev/null +++ b/packages/core/src/routes/connector/index.get.test.ts @@ -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, []>(); + +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); + }); + }); +}); diff --git a/packages/core/src/routes/connector.update.test.ts b/packages/core/src/routes/connector/index.patch.test.ts similarity index 94% rename from packages/core/src/routes/connector.update.test.ts rename to packages/core/src/routes/connector/index.patch.test.ts index 0da984db7..abdd39ccc 100644 --- a/packages/core/src/routes/connector.update.test.ts +++ b/packages/core/src/routes/connector/index.patch.test.ts @@ -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> = jest.fn(); const getLogtoConnectorById: jest.MockedFunction<(connectorId: string) => Promise> = 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; +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([ { diff --git a/packages/core/src/routes/connector.test.ts b/packages/core/src/routes/connector/index.post.test.ts similarity index 54% rename from packages/core/src/routes/connector.test.ts rename to packages/core/src/routes/connector/index.post.test.ts index 645253247..b29bcbb7b 100644 --- a/packages/core/src/routes/connector.test.ts +++ b/packages/core/src/routes/connector/index.post.test.ts @@ -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; -const { - findConnectorById, - countConnectorByConnectorId, - deleteConnectorById, - deleteConnectorByIds, - insertConnector, -} = connectorQueries; +const { countConnectorByConnectorId, deleteConnectorByIds, insertConnector } = connectorQueries; // eslint-disable-next-line @typescript-eslint/ban-types const getLogtoConnectors = jest.fn, []>(); @@ -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 = { - ...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 = { - ...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 */ diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector/index.ts similarity index 79% rename from packages/core/src/routes/connector.ts rename to packages/core/src/routes/connector/index.ts index 4920ce2d0..6d73fd96c 100644 --- a/packages/core/src/routes/connector.ts +++ b/packages/core/src/routes/connector/index.ts @@ -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( - ...[router, { queries, connectors, libraries }]: RouterInitArgs + ...[router, tenant]: RouterInitArgs ) { const { findConnectorById, @@ -38,88 +31,11 @@ export default function connectorRoutes( 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( } ); + 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( } ); - 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 | ConnectorFactory => - 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(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( return next(); } ); + + connectorConfigTestingRoutes(router, tenant); } -/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 36df2baa4..b0de184d3 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -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';