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:
parent
2c1610d665
commit
a4cb138c12
17 changed files with 154 additions and 20 deletions
|
@ -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",
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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/;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
6
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue