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

refactor(core,phrases,schemas): validate fallback language before updating sign-in experience (#2023)

This commit is contained in:
IceHe 2022-09-29 15:28:58 +08:00 committed by GitHub
parent 2c1610d665
commit a4cb138c12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 154 additions and 20 deletions

View file

@ -23,8 +23,8 @@
},
"dependencies": {
"@logto/connector-kit": "^1.0.0-beta.13",
"@logto/core-kit": "1.0.0-beta.16",
"@logto/language-kit": "1.0.0-beta.16",
"@logto/core-kit": "^1.0.0-beta.16",
"@logto/language-kit": "^1.0.0-beta.16",
"@logto/phrases": "^1.0.0-beta.9",
"@logto/phrases-ui": "^1.0.0-beta.9",
"@logto/schemas": "^1.0.0-beta.9",

View file

@ -1,3 +1,5 @@
import { LanguageTag } from '@logto/language-kit';
import { builtInLanguages } from '@logto/phrases-ui';
import { BrandingStyle, SignInMethodState, ConnectorType } from '@logto/schemas';
import {
@ -11,12 +13,24 @@ import RequestError from '@/errors/RequestError';
import {
isEnabled,
validateBranding,
validateLanguageInfo,
validateSignInMethods,
validateTermsOfUse,
} from '@/lib/sign-in-experience';
const enabledConnectors = [mockFacebookConnector, mockGithubConnector];
const allCustomLanguageKeys: LanguageTag[] = [];
const findAllCustomLanguageKeys = jest.fn(async () => allCustomLanguageKeys);
jest.mock('@/queries/custom-phrase', () => ({
findAllCustomLanguageKeys: async () => findAllCustomLanguageKeys(),
}));
beforeEach(() => {
jest.clearAllMocks();
});
describe('validate branding', () => {
test('should throw when the UI style contains the slogan and slogan is empty', () => {
expect(() => {
@ -59,6 +73,61 @@ describe('validate branding', () => {
});
});
describe('validate language info', () => {
it('should call findAllCustomLanguageKeys', async () => {
await validateLanguageInfo({
autoDetect: true,
fallbackLanguage: 'zh-CN',
fixedLanguage: 'en',
});
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
});
it('should pass when the language is built-in supported', async () => {
const builtInSupportedLanguage = 'tr-TR';
await expect(
validateLanguageInfo({
autoDetect: true,
fallbackLanguage: builtInSupportedLanguage,
fixedLanguage: 'en',
})
).resolves.not.toThrow();
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
});
it('should pass when the language is custom supported', async () => {
const customOnlySupportedLanguage = 'zh-HK';
expect(customOnlySupportedLanguage in builtInLanguages).toBeFalsy();
findAllCustomLanguageKeys.mockResolvedValueOnce([customOnlySupportedLanguage]);
await expect(
validateLanguageInfo({
autoDetect: true,
fallbackLanguage: customOnlySupportedLanguage,
fixedLanguage: 'en',
})
).resolves.not.toThrow();
expect(findAllCustomLanguageKeys).toBeCalledTimes(1);
});
it('unsupported fallback language should fail', async () => {
const unsupportedLanguage = 'zh-MO';
expect(unsupportedLanguage in builtInLanguages).toBeFalsy();
expect(allCustomLanguageKeys.includes(unsupportedLanguage)).toBeFalsy();
await expect(
validateLanguageInfo({
autoDetect: true,
fallbackLanguage: unsupportedLanguage,
fixedLanguage: 'en',
})
).rejects.toMatchError(
new RequestError({
code: 'sign_in_experiences.unsupported_default_language',
language: unsupportedLanguage,
})
);
});
});
describe('validate terms of use', () => {
test('should throw when terms of use is enabled and content URL is empty', () => {
expect(() => {

View file

@ -1,6 +1,8 @@
import { builtInLanguages } from '@logto/phrases-ui';
import {
Branding,
BrandingStyle,
LanguageInfo,
SignInMethods,
SignInMethodState,
TermsOfUse,
@ -9,6 +11,7 @@ import { Optional } from '@silverhand/essentials';
import { ConnectorType, LogtoConnector } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { findAllCustomLanguageKeys } from '@/queries/custom-phrase';
import assertThat from '@/utils/assert-that';
export const validateBranding = (branding: Branding) => {
@ -19,6 +22,18 @@ export const validateBranding = (branding: Branding) => {
assertThat(branding.logoUrl.trim(), 'sign_in_experiences.empty_logo');
};
export const validateLanguageInfo = async (languageInfo: LanguageInfo) => {
const supportedLanguages = [...builtInLanguages, ...(await findAllCustomLanguageKeys())];
assertThat(
supportedLanguages.includes(languageInfo.fallbackLanguage),
new RequestError({
code: 'sign_in_experiences.unsupported_default_language',
language: languageInfo.fallbackLanguage,
})
);
};
export const validateTermsOfUse = (termsOfUse: TermsOfUse) => {
assertThat(
!termsOfUse.enabled || termsOfUse.contentUrl,

View file

@ -1,5 +1,9 @@
import { languageKeys } from '@logto/core-kit';
import { CreateSignInExperience, SignInExperience, SignInMethodState } from '@logto/schemas';
import {
CreateSignInExperience,
LanguageInfo,
SignInExperience,
SignInMethodState,
} from '@logto/schemas';
import {
mockAliyunDmConnector,
@ -26,6 +30,14 @@ jest.mock('@/connectors', () => ({
]),
}));
// eslint-disable-next-line @typescript-eslint/no-empty-function
const validateLanguageInfo = jest.fn(async (languageInfo: LanguageInfo): Promise<void> => {});
jest.mock('@/lib/sign-in-experience', () => ({
...jest.requireActual('@/lib/sign-in-experience'),
validateLanguageInfo: async (languageInfo: LanguageInfo) => validateLanguageInfo(languageInfo),
}));
jest.mock('@/queries/sign-in-experience', () => ({
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
@ -48,6 +60,10 @@ const expectPatchResponseStatus = async (
const validBooleans = [true, false];
const invalidBooleans = [undefined, null, 0, 1, '0', '1', 'true', 'false'];
beforeEach(() => {
jest.clearAllMocks();
});
describe('terms of use', () => {
describe('enabled', () => {
test.each(validBooleans)('%p should success', async (enabled) => {
@ -104,8 +120,8 @@ describe('languageInfo', () => {
});
});
const validLanguages = languageKeys;
const invalidLanguages = [undefined, null, '', ' \t\n\r', 'abc'];
const validLanguages = ['en', 'pt-PT', 'zh-HK', 'zh-TW'];
const invalidLanguages = [undefined, null, '', ' \t\n\r', 'ab', 'xx-XX'];
describe('fallbackLanguage', () => {
test.each(validLanguages)('%p should success', async (fallbackLanguage) => {
@ -119,16 +135,10 @@ describe('languageInfo', () => {
});
});
describe('fixedLanguage', () => {
test.each(validLanguages)('%p should success', async (fixedLanguage) => {
const signInExperience = { languageInfo: { ...mockLanguageInfo, fixedLanguage } };
await expectPatchResponseStatus(signInExperience, 200);
});
test.each(invalidLanguages)('%p should fail', async (fixedLanguage) => {
const signInExperience = { languageInfo: { ...mockLanguageInfo, fixedLanguage } };
await expectPatchResponseStatus(signInExperience, 400);
});
it('should call validateLanguageInfo', async () => {
const signInExperience = { languageInfo: mockLanguageInfo };
await expectPatchResponseStatus(signInExperience, 200);
expect(validateLanguageInfo).toBeCalledWith(mockLanguageInfo);
});
});

View file

@ -14,6 +14,7 @@ import {
mockSignInMethods,
mockWechatConnector,
mockColor,
mockLanguageInfo,
} from '@/__mocks__';
import * as signInExpLib from '@/lib/sign-in-experience';
import { createRequester } from '@/utils/test-utils';
@ -50,6 +51,10 @@ jest.mock('@/queries/sign-in-experience', () => ({
const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes });
jest.mock('@/queries/custom-phrase', () => ({
findAllCustomLanguageKeys: async () => [],
}));
describe('GET /sign-in-exp', () => {
afterAll(() => {
jest.clearAllMocks();
@ -138,18 +143,21 @@ describe('PATCH /sign-in-exp', () => {
const socialSignInConnectorTargets = ['github', 'facebook', 'wechat'];
const validateBranding = jest.spyOn(signInExpLib, 'validateBranding');
const validateLanguageInfo = jest.spyOn(signInExpLib, 'validateLanguageInfo');
const validateTermsOfUse = jest.spyOn(signInExpLib, 'validateTermsOfUse');
const validateSignInMethods = jest.spyOn(signInExpLib, 'validateSignInMethods');
const response = await signInExperienceRequester.patch('/sign-in-exp').send({
color: mockColor,
branding: mockBranding,
languageInfo: mockLanguageInfo,
termsOfUse,
signInMethods: mockSignInMethods,
socialSignInConnectorTargets,
});
expect(validateBranding).toHaveBeenCalledWith(mockBranding);
expect(validateLanguageInfo).toHaveBeenCalledWith(mockLanguageInfo);
expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse);
expect(validateSignInMethods).toHaveBeenCalledWith(
mockSignInMethods,

View file

@ -3,6 +3,7 @@ import { ConnectorType, SignInExperiences } from '@logto/schemas';
import { getLogtoConnectors } from '@/connectors';
import {
validateBranding,
validateLanguageInfo,
validateTermsOfUse,
validateSignInMethods,
isEnabled,
@ -33,12 +34,16 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(router:
}),
async (ctx, next) => {
const { socialSignInConnectorTargets, ...rest } = ctx.guard.body;
const { branding, termsOfUse, signInMethods } = rest;
const { branding, languageInfo, termsOfUse, signInMethods } = rest;
if (branding) {
validateBranding(branding);
}
if (languageInfo) {
await validateLanguageInfo(languageInfo);
}
if (termsOfUse) {
validateTermsOfUse(termsOfUse);
}

View file

@ -1,3 +1,4 @@
import { languages, languageTagGuard } from '@logto/language-kit';
import { ApplicationType, arbitraryObjectGuard, translationGuard } from '@logto/schemas';
import { string, boolean, number, object, nativeEnum, unknown, literal, union } from 'zod';
@ -19,6 +20,13 @@ describe('zodTypeToSwagger', () => {
});
});
it('language tag guard', () => {
expect(zodTypeToSwagger(languageTagGuard)).toEqual({
type: 'string',
enum: Object.keys(languages),
});
});
describe('string type', () => {
const notStartingWithDigitRegex = /^\D/;

View file

@ -1,3 +1,4 @@
import { languages, languageTagGuard } from '@logto/language-kit';
import { arbitraryObjectGuard, translationGuard } from '@logto/schemas';
import { conditional, ValuesOf } from '@silverhand/essentials';
import { OpenAPIV3 } from 'openapi-types';
@ -140,6 +141,13 @@ export const zodTypeToSwagger = (
};
}
if (config === languageTagGuard) {
return {
type: 'string',
enum: Object.keys(languages),
};
}
if (config instanceof ZodOptional) {
return zodTypeToSwagger(config._def.innerType);
}

View file

@ -105,6 +105,7 @@ const errors = {
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.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:

View file

@ -113,6 +113,7 @@ const errors = {
enabled_connector_not_found: 'Le connecteur {{type}} activé est introuvable.',
not_one_and_only_one_primary_sign_in_method:
'Il doit y avoir une et une seule méthode de connexion primaire. Veuillez vérifier votre saisie.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:

View file

@ -102,6 +102,7 @@ const errors = {
enabled_connector_not_found: '활성된 {{type}} 연동을 찾을 수 없어요.',
not_one_and_only_one_primary_sign_in_method:
'반드시 하나의 메인 로그인 방법이 설정되어야 해요. 입력된 값을 확인해주세요.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:

View file

@ -108,6 +108,7 @@ const errors = {
enabled_connector_not_found: 'Conector {{type}} ativado não encontrado.',
not_one_and_only_one_primary_sign_in_method:
'Deve haver um e apenas um método de login principal. Por favor, verifique sua entrada.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:

View file

@ -106,6 +106,7 @@ const errors = {
enabled_connector_not_found: 'Etkin {{type}} bağlayıcı bulunamadı.',
not_one_and_only_one_primary_sign_in_method:
'Yalnızca bir tane birincil oturum açma yöntemi olmalıdır. Lütfen inputu kontrol ediniz.',
unsupported_default_language: 'Default language {{language}} is unsupported.', // UNTRANSLATED
},
localization: {
cannot_delete_default_language:

View file

@ -98,6 +98,7 @@ const errors = {
empty_social_connectors: '你启用了社交登录的方式。请至少选择一个社交连接器。',
enabled_connector_not_found: '未找到已启用的 {{type}} 连接器',
not_one_and_only_one_primary_sign_in_method: '主要的登录方式必须有且仅有一个,请检查你的输入。',
unsupported_default_language: '不支持默认语言 {{language}}。', // UNTRANSLATED
},
localization: {
cannot_delete_default_language: '不能删除「登录体验」正在使用的默认语言 {{languageKey}}。', // UNTRANSLATED

View file

@ -53,6 +53,7 @@
"dependencies": {
"@logto/connector-kit": "^1.0.0-beta.13",
"@logto/core-kit": "^1.0.0-beta.13",
"@logto/language-kit": "^1.0.0-beta.16",
"@logto/phrases": "^1.0.0-beta.9",
"@logto/phrases-ui": "^1.0.0-beta.9",
"zod": "^3.18.0"

View file

@ -1,4 +1,5 @@
import { hexColorRegEx, languageKeys } from '@logto/core-kit';
import { languageTagGuard } from '@logto/language-kit';
import { z } from 'zod';
/**
@ -102,7 +103,8 @@ export type TermsOfUse = z.infer<typeof termsOfUseGuard>;
export const languageInfoGuard = z.object({
autoDetect: z.boolean(),
fallbackLanguage: z.enum(languageKeys),
fallbackLanguage: languageTagGuard,
/** @deprecated */
fixedLanguage: z.enum(languageKeys),
});

6
pnpm-lock.yaml generated
View file

@ -155,8 +155,8 @@ importers:
packages/core:
specifiers:
'@logto/connector-kit': ^1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.16
'@logto/language-kit': 1.0.0-beta.16
'@logto/core-kit': ^1.0.0-beta.16
'@logto/language-kit': ^1.0.0-beta.16
'@logto/phrases': ^1.0.0-beta.9
'@logto/phrases-ui': ^1.0.0-beta.9
'@logto/schemas': ^1.0.0-beta.9
@ -471,6 +471,7 @@ importers:
specifiers:
'@logto/connector-kit': ^1.0.0-beta.13
'@logto/core-kit': ^1.0.0-beta.13
'@logto/language-kit': ^1.0.0-beta.16
'@logto/phrases': ^1.0.0-beta.9
'@logto/phrases-ui': ^1.0.0-beta.9
'@silverhand/eslint-config': 1.0.0
@ -495,6 +496,7 @@ importers:
dependencies:
'@logto/connector-kit': 1.0.0-beta.13
'@logto/core-kit': 1.0.0-beta.13
'@logto/language-kit': 1.0.0-beta.16
'@logto/phrases': link:../phrases
'@logto/phrases-ui': link:../phrases-ui
zod: 3.18.0