diff --git a/packages/core/src/lib/sign-in-experience.test.ts b/packages/core/src/lib/sign-in-experience.test.ts index 68370de97..f84dfa19f 100644 --- a/packages/core/src/lib/sign-in-experience.test.ts +++ b/packages/core/src/lib/sign-in-experience.test.ts @@ -1,12 +1,21 @@ import { BrandingStyle, SignInMethodState } from '@logto/schemas'; +import { ConnectorType } from '@/connectors/types'; import RequestError from '@/errors/RequestError'; import { validateBranding, validateSignInMethods, validateTermsOfUse, } from '@/lib/sign-in-experience'; -import { mockBranding, mockSignInMethods } from '@/utils/mock'; +import { + mockAliyunDmConnectorInstance, + mockBranding, + mockFacebookConnectorInstance, + mockGithubConnectorInstance, + mockSignInMethods, +} from '@/utils/mock'; + +const enabledConnectorInstances = [mockFacebookConnectorInstance, mockGithubConnectorInstance]; describe('validate branding', () => { test('should throw when the UI style contains the slogan and slogan is empty', () => { @@ -51,26 +60,66 @@ describe('validate terms of use', () => { describe('validate sign-in methods', () => { describe('There must be one and only one primary sign-in method.', () => { - test('should throw when there is no primary sign-in method', async () => { + test('should throw when there is no primary sign-in method', () => { expect(() => { - validateSignInMethods({ - ...mockSignInMethods, - username: SignInMethodState.disabled, - }); + validateSignInMethods({ ...mockSignInMethods, username: SignInMethodState.disabled }, []); }).toMatchError( new RequestError('sign_in_experiences.not_one_and_only_one_primary_sign_in_method') ); }); - test('should throw when there are more than one primary sign-in methods', async () => { + test('should throw when there are more than one primary sign-in methods', () => { expect(() => { - validateSignInMethods({ - ...mockSignInMethods, - social: SignInMethodState.primary, - }); + validateSignInMethods({ ...mockSignInMethods, social: SignInMethodState.primary }, []); }).toMatchError( new RequestError('sign_in_experiences.not_one_and_only_one_primary_sign_in_method') ); }); }); + + describe('There must be at least one enabled connector when the specific sign-in method is enabled.', () => { + test('should throw when there is no enabled email connector and email sign-in method is enabled', async () => { + expect(() => { + validateSignInMethods( + { ...mockSignInMethods, email: SignInMethodState.secondary }, + // @ts-expect-error-this-line + enabledConnectorInstances + ); + }).toMatchError( + new RequestError({ + code: 'sign_in_experiences.enabled_connector_not_found', + type: ConnectorType.Email, + }) + ); + }); + + test('should throw when there is no enabled SMS connector and SMS sign-in method is enabled', () => { + expect(() => { + validateSignInMethods( + { ...mockSignInMethods, sms: SignInMethodState.secondary }, + // @ts-expect-error-this-line + enabledConnectorInstances + ); + }).toMatchError( + new RequestError({ + code: 'sign_in_experiences.enabled_connector_not_found', + type: ConnectorType.SMS, + }) + ); + }); + + test('should throw when there is no enabled social connector and social sign-in method is enabled', () => { + expect(() => { + validateSignInMethods({ ...mockSignInMethods, social: SignInMethodState.secondary }, [ + // @ts-expect-error-this-line + mockAliyunDmConnectorInstance, + ]); + }).toMatchError( + new RequestError({ + code: 'sign_in_experiences.enabled_connector_not_found', + type: ConnectorType.Social, + }) + ); + }); + }); }); diff --git a/packages/core/src/lib/sign-in-experience.ts b/packages/core/src/lib/sign-in-experience.ts index 362cd7e84..bc2c69053 100644 --- a/packages/core/src/lib/sign-in-experience.ts +++ b/packages/core/src/lib/sign-in-experience.ts @@ -6,6 +6,7 @@ import { TermsOfUse, } from '@logto/schemas'; +import { ConnectorInstance, ConnectorType } from '@/connectors/types'; import assertThat from '@/utils/assert-that'; export const validateBranding = (branding: Branding) => { @@ -21,12 +22,41 @@ export const validateTermsOfUse = (termsOfUse: TermsOfUse) => { ); }; -export const validateSignInMethods = (signInMethods: SignInMethods) => { +const isEnabled = (state: SignInMethodState) => state !== SignInMethodState.disabled; + +export const validateSignInMethods = ( + signInMethods: SignInMethods, + enabledConnectorInstances: ConnectorInstance[] +) => { const signInMethodStates = Object.values(signInMethods); assertThat( signInMethodStates.filter((state) => state === SignInMethodState.primary).length === 1, 'sign_in_experiences.not_one_and_only_one_primary_sign_in_method' ); - // TODO: assert others next PR + if (isEnabled(signInMethods.email)) { + assertThat( + enabledConnectorInstances.some((item) => item.metadata.type === ConnectorType.Email), + 'sign_in_experiences.enabled_connector_not_found', + { type: ConnectorType.Email } + ); + } + + if (isEnabled(signInMethods.sms)) { + assertThat( + enabledConnectorInstances.some((item) => item.metadata.type === ConnectorType.SMS), + 'sign_in_experiences.enabled_connector_not_found', + { type: ConnectorType.SMS } + ); + } + + if (isEnabled(signInMethods.social)) { + assertThat( + enabledConnectorInstances.some((item) => item.metadata.type === ConnectorType.Social), + 'sign_in_experiences.enabled_connector_not_found', + { type: ConnectorType.Social } + ); + // TODO: assertNonemptySocialConnectorIds + // TODO: assertEnabledSocialConnectorIds + } }; diff --git a/packages/core/src/routes/sign-in-experience.test.ts b/packages/core/src/routes/sign-in-experience.test.ts index 4b677d002..6aa13a974 100644 --- a/packages/core/src/routes/sign-in-experience.test.ts +++ b/packages/core/src/routes/sign-in-experience.test.ts @@ -1,15 +1,33 @@ import { SignInExperience, CreateSignInExperience, TermsOfUse } from '@logto/schemas'; import * as signInExpLib from '@/lib/sign-in-experience'; -import { mockBranding, mockSignInExperience, mockSignInMethods } from '@/utils/mock'; +import { + mockBranding, + mockFacebookConnectorInstance, + mockGithubConnectorInstance, + mockGoogleConnectorInstance, + mockSignInExperience, + mockSignInMethods, +} from '@/utils/mock'; import { createRequester } from '@/utils/test-utils'; import signInExperiencesRoutes from './sign-in-experience'; -jest.mock('@/connectors', () => ({ - ...jest.requireActual('@/connectors'), - getEnabledSocialConnectorIds: jest.fn(async () => ['facebook', 'github']), -})); +const connectorInstances = [ + mockFacebookConnectorInstance, + mockGithubConnectorInstance, + mockGoogleConnectorInstance, +]; + +const getConnectorInstances = jest.fn(async () => connectorInstances); + +jest.mock('@/connectors', () => { + return { + ...jest.requireActual('@/connectors'), + getEnabledSocialConnectorIds: jest.fn(async () => ['facebook', 'github']), + getConnectorInstances: jest.fn(async () => getConnectorInstances()), + }; +}); jest.mock('@/queries/sign-in-experience', () => ({ findDefaultSignInExperience: jest.fn(async (): Promise => mockSignInExperience), @@ -34,7 +52,7 @@ describe('signInExperiences routes', () => { it('PATCH /sign-in-exp', async () => { const termsOfUse: TermsOfUse = { enabled: false }; - const socialSignInConnectorIds = ['abc', 'def']; + const socialSignInConnectorIds = ['github', 'facebook']; const validateBranding = jest.spyOn(signInExpLib, 'validateBranding'); const validateTermsOfUse = jest.spyOn(signInExpLib, 'validateTermsOfUse'); @@ -49,7 +67,10 @@ describe('signInExperiences routes', () => { expect(validateBranding).toHaveBeenCalledWith(mockBranding); expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse); - expect(validateSignInMethods).toHaveBeenCalledWith(mockSignInMethods); + expect(validateSignInMethods).toHaveBeenCalledWith(mockSignInMethods, [ + mockFacebookConnectorInstance, + mockGithubConnectorInstance, + ]); // TODO: only update socialSignInConnectorIds when social sign-in is enabled. expect(response).toMatchObject({ diff --git a/packages/core/src/routes/sign-in-experience.ts b/packages/core/src/routes/sign-in-experience.ts index 3c18d2038..b5c0668fe 100644 --- a/packages/core/src/routes/sign-in-experience.ts +++ b/packages/core/src/routes/sign-in-experience.ts @@ -1,6 +1,6 @@ import { SignInExperiences } from '@logto/schemas'; -import { getEnabledSocialConnectorIds } from '@/connectors'; +import { getConnectorInstances, getEnabledSocialConnectorIds } from '@/connectors'; import { validateBranding, validateTermsOfUse, @@ -57,7 +57,13 @@ export default function signInExperiencesRoutes(router: } if (signInMethods) { - validateSignInMethods(signInMethods); + // TODO: LOG-2055 refactor connectors + const connectorInstances = await getConnectorInstances(); + const enabledConnectorInstances = connectorInstances.filter( + (instance) => instance.connector.enabled + ); + + validateSignInMethods(signInMethods, enabledConnectorInstances); } // TODO: validate socialConnectorIds diff --git a/packages/core/src/utils/mock.ts b/packages/core/src/utils/mock.ts index 48ee24ed0..152cab777 100644 --- a/packages/core/src/utils/mock.ts +++ b/packages/core/src/utils/mock.ts @@ -390,4 +390,52 @@ export const mockSignInMethods: SignInMethods = { sms: SignInMethodState.disabled, social: SignInMethodState.disabled, }; + +export const mockAliyunDmConnectorInstance = { + connector: { + id: 'aliyun-dm', + enabled: true, + config: {}, + createdAt: 1_646_382_233_333, + }, + metadata: { + type: ConnectorType.Email, + }, +}; + +export const mockFacebookConnectorInstance = { + connector: { + id: 'facebook', + enabled: true, + config: {}, + createdAt: 1_646_382_233_333, + }, + metadata: { + type: ConnectorType.Social, + }, +}; + +export const mockGithubConnectorInstance = { + connector: { + id: 'github', + enabled: true, + config: {}, + createdAt: 1_646_382_233_000, + }, + metadata: { + type: ConnectorType.Social, + }, +}; + +export const mockGoogleConnectorInstance = { + connector: { + id: 'google', + enabled: false, + config: {}, + createdAt: 1_646_382_233_000, + }, + metadata: { + type: ConnectorType.Social, + }, +}; /* eslint-enable max-lines */ diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 179c0673e..d4352a11e 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -347,6 +347,7 @@ const errors = { 'Empty "Terms of use" content URL. Please add the content URL if "Terms of use" is enabled.', empty_slogan: 'Empty branding slogan. Please add a branding slogan if a UI style containing the slogan is selected.', + enabled_connector_not_found: 'Enabled {{type}} connector not found.', not_one_and_only_one_primary_sign_in_method: 'There must be one and only one primary sign-in method. Please check your input.', }, diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 3d8de5093..7fa7e073a 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -344,7 +344,8 @@ const errors = { empty_content_url_of_terms_of_use: '空的《用户协议》内容链接。当启用《用户协议》时,请添加其内容链接。', empty_slogan: '空的标语。当使用包含标语的 UI 风格时,请添加标语。', - not_one_and_only_one_primary_sign_in_method: '主要的登录方式必须有且仅有一个。请检查你的输入。', + enabled_connector_not_found: '未找到可用的 {{type}} 类型的连接器。', + not_one_and_only_one_primary_sign_in_method: '主要的登录方式必须有且仅有一个,请检查你的输入。', }, swagger: { invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。',