diff --git a/packages/core/src/__mocks__/connector.ts b/packages/core/src/__mocks__/connector.ts index aa683e18c..a233f8eb3 100644 --- a/packages/core/src/__mocks__/connector.ts +++ b/packages/core/src/__mocks__/connector.ts @@ -24,6 +24,8 @@ import { } from './connector-base-data.js'; export { + mockConnector0, + mockConnector1, mockMetadata, mockMetadata0, mockMetadata1, diff --git a/packages/core/src/lib/connector.test.ts b/packages/core/src/lib/connector.test.ts new file mode 100644 index 000000000..5380ed222 --- /dev/null +++ b/packages/core/src/lib/connector.test.ts @@ -0,0 +1,46 @@ +import { ConnectorType } from '@logto/schemas'; + +import { + mockMetadata0, + mockMetadata1, + mockConnector0, + mockConnector1, + mockLogtoConnector, + mockLogtoConnectorList, +} from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; + +import { checkSocialConnectorTargetAndPlatformUniqueness } from './connector.js'; + +describe('check social connector target and platform uniqueness', () => { + it('throws if more than one same-platform social connectors sharing the same `target`', () => { + const mockConnectors = [ + { + dbEntry: mockConnector0, + metadata: { ...mockMetadata0, target: 'target' }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + { + dbEntry: mockConnector1, + metadata: { ...mockMetadata1, target: 'target' }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]; + expect(() => { + checkSocialConnectorTargetAndPlatformUniqueness(mockConnectors); + }).toMatchError( + new RequestError({ + code: 'connector.multiple_target_with_same_platform', + status: 400, + }) + ); + }); + + it('should not throw when no multiple connectors sharing same target and platform', () => { + expect(() => { + checkSocialConnectorTargetAndPlatformUniqueness(mockLogtoConnectorList); + }).not.toThrow(); + }); +}); diff --git a/packages/core/src/lib/connector.ts b/packages/core/src/lib/connector.ts new file mode 100644 index 000000000..7c614b872 --- /dev/null +++ b/packages/core/src/lib/connector.ts @@ -0,0 +1,27 @@ +import { ConnectorType } from '@logto/schemas'; + +import type { LogtoConnector } from '#src/connectors/types.js'; +import assertThat from '#src/utils/assert-that.js'; + +export const checkSocialConnectorTargetAndPlatformUniqueness = (connectors: LogtoConnector[]) => { + const targetAndPlatformObjectsInUse = connectors + .filter(({ type }) => type === ConnectorType.Social) + .map(({ metadata: { target, platform } }) => ({ + target, + platform, + })); + + const targetAndPlatformSet = new Set(); + + for (const targetAndPlatformObject of targetAndPlatformObjectsInUse) { + const { target, platform } = targetAndPlatformObject; + + if (platform === null) { + continue; + } + + const element = JSON.stringify([target, platform]); + assertThat(!targetAndPlatformSet.has(element), 'connector.multiple_target_with_same_platform'); + targetAndPlatformSet.add(element); + } +}; diff --git a/packages/core/src/routes/connector.test.ts b/packages/core/src/routes/connector.test.ts index 6ef17d060..a817d4be1 100644 --- a/packages/core/src/routes/connector.test.ts +++ b/packages/core/src/routes/connector.test.ts @@ -1,5 +1,5 @@ import type { EmailConnector, SmsConnector } from '@logto/connector-kit'; -import { MessageTypes } from '@logto/connector-kit'; +import { ConnectorPlatform, MessageTypes } from '@logto/connector-kit'; import { ConnectorType } from '@logto/schemas'; import { any } from 'zod'; @@ -36,6 +36,10 @@ const getLogtoConnectorsPlaceHolder = jest.fn() as jest.MockedFunction< () => Promise >; +jest.mock('#src/lib/connector.js', () => ({ + checkSocialConnectorTargetAndPlatformUniqueness: jest.fn(), +})); + jest.mock('#src/lib/sign-in-experience/index.js', () => ({ removeUnavailableSocialConnectorTargets: jest.fn(), })); @@ -259,6 +263,39 @@ describe('connector route', () => { ); expect(deleteConnectorByIds).toHaveBeenCalledWith(['id']); }); + + it('throws when add more than 1 social connector instance with same target and platform', async () => { + loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([ + { + ...mockConnectorFactory, + metadata: { + ...mockConnectorFactory.metadata, + id: 'id0', + platform: ConnectorPlatform.Universal, + isStandard: true, + }, + }, + ]); + mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 0 }); + getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([ + { + dbEntry: { ...mockConnector, connectorId: 'id0', metadata: { target: 'target' } }, + metadata: { + ...mockMetadata, + id: 'id0', + target: 'target', + platform: ConnectorPlatform.Universal, + }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id0', + metadata: { target: 'target' }, + }); + expect(response).toHaveProperty('statusCode', 422); + }); }); describe('POST /connectors/:id/test', () => { diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector.ts index 8b68953bd..3f3b6f6f8 100644 --- a/packages/core/src/routes/connector.ts +++ b/packages/core/src/routes/connector.ts @@ -14,6 +14,7 @@ import { } from '#src/connectors/index.js'; import type { LogtoConnector } from '#src/connectors/types.js'; import RequestError from '#src/errors/RequestError/index.js'; +import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/lib/connector.js'; import { removeUnavailableSocialConnectorTargets } from '#src/lib/sign-in-experience/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import { @@ -52,6 +53,8 @@ export default function connectorRoutes(router: T) { 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' @@ -108,12 +111,12 @@ export default function connectorRoutes(router: T) { syncProfile: true, }), }), + // eslint-disable-next-line complexity async (ctx, next) => { const { body: { connectorId }, body, } = ctx.guard; - const connectorFactories = await loadConnectorFactories(); const connectorFactory = connectorFactories.find( ({ metadata: { id } }) => id === connectorId @@ -135,6 +138,17 @@ export default function connectorRoutes(router: T) { }) ); + if (body.metadata?.target && connectorFactory.type === ConnectorType.Social) { + const connectors = await getLogtoConnectors(); + assertThat( + !connectors.some( + ({ metadata: { target, platform } }) => + target === body.metadata?.target && platform === connectorFactory.metadata.platform + ), + new RequestError({ code: 'connector.multiple_target_with_same_platform', status: 422 }) + ); + } + const insertConnectorId = generateConnectorId(); const { metadata, ...rest } = body; diff --git a/packages/phrases/src/locales/de/errors.ts b/packages/phrases/src/locales/de/errors.ts index 256d739fa..8de26e02d 100644 --- a/packages/phrases/src/locales/de/errors.ts +++ b/packages/phrases/src/locales/de/errors.ts @@ -106,6 +106,8 @@ const errors = { 'Can not create multiple instance with picked standard connector.', invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', can_not_modify_target: 'The connector target can not be modified.', + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', }, passcode: { phone_email_empty: 'Telefonnummer oder E-Mail darf nicht leer sein.', diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index 8b46f65a6..f073d9a1b 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -105,6 +105,8 @@ const errors = { 'Can not create multiple instance with picked standard connector.', invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', can_not_modify_target: 'The connector target can not be modified.', + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', }, passcode: { phone_email_empty: 'Both phone and email are empty.', diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 2849fddfa..5269dc709 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -112,6 +112,8 @@ const errors = { 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', // UNTRANSLATED can_not_modify_target: 'The connector target can not be modified.', // UNTRANSLATED + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', // UNTRANSLATED }, passcode: { phone_email_empty: "Le téléphone et l'email sont vides.", diff --git a/packages/phrases/src/locales/ko/errors.ts b/packages/phrases/src/locales/ko/errors.ts index e85eb243e..3b32d1f1c 100644 --- a/packages/phrases/src/locales/ko/errors.ts +++ b/packages/phrases/src/locales/ko/errors.ts @@ -104,6 +104,8 @@ const errors = { 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', // UNTRANSLATED can_not_modify_target: 'The connector target can not be modified.', // UNTRANSLATED + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', // UNTRANSLATED }, passcode: { phone_email_empty: '휴대전화번호 그리고 이메일이 비어있어요.', diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index 26feaaa5d..c7d42da50 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -107,6 +107,8 @@ const errors = { 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', // UNTRANSLATED can_not_modify_target: 'The connector target can not be modified.', // UNTRANSLATED + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', // UNTRANSLATED }, passcode: { phone_email_empty: 'O campos telefone e email estão vazios.', diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index b2aa56886..982b5065b 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -106,6 +106,8 @@ const errors = { 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', // UNTRANSLATED can_not_modify_target: 'The connector target can not be modified.', // UNTRANSLATED + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', // UNTRANSLATED }, passcode: { phone_email_empty: 'Hem telefon hem de e-posta adresi yok.', diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index dc2518549..35b1cb0d6 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -101,6 +101,7 @@ const errors = { multiple_instances_not_supported: '你选择的连接器不支持创建多实例。', invalid_type_for_syncing_profile: '只有社交连接器可以开启用户档案同步。', can_not_modify_target: '不可修改连接器 target。', + multiple_target_with_same_platform: '不能同时存在多个有相同 target 和平台类型的社交连接器。', }, passcode: { phone_email_empty: '手机号与邮箱地址均为空',