0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(core,phrases): validate sign-in methods with enabled connectors (#480)

This commit is contained in:
IceHe.xyz 2022-04-01 16:32:45 +08:00 committed by GitHub
parent 039f3d0cbb
commit efdb24833e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 179 additions and 23 deletions

View file

@ -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,
})
);
});
});
});

View file

@ -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
}
};

View file

@ -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<SignInExperience> => 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({

View file

@ -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<T extends AuthedRouter>(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

View file

@ -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 */

View file

@ -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.',
},

View file

@ -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 配置。',