diff --git a/src/context/directory/handlers/prompts.ts b/src/context/directory/handlers/prompts.ts index 12fee4eae..351364db5 100644 --- a/src/context/directory/handlers/prompts.ts +++ b/src/context/directory/handlers/prompts.ts @@ -1,29 +1,33 @@ import path from 'path'; -import { ensureDirSync } from 'fs-extra'; +import { ensureDirSync, readFileSync, writeFileSync } from 'fs-extra'; import { constants } from '../../../tools'; -import { existsMustBeDir, dumpJSON, loadJSON, isFile } from '../../../utils'; +import { dumpJSON, existsMustBeDir, isFile, loadJSON } from '../../../utils'; import { DirectoryHandler } from '.'; import DirectoryContext from '..'; import { ParsedAsset } from '../../../types'; import { + AllPromptsByLanguage, + CustomPartialsConfig, + CustomPartialsInsertionPoints, + CustomPartialsPromptTypes, + CustomPartialsScreenTypes, + CustomPromptPartialsScreens, Prompts, PromptSettings, - AllPromptsByLanguage, + ScreenConfig, } from '../../../tools/auth0/handlers/prompts'; type ParsedPrompts = ParsedAsset<'prompts', Prompts>; -const getPromptsDirectory = (filePath: string) => { - return path.join(filePath, constants.PROMPTS_DIRECTORY); -}; +const getPromptsDirectory = (filePath: string) => path.join(filePath, constants.PROMPTS_DIRECTORY); -const getPromptsSettingsFile = (promptsDirectory: string) => { - return path.join(promptsDirectory, 'prompts.json'); -}; +const getPromptsSettingsFile = (promptsDirectory: string) => + path.join(promptsDirectory, 'prompts.json'); -const getCustomTextFile = (promptsDirectory: string) => { - return path.join(promptsDirectory, 'custom-text.json'); -}; +const getCustomTextFile = (promptsDirectory: string) => + path.join(promptsDirectory, 'custom-text.json'); + +const getPartialsFile = (promptsDirectory: string) => path.join(promptsDirectory, 'partials.json'); function parse(context: DirectoryContext): ParsedPrompts { const promptsDirectory = getPromptsDirectory(context.filePath); @@ -47,10 +51,41 @@ function parse(context: DirectoryContext): ParsedPrompts { }) as AllPromptsByLanguage; })(); + const partials = (() => { + const partialsFile = getPartialsFile(promptsDirectory); + if (!isFile(partialsFile)) return {}; + const partialsFileContent = loadJSON(partialsFile, { + mappings: context.mappings, + disableKeywordReplacement: context.disableKeywordReplacement, + }) as CustomPartialsConfig; + + return Object.entries(partialsFileContent).reduce((acc, [promptName, screensArray]) => { + const screensObject = screensArray[0] as Record; + acc[promptName as CustomPartialsPromptTypes] = Object.entries(screensObject).reduce( + (screenAcc, [screenName, items]) => { + screenAcc[screenName as CustomPartialsScreenTypes] = items.reduce( + (insertionAcc, { name, template }) => { + const templateFilePath = path.join(promptsDirectory, template); + insertionAcc[name] = isFile(templateFilePath) + ? readFileSync(templateFilePath, 'utf8').trim() + : ''; + return insertionAcc; + }, + {} as Record + ); + return screenAcc; + }, + {} as Record> + ); + return acc; + }, {} as Record>>); + })(); + return { prompts: { ...promptsSettings, customText, + partials, }, }; } @@ -60,7 +95,7 @@ async function dump(context: DirectoryContext): Promise { if (!prompts) return; - const { customText, ...promptsSettings } = prompts; + const { customText, partials, ...promptsSettings } = prompts; const promptsDirectory = getPromptsDirectory(context.filePath); ensureDirSync(promptsDirectory); @@ -72,6 +107,40 @@ async function dump(context: DirectoryContext): Promise { if (!customText) return; const customTextFile = getCustomTextFile(promptsDirectory); dumpJSON(customTextFile, customText); + + if (!partials) return; + const partialsFile = getPartialsFile(promptsDirectory); + + const transformedPartials = Object.entries(partials).reduce((acc, [promptName, screens]) => { + acc[promptName as CustomPartialsPromptTypes] = [ + Object.entries(screens as CustomPromptPartialsScreens).reduce( + (screenAcc, [screenName, insertionPoints]) => { + screenAcc[screenName as CustomPartialsScreenTypes] = Object.entries( + insertionPoints as Partial> + ).map(([insertionPoint, template]) => { + const templateFilePath = path.join( + promptsDirectory, + 'partials', + promptName, + screenName, + `${insertionPoint}.liquid` + ); + ensureDirSync(path.dirname(templateFilePath)); + writeFileSync(templateFilePath, template, 'utf8'); + return { + name: insertionPoint, + template: path.relative(promptsDirectory, templateFilePath), // Path relative to `promptsDirectory` + }; + }); + return screenAcc; + }, + {} as Record + ), + ]; + return acc; + }, {} as CustomPartialsConfig); + + dumpJSON(partialsFile, transformedPartials); } const promptsHandler: DirectoryHandler = { diff --git a/src/tools/auth0/handlers/prompts.ts b/src/tools/auth0/handlers/prompts.ts index b98ce7335..9c56ba1d6 100644 --- a/src/tools/auth0/handlers/prompts.ts +++ b/src/tools/auth0/handlers/prompts.ts @@ -1,6 +1,7 @@ -import DefaultHandler from './default'; -import { Assets, Language, languages } from '../../../types'; import { isEmpty } from 'lodash'; +import DefaultHandler from './default'; +import { Asset, Assets, Language, languages } from '../../../types'; +import log from '../../../logger'; const promptTypes = [ 'login', @@ -93,6 +94,62 @@ const screenTypes = [ export type ScreenTypes = typeof screenTypes[number]; +const customPartialsPromptTypes = [ + 'login', + 'login-id', + 'login-password', + 'signup', + 'signup-id', + 'signup-password', +]; + +export type CustomPartialsPromptTypes = typeof customPartialsPromptTypes[number]; + +const customPartialsScreenTypes = [ + 'login', + 'login-id', + 'login-password', + 'signup', + 'signup-id', + 'signup-password', +] as const; + +export type CustomPartialsScreenTypes = typeof customPartialsPromptTypes[number]; + +const customPartialsInsertionPoints = [ + 'form-content-start', + 'form-content-end', + 'form-footer-start', + 'form-footer-end', + 'secondary-actions-start', + 'secondary-actions-end', +] as const; + +export type CustomPartialsInsertionPoints = typeof customPartialsInsertionPoints[number]; + +export type CustomPromptPartialsScreens = Partial<{ + [screen in CustomPartialsScreenTypes]: Partial<{ + [insertionPoint in CustomPartialsInsertionPoints]: string; + }>; +}>; + +export type CustomPromptPartials = Partial<{ + [prompt in CustomPartialsPromptTypes]: CustomPromptPartialsScreens; +}>; + +export interface ScreenConfig { + name: string; + template: string; +} + +export type CustomPartialsConfig = { + [prompt in CustomPartialsPromptTypes]: [ + { + [screen in CustomPartialsScreenTypes]: ScreenConfig[]; + } + ]; +}; + export const schema = { type: 'object', properties: { @@ -113,15 +170,15 @@ export const schema = { ...acc, [language]: { type: 'object', - properties: promptTypes.reduce((acc, promptTypes) => { + properties: promptTypes.reduce((promptAcc, promptType) => { return { - ...acc, - [promptTypes]: { + ...promptAcc, + [promptType]: { type: 'object', - properties: screenTypes.reduce((acc, screenTypes) => { + properties: screenTypes.reduce((screenAcc, screenType) => { return { - ...acc, - [screenTypes]: { + ...screenAcc, + [screenType]: { type: 'object', }, }; @@ -133,6 +190,43 @@ export const schema = { }; }, {}), }, + partials: { + type: 'object', + properties: customPartialsPromptTypes.reduce((acc, customPartialsPromptType) => { + return { + ...acc, + [customPartialsPromptType]: { + oneOf: [ + { + type: 'object', + properties: customPartialsScreenTypes.reduce((screenAcc, customPartialsScreenType) => { + return { + ...screenAcc, + [customPartialsScreenType]: { + oneOf: [ + { + type: 'object', + properties: customPartialsInsertionPoints.reduce((insertionAcc, customPartialsInsertionPoint) => { + return { + ...insertionAcc, + [customPartialsInsertionPoint]: { + type: 'string', + }, + }; + }, {}), + }, + { type: 'null' } + ], + }, + }; + }, {}), + }, + { type: 'null' } + ], + }, + }; + }, {}), + }, }, }; @@ -150,19 +244,36 @@ export type PromptsCustomText = { }>; }; +export type AllPromptsByLanguage = Partial<{ + [key in Language]: Partial; +}>; + export type Prompts = Partial< PromptSettings & { customText: AllPromptsByLanguage; + partials: CustomPromptPartials; } >; -export type AllPromptsByLanguage = Partial<{ - [key in Language]: Partial; -}>; - export default class PromptsHandler extends DefaultHandler { existing: Prompts; + private IsFeatureSupported: boolean = true; + + private promptClient = this.client.prompts._getRestClient('/prompts/:prompt/partials'); + + private async partialHttpRequest( + method: string, + options: [{ prompt: string }, ...Record[]] + ): Promise { + return this.withErrorHandling(async () => { + if (method === 'put') { + return this.promptClient.invoke('wrappedProvider', [method, options]); + } + return this.promptClient[method](...options); + }); + } + constructor(options: DefaultHandler) { super({ ...options, @@ -171,7 +282,7 @@ export default class PromptsHandler extends DefaultHandler { } objString({ customText }: Prompts): string { - return `Prompts settings${!!customText ? ' and prompts custom text' : ''}`; + return `Prompts settings${customText ? ' and prompts custom text' : ''}`; } async getType(): Promise { @@ -179,9 +290,12 @@ export default class PromptsHandler extends DefaultHandler { const customText = await this.getCustomTextSettings(); + const partials = await this.getCustomPromptsPartials(); + return { ...promptsSettings, customText, + partials, }; } @@ -216,11 +330,9 @@ export default class PromptsHandler extends DefaultHandler { }), }) .promise() - .then((customTextData) => { - return customTextData - .filter((customTextData) => { - return customTextData !== null; - }) + .then((customTextData) => + customTextData + .filter((customTextData) => customTextData !== null) .reduce((acc: AllPromptsByLanguage, customTextItem) => { if (customTextItem?.language === undefined) return acc; @@ -228,12 +340,87 @@ export default class PromptsHandler extends DefaultHandler { return { ...acc, - [language]: !!acc[language] + [language]: acc[language] ? { ...acc[language], ...customTextSettings } : { ...customTextSettings }, }; - }, {}); - }); + }, {}) + ); + } + + /** + * Error handler wrapper. + */ + async withErrorHandling(callback) { + try { + return await callback(); + } catch (error) { + // Extract error data + if (error && error?.statusCode === 403) { + log.warn('Partial Prompts feature is not supported for the tenant'); + this.IsFeatureSupported = false; + return null; + } + + if ( + error && + error?.statusCode === 400 && + error.message?.includes('feature requires at least one custom domain') + ) { + log.warn( + 'Partial Prompts feature requires at least one custom domain to be configured for the tenant' + ); + this.IsFeatureSupported = false; + return null; + } + + if (error && error.statusCode === 429) { + log.error( + `The global rate limit has been exceeded, resulting in a ${error.statusCode} error. ${error.message}. Although this is an error, it is not blocking the pipeline.` + ); + return null; + } + + throw error; + } + } + + async getCustomPartial({ + prompt, + }: { + prompt: CustomPartialsPromptTypes; + }): Promise { + if (!this.IsFeatureSupported) return {}; + return this.partialHttpRequest('get', [{ prompt: prompt }]); // Implement this method for making HTTP requests + } + + async getCustomPromptsPartials(): Promise { + const partialsDataWithNulls = await this.client.pool + .addEachTask({ + data: customPartialsPromptTypes, + generator: (promptType) => + this.getCustomPartial({ + prompt: promptType, + }).then((partialsData: CustomPromptPartials) => { + if (isEmpty(partialsData)) return null; + return { promptType, partialsData }; + }), + }) + .promise(); + const validPartialsData = partialsDataWithNulls.filter(Boolean); + return validPartialsData.reduce( + ( + acc: CustomPromptPartials, + partialData: { promptType: string; partialsData: CustomPromptPartials } + ) => { + if (partialData) { + const { promptType, partialsData } = partialData; + acc[promptType] = partialsData; + } + return acc; + }, + {} + ); } async processChanges(assets: Assets): Promise { @@ -241,13 +428,14 @@ export default class PromptsHandler extends DefaultHandler { if (!prompts) return; - const { customText, ...promptSettings } = prompts; + const { partials, customText, ...promptSettings } = prompts; if (!isEmpty(promptSettings)) { await this.client.prompts.updateSettings({}, promptSettings); } await this.updateCustomTextSettings(customText); + await this.updateCustomPromptsPartials(partials); this.updated += 1; this.didUpdate(prompts); @@ -284,4 +472,34 @@ export default class PromptsHandler extends DefaultHandler { }) .promise(); } + + async updateCustomPartials({ + prompt, + body, + }: { + prompt: CustomPartialsPromptTypes; + body: CustomPromptPartialsScreens; + }): Promise { + if (!this.IsFeatureSupported) return; + await this.partialHttpRequest('put', [{ prompt: prompt }, body]); // Implement this method for making HTTP requests + } + + async updateCustomPromptsPartials(partials: Prompts['partials']): Promise { + /* + Note: deletes are not currently supported + */ + if (!partials) return; + await this.client.pool + .addEachTask({ + data: Object.keys(partials).map((prompt: CustomPartialsPromptTypes) => { + const body = partials[prompt] || {}; + return { + body, + prompt, + }; + }), + generator: ({ prompt, body }) => this.updateCustomPartials({ prompt, body }), + }) + .promise(); + } } diff --git a/src/tools/constants.ts b/src/tools/constants.ts index 4506b46fd..357e2fc91 100644 --- a/src/tools/constants.ts +++ b/src/tools/constants.ts @@ -169,6 +169,7 @@ const constants = { SUPPORTED_BRANDING_TEMPLATES: [UNIVERSAL_LOGIN_TEMPLATE], LOG_STREAMS_DIRECTORY: 'log-streams', PROMPTS_DIRECTORY: 'prompts', + PARTIALS_DIRECTORY: 'partials', CUSTOM_DOMAINS_DIRECTORY: 'custom-domains', THEMES_DIRECTORY: 'themes', }; diff --git a/src/types.ts b/src/types.ts index 8fe333ee1..8fb49014c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import { PromisePoolExecutor } from 'promise-pool-executor'; import { Action } from './tools/auth0/handlers/actions'; import { PromptTypes, @@ -13,7 +14,6 @@ import { LogStream } from './tools/auth0/handlers/logStreams'; import { Client } from './tools/auth0/handlers/clients'; import { ClientGrant } from './tools/auth0/handlers/clientGrants'; import { ResourceServer } from './tools/auth0/handlers/resourceServers'; -import { PromisePoolExecutor } from 'promise-pool-executor'; type SharedPaginationParams = { checkpoint?: boolean; @@ -66,15 +66,15 @@ export type BaseAuth0APIClient = { getBreachedPasswordDetectionConfig: () => Promise; getBruteForceConfig: () => Promise; getSuspiciousIpThrottlingConfig: () => Promise; - updateBreachedPasswordDetectionConfig: ({}, arg1: Asset) => Promise; - updateSuspiciousIpThrottlingConfig: ({}, arg1: Asset) => Promise; - updateBruteForceConfig: ({}, arg1: Asset) => Promise; + updateBreachedPasswordDetectionConfig: ({ }, arg1: Asset) => Promise; + updateSuspiciousIpThrottlingConfig: ({ }, arg1: Asset) => Promise; + updateBruteForceConfig: ({ }, arg1: Asset) => Promise; }; branding: APIClientBaseFunctions & { getSettings: () => Promise; getUniversalLoginTemplate: () => Promise; - updateSettings: ({}, Asset) => Promise; - setUniversalLoginTemplate: ({}, Asset) => Promise; + updateSettings: ({ }, Asset) => Promise; + setUniversalLoginTemplate: ({ }, Asset) => Promise; getDefaultTheme: () => Promise; updateTheme: ( arg0: { id: string }, @@ -150,6 +150,10 @@ export type BaseAuth0APIClient = { }; }; prompts: { + _getRestClient: (arg0: string) => { + get: (arg0: string) => Promise; + invoke: (arg0: string, arg1: any) => Promise; + }; updateCustomTextByLanguage: (arg0: { prompt: PromptTypes; language: Language; @@ -232,10 +236,10 @@ export type Assets = Partial<{ actions: Action[] | null; attackProtection: Asset | null; branding: - | (Asset & { - templates?: { template: string; body: string }[] | null; - }) - | null; + | (Asset & { + templates?: { template: string; body: string }[] | null; + }) + | null; clients: Client[] | null; clientGrants: ClientGrant[] | null; connections: Asset[] | null; diff --git a/test/context/directory/context.test.js b/test/context/directory/context.test.js index 8d9603cf9..8da928db2 100644 --- a/test/context/directory/context.test.js +++ b/test/context/directory/context.test.js @@ -165,6 +165,11 @@ describe('#directory context validation', () => { }) ), }, + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, actions: { getSettings: async () => new Promise((res) => res([])), }, diff --git a/test/context/directory/prompts.test.ts b/test/context/directory/prompts.test.ts index 295b8de38..d11ec9455 100644 --- a/test/context/directory/prompts.test.ts +++ b/test/context/directory/prompts.test.ts @@ -5,13 +5,20 @@ import { constants } from '../../../src/tools'; import Context from '../../../src/context/directory'; import promptsHandler from '../../../src/context/directory/handlers/prompts'; import { getFiles, loadJSON } from '../../../src/utils'; -import { cleanThenMkdir, testDataDir, createDir, mockMgmtClient } from '../../utils'; +import { + cleanThenMkdir, + testDataDir, + createDir, + mockMgmtClient, + createDirWithNestedDir, +} from '../../utils'; const dir = path.join(testDataDir, 'directory', 'promptsDump'); const promptsDirectory = path.join(dir, constants.PROMPTS_DIRECTORY); const promptsSettingsFile = 'prompts.json'; const customTextFile = 'custom-text.json'; +const partialsFile = 'partials.json'; describe('#directory context prompts', () => { it('should parse prompts', async () => { @@ -45,12 +52,55 @@ describe('#directory context prompts', () => { }, }, }), + [partialsFile]: JSON.stringify({ + login: [ + { + login: [ + { + name: 'form-content-start', + template: 'partials/login/login/form-content-start.liquid', + }, + ], + }, + ], + signup: [ + { + signup: [ + { + name: 'form-content-end', + template: 'partials/signup/signup/form-content-end.liquid', + }, + ], + }, + ], + }), }, }; const repoDir = path.join(testDataDir, 'directory', 'prompts'); createDir(repoDir, files); + const partialsDir = path.join( + repoDir, + constants.PROMPTS_DIRECTORY, + constants.PARTIALS_DIRECTORY + ); + + const partialsFiles = { + login: { + login: { + 'form-content-start.liquid': '
TEST
', + }, + }, + signup: { + signup: { + 'form-content-end.liquid': '
TEST AGAIN
', + }, + }, + }; + + createDirWithNestedDir(partialsDir, partialsFiles); + const config = { AUTH0_INPUT_FILE: repoDir, AUTH0_KEYWORD_REPLACE_MAPPINGS: { @@ -60,10 +110,21 @@ describe('#directory context prompts', () => { }; const context = new Context(config, mockMgmtClient()); await context.loadAssetsFromLocal(); - expect(context.assets.prompts).to.deep.equal({ universal_login_experience: 'classic', identifier_first: true, + partials: { + login: { + login: { + 'form-content-start': '
TEST
', + }, + }, + signup: { + signup: { + 'form-content-end': '
TEST AGAIN
', + }, + }, + }, customText: { en: { login: { @@ -92,7 +153,7 @@ describe('#directory context prompts', () => { }); describe('should parse prompts even if one or both files are absent', async () => { - it('should parse even if custom text file is absent', async () => { + it('should parse prompts even if one or more files are absent', async () => { cleanThenMkdir(promptsDirectory); const mockPromptsSettings = { universal_login_experience: 'classic', @@ -101,18 +162,50 @@ describe('#directory context prompts', () => { const promptsDirectoryNoCustomTextFile = { [constants.PROMPTS_DIRECTORY]: { [promptsSettingsFile]: JSON.stringify(mockPromptsSettings), + [partialsFile]: JSON.stringify({}), }, }; createDir(promptsDirectory, promptsDirectoryNoCustomTextFile); + const config = { + AUTH0_INPUT_FILE: promptsDirectory, + }; + const context = new Context(config, mockMgmtClient()); + await context.loadAssetsFromLocal(); + expect(context.assets.prompts).to.deep.equal({ + ...mockPromptsSettings, + customText: {}, + partials: {}, + }); + }); + + it('should parse even if custom prompts file is absent', async () => { + cleanThenMkdir(promptsDirectory); + const mockPromptsSettings = { + universal_login_experience: 'classic', + identifier_first: true, + }; + const promptsDirectoryNoPartialsFile = { + [constants.PROMPTS_DIRECTORY]: { + [promptsSettingsFile]: JSON.stringify(mockPromptsSettings), + [customTextFile]: JSON.stringify({}), + }, + }; + + createDir(promptsDirectory, promptsDirectoryNoPartialsFile); + const config = { AUTH0_INPUT_FILE: promptsDirectory, }; const context = new Context(config, mockMgmtClient()); await context.loadAssetsFromLocal(); - expect(context.assets.prompts).to.deep.equal({ ...mockPromptsSettings, customText: {} }); + expect(context.assets.prompts).to.deep.equal({ + ...mockPromptsSettings, + customText: {}, + partials: {}, + }); }); it('should parse even if both files are absent', async () => { @@ -129,7 +222,7 @@ describe('#directory context prompts', () => { const context = new Context(config, mockMgmtClient()); await context.loadAssetsFromLocal(); - expect(context.assets.prompts).to.deep.equal({ customText: {} }); + expect(context.assets.prompts).to.deep.equal({ customText: {}, partials: {} }); }); }); @@ -182,6 +275,18 @@ describe('#directory context prompts', () => { }, }, }, + partials: { + login: { + login: { + 'form-content-start': '
TEST
', + }, + }, + signup: { + signup: { + 'form-content-end': '
TEST AGAIN
', + }, + }, + }, }; await promptsHandler.dump(context); @@ -190,12 +295,35 @@ describe('#directory context prompts', () => { expect(dumpedFiles).to.deep.equal([ path.join(promptsDirectory, customTextFile), + path.join(promptsDirectory, partialsFile), path.join(promptsDirectory, promptsSettingsFile), ]); expect(loadJSON(path.join(promptsDirectory, customTextFile), {})).to.deep.equal( context.assets.prompts.customText ); + expect(loadJSON(path.join(promptsDirectory, partialsFile), {})).to.deep.equal({ + login: [ + { + login: [ + { + name: 'form-content-start', + template: 'partials/login/login/form-content-start.liquid', + }, + ], + }, + ], + signup: [ + { + signup: [ + { + name: 'form-content-end', + template: 'partials/signup/signup/form-content-end.liquid', + }, + ], + }, + ], + }); expect(loadJSON(path.join(promptsDirectory, promptsSettingsFile), {})).to.deep.equal({ universal_login_experience: context.assets.prompts.universal_login_experience, identifier_first: context.assets.prompts.identifier_first, diff --git a/test/context/yaml/context.test.js b/test/context/yaml/context.test.js index 4f6021579..450d229e8 100644 --- a/test/context/yaml/context.test.js +++ b/test/context/yaml/context.test.js @@ -3,12 +3,20 @@ import fs from 'fs-extra'; import jsYaml from 'js-yaml'; import { expect } from 'chai'; import sinon from 'sinon'; - +import handlers from '../../../src/tools/auth0/handlers'; import Context from '../../../src/context/yaml'; import { cleanThenMkdir, testDataDir, mockMgmtClient } from '../../utils'; import ScimHandler from '../../../src/tools/auth0/handlers/scimHandler'; describe('#YAML context validation', () => { + beforeEach(() => { + sinon.stub(handlers.prompts.default.prototype, 'getCustomPartial').resolves({}); + }); + + afterEach(() => { + sinon.restore(); + }); + it('should do nothing on empty yaml', async () => { /* Create empty directory */ const dir = path.resolve(testDataDir, 'yaml', 'empty'); @@ -281,10 +289,11 @@ describe('#YAML context validation', () => { bruteForceProtection: {}, suspiciousIpThrottling: {}, }, - logStreams: [], prompts: { customText: {}, + partials: {}, }, + logStreams: [], customDomains: [], themes: [], }); @@ -393,10 +402,11 @@ describe('#YAML context validation', () => { bruteForceProtection: {}, suspiciousIpThrottling: {}, }, - logStreams: [], prompts: { customText: {}, + partials: {}, }, + logStreams: [], customDomains: [], themes: [], }); @@ -506,10 +516,11 @@ describe('#YAML context validation', () => { bruteForceProtection: {}, suspiciousIpThrottling: {}, }, - logStreams: [], prompts: { customText: {}, + partials: {}, }, + logStreams: [], customDomains: [], themes: [], }); @@ -522,6 +533,7 @@ describe('#YAML context validation', () => { const config = { AUTH0_INPUT_FILE: tenantFile, INCLUDED_PROPS: { clients: ['client_secret', 'name'] }, + AUTH0_EXCLUDED: ['prompts'], EXCLUDED_PROPS: { clients: ['client_secret', 'name'] }, }; const context = new Context(config, mockMgmtClient()); @@ -541,7 +553,6 @@ describe('#YAML context validation', () => { it('should preserve keywords when dumping', async () => { const applyScimConfiguration = (connections) => connections; sinon.stub(ScimHandler.prototype, 'applyScimConfiguration').returns(applyScimConfiguration); - const dir = path.resolve(testDataDir, 'yaml', 'dump'); cleanThenMkdir(dir); const tenantFile = path.join(dir, 'tenant.yml'); @@ -590,11 +601,15 @@ describe('#YAML context validation', () => { }, }, ], - }) - } + }), + }, + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, } ); - await context.dump(); const yaml = jsYaml.load(fs.readFileSync(tenantFile)); @@ -613,6 +628,6 @@ describe('#YAML context validation', () => { }, ], }); - sinon.restore(); + sinon.restore(); }); }); diff --git a/test/context/yaml/prompts.test.ts b/test/context/yaml/prompts.test.ts index ff815ccd7..e70f8cc2d 100644 --- a/test/context/yaml/prompts.test.ts +++ b/test/context/yaml/prompts.test.ts @@ -62,6 +62,15 @@ describe('#YAML context prompts', () => { passwordSecurityText: 'Your password must contain:' title: Create Your Account! usernamePlaceholder: Username + partials: + login: + login: + form-content-end: >- +
TEST
+ login-id: + login-id: + form-content-end: >- +
TEST
`; const yamlFile = path.join(dir, 'config.yaml'); @@ -130,6 +139,18 @@ describe('#YAML context prompts', () => { }, }, identifier_first: true, + partials: { + login: { + login: { + 'form-content-end': '
TEST
', + }, + }, + 'login-id': { + 'login-id': { + 'form-content-end': '
TEST
', + }, + }, + }, universal_login_experience: 'classic', }); }); @@ -171,6 +192,18 @@ describe('#YAML context prompts', () => { }, }, }, + partials: { + login: { + login: { + 'form-content-end': '
TEST
', + }, + }, + 'login-id': { + 'login-id': { + 'form-content-end': '
TEST
', + }, + }, + }, }; const dumped = await promptsHandler.dump(context); diff --git a/test/e2e/recordings/should-deploy-while-deleting-resources-if-AUTH0_ALLOW_DELETE-is-true.json b/test/e2e/recordings/should-deploy-while-deleting-resources-if-AUTH0_ALLOW_DELETE-is-true.json index 5c0660552..82a4a8d93 100644 --- a/test/e2e/recordings/should-deploy-while-deleting-resources-if-AUTH0_ALLOW_DELETE-is-true.json +++ b/test/e2e/recordings/should-deploy-while-deleting-resources-if-AUTH0_ALLOW_DELETE-is-true.json @@ -11249,6 +11249,66 @@ "rawHeaders": [], "responseIsBinary": false }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login-id/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login-password/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup-id/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup-password/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, { "scope": "https://deploy-cli-dev.eu.auth0.com:443", "method": "GET", @@ -11937,4 +11997,4 @@ "rawHeaders": [], "responseIsBinary": false } -] \ No newline at end of file +] diff --git a/test/e2e/recordings/should-deploy-without-deleting-resources-if-AUTH0_ALLOW_DELETE-is-false.json b/test/e2e/recordings/should-deploy-without-deleting-resources-if-AUTH0_ALLOW_DELETE-is-false.json index 3bd2b0840..79b5a8023 100644 --- a/test/e2e/recordings/should-deploy-without-deleting-resources-if-AUTH0_ALLOW_DELETE-is-false.json +++ b/test/e2e/recordings/should-deploy-without-deleting-resources-if-AUTH0_ALLOW_DELETE-is-false.json @@ -12748,6 +12748,66 @@ "rawHeaders": [], "responseIsBinary": false }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login-id/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login-password/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup-id/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup-password/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, { "scope": "https://deploy-cli-dev.eu.auth0.com:443", "method": "GET", @@ -13599,4 +13659,4 @@ "rawHeaders": [], "responseIsBinary": false } -] \ No newline at end of file +] diff --git a/test/e2e/recordings/should-dump-and-deploy-without-throwing-an-error.json b/test/e2e/recordings/should-dump-and-deploy-without-throwing-an-error.json index 7ba389f22..724c9a67f 100644 --- a/test/e2e/recordings/should-dump-and-deploy-without-throwing-an-error.json +++ b/test/e2e/recordings/should-dump-and-deploy-without-throwing-an-error.json @@ -1722,6 +1722,66 @@ "rawHeaders": [], "responseIsBinary": false }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login-id/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login-password/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup-id/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup-password/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, { "scope": "https://deploy-cli-dev.eu.auth0.com:443", "method": "GET", @@ -5064,4 +5124,4 @@ "rawHeaders": [], "responseIsBinary": false } -] \ No newline at end of file +] diff --git a/test/e2e/recordings/should-dump-without-throwing-an-error.json b/test/e2e/recordings/should-dump-without-throwing-an-error.json index f67efad27..30682a7d5 100644 --- a/test/e2e/recordings/should-dump-without-throwing-an-error.json +++ b/test/e2e/recordings/should-dump-without-throwing-an-error.json @@ -1680,6 +1680,66 @@ "rawHeaders": [], "responseIsBinary": false }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login-id/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/login-password/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup-id/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, + { + "scope": "https://deploy-cli-dev.eu.auth0.com:443", + "method": "GET", + "path": "/api/v2/prompts/signup-password/partials", + "body": "", + "status": 200, + "response": {}, + "rawHeaders": [], + "responseIsBinary": false + }, { "scope": "https://deploy-cli-dev.eu.auth0.com:443", "method": "GET", @@ -2605,4 +2665,4 @@ "rawHeaders": [], "responseIsBinary": false } -] \ No newline at end of file +] diff --git a/test/e2e/testdata/lots-of-configuration/tenant.yaml b/test/e2e/testdata/lots-of-configuration/tenant.yaml index a943dc264..3e7b3a63b 100644 --- a/test/e2e/testdata/lots-of-configuration/tenant.yaml +++ b/test/e2e/testdata/lots-of-configuration/tenant.yaml @@ -657,6 +657,7 @@ roles: description: Readz Only permissions: [] prompts: + partials: {} universal_login_experience: new identifier_first: true migrations: {} diff --git a/test/e2e/testdata/should-deploy-without-throwing-an-error/tenant.yaml b/test/e2e/testdata/should-deploy-without-throwing-an-error/tenant.yaml index c5dac8f38..1b0aa2fb6 100644 --- a/test/e2e/testdata/should-deploy-without-throwing-an-error/tenant.yaml +++ b/test/e2e/testdata/should-deploy-without-throwing-an-error/tenant.yaml @@ -124,6 +124,7 @@ branding: templates: [] prompts: customText: {} + partials: {} universal_login_experience: new migrations: {} actions: [] diff --git a/test/tools/auth0/handlers/prompts.tests.ts b/test/tools/auth0/handlers/prompts.tests.ts index 8e8ae6bf2..bd225c334 100644 --- a/test/tools/auth0/handlers/prompts.tests.ts +++ b/test/tools/auth0/handlers/prompts.tests.ts @@ -1,8 +1,14 @@ import { expect } from 'chai'; -import promptsHandler from '../../../../src/tools/auth0/handlers/prompts'; -import { Language } from '../../../../src/types'; import _ from 'lodash'; import { PromisePoolExecutor } from 'promise-pool-executor'; +import sinon from 'sinon'; +import promptsHandler, { Prompts } from '../../../../src/tools/auth0/handlers/prompts'; +import { Language } from '../../../../src/types'; +import log from '../../../../src/logger'; +import { + CustomPartialsPromptTypes, + CustomPromptPartialsScreens, +} from '../../../../lib/tools/auth0/handlers/prompts'; const mockPromptsSettings = { universal_login_experience: 'classic', @@ -11,7 +17,7 @@ const mockPromptsSettings = { describe('#prompts handler', () => { describe('#prompts process', () => { - it('should get prompts settings and prompts custom text', async () => { + it('should get prompts settings, custom texts and template partials', async () => { const supportedLanguages: Language[] = ['es', 'fr', 'en']; const englishCustomText = { @@ -48,6 +54,16 @@ describe('#prompts handler', () => { 'mfa-webauthn': {}, }; // Has no prompts configured. + const loginPartial = { + login: { + 'form-content-end': '
TEST
', + }, + }; + const signupPartial = { + signup: { + 'form-content-end': '
TEST
', + }, + }; const auth0 = { tenant: { getSettings: () => @@ -70,6 +86,9 @@ describe('#prompts handler', () => { return Promise.resolve(customTextValue); }, + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), }, pool: new PromisePoolExecutor({ concurrencyLimit: 3, @@ -78,26 +97,46 @@ describe('#prompts handler', () => { }), }; - const handler = new promptsHandler({ client: auth0 }); + const handler = new promptsHandler({ + client: auth0, + }); + + const getCustomPartial = sinon.stub(handler, 'getCustomPartial'); + getCustomPartial.withArgs({ prompt: 'login' }).resolves(loginPartial); + getCustomPartial.withArgs({ prompt: 'login-id' }).resolves({}); + getCustomPartial.withArgs({ prompt: 'login-password' }).resolves({}); + getCustomPartial.withArgs({ prompt: 'signup-password' }).resolves({}); + getCustomPartial.withArgs({ prompt: 'signup-id' }).resolves({}); + getCustomPartial.withArgs({ prompt: 'signup' }).resolves(signupPartial); + const data = await handler.getType(); expect(data).to.deep.equal({ ...mockPromptsSettings, customText: { en: { 'signup-password': englishCustomText['signup-password'], - login: englishCustomText['login'], + login: englishCustomText.login, }, fr: { - login: frenchCustomText['login'], + login: frenchCustomText.login, + }, + // does not have spanish custom text because all responses returned empty objects + }, + partials: { + login: { + login: loginPartial.login, + }, + signup: { + signup: signupPartial.signup, }, - //does not have spanish custom text because all responses returned empty objects }, }); }); - it('should update prompts settings but not custom text settings if not set', async () => { + it('should update prompts settings but not custom text/partials settings if not set', async () => { let didCallUpdatePromptsSettings = false; let didCallUpdateCustomText = false; + let didCallUpdatePartials = false; const auth0 = { tenant: { @@ -114,22 +153,36 @@ describe('#prompts handler', () => { expect(data).to.deep.equal(mockPromptsSettings); return Promise.resolve(data); }, + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), }, }; - const handler = new promptsHandler({ client: auth0 }); - const stageFn = Object.getPrototypeOf(handler).processChanges; + const handler = new promptsHandler({ + client: auth0, + }); + sinon.stub(handler, 'updateCustomPartials').callsFake(() => { + didCallUpdatePartials = true; + return Promise.resolve({}); + }); + const stageFn = handler.processChanges.bind(handler); const customText = undefined; - await stageFn.apply(handler, [{ prompts: { ...mockPromptsSettings, customText } }]); + await stageFn.apply(handler, [ + { prompts: { ...mockPromptsSettings, customText }, partials: undefined }, + ]); expect(didCallUpdatePromptsSettings).to.equal(true); expect(didCallUpdateCustomText).to.equal(false); + expect(didCallUpdatePartials).to.equal(false); }); - it('should update prompts settings and custom text settings when both are set', async () => { + it('should update prompts settings and custom text/partials settings when set', async () => { let didCallUpdatePromptsSettings = false; let didCallUpdateCustomText = false; + let didCallUpdatePartials = false; let numberOfUpdateCustomTextCalls = 0; + let numberOfUpdatePartialsCalls = 0; const customTextToSet = { en: { @@ -149,6 +202,24 @@ describe('#prompts handler', () => { }, }; + const partialsToSet: Prompts['partials'] = { + login: { + login: { + 'form-content-start': '
TEST
', + }, + }, + 'signup-id': { + 'signup-id': { + 'form-content-start': '
TEST
', + }, + }, + 'signup-password': { + 'signup-password': { + 'form-content-start': '
TEST
', + }, + }, + }; + const auth0 = { prompts: { updateCustomTextByLanguage: () => { @@ -161,6 +232,9 @@ describe('#prompts handler', () => { expect(data).to.deep.equal(mockPromptsSettings); return Promise.resolve(data); }, + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), }, pool: new PromisePoolExecutor({ concurrencyLimit: 3, @@ -169,19 +243,31 @@ describe('#prompts handler', () => { }), }; - const handler = new promptsHandler({ client: auth0 }); - const stageFn = Object.getPrototypeOf(handler).processChanges; + const handler = new promptsHandler({ + client: auth0, + }); + sinon.stub(handler, 'updateCustomPartials').callsFake(() => { + didCallUpdatePartials = true; + numberOfUpdatePartialsCalls++; + return Promise.resolve({}); + }); + + const stageFn = Object.getPrototypeOf(handler).processChanges; await stageFn.apply(handler, [ - { prompts: { ...mockPromptsSettings, customText: customTextToSet } }, + { + prompts: { ...mockPromptsSettings, customText: customTextToSet, partials: partialsToSet }, + }, ]); expect(didCallUpdatePromptsSettings).to.equal(true); expect(didCallUpdateCustomText).to.equal(true); + expect(didCallUpdatePartials).to.equal(true); expect(numberOfUpdateCustomTextCalls).to.equal(3); + expect(numberOfUpdatePartialsCalls).to.equal(3); }); - it('should not fail if tenant languages undefined', async () => { + it('should not fail if tenant languages or partials are undefined', async () => { const auth0 = { tenant: { getSettings: () => @@ -191,6 +277,9 @@ describe('#prompts handler', () => { }, prompts: { getSettings: () => mockPromptsSettings, + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), }, pool: new PromisePoolExecutor({ concurrencyLimit: 3, @@ -200,11 +289,225 @@ describe('#prompts handler', () => { }; const handler = new promptsHandler({ client: auth0 }); + const getCustomPartial = sinon.stub(handler, 'getCustomPartial'); + getCustomPartial.withArgs({ prompt: 'login' }).resolves({}); + getCustomPartial.withArgs({ prompt: 'login-id' }).resolves({}); + getCustomPartial.withArgs({ prompt: 'login-password' }).resolves({}); + getCustomPartial.withArgs({ prompt: 'signup-password' }).resolves({}); + getCustomPartial.withArgs({ prompt: 'signup-id' }).resolves({}); + getCustomPartial.withArgs({ prompt: 'signup' }).resolves({}); + const data = await handler.getType(); expect(data).to.deep.equal({ ...mockPromptsSettings, customText: {}, // Custom text empty + partials: {}, // Partials empty + }); + }); + }); + describe('withErrorHandling', () => { + let handler: any; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + const auth0 = { + tokenProvider: { + getAccessToken: async function () { + return 'test-access-token'; + }, + }, + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + + invoke: (...options) => Promise.resolve({ endpoint, method: 'put', options }), + }), + }, + }; + handler = new promptsHandler({ client: auth0 }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should return the result of the callback', async () => { + const callback = sandbox.stub().resolves('success'); + const result = await handler.withErrorHandling(callback); + expect(result).to.equal('success'); + }); + + it('should handle 403 error and set IsFeatureSupported to false', async () => { + const error = { + statusCode: 403, + }; + const callback = sandbox.stub().rejects(error); + const logWarn = sandbox.stub(log, 'warn'); + + const result = await handler.withErrorHandling(callback); + expect(result).to.deep.equal(null); + expect(handler.IsFeatureSupported).to.be.false; + expect(logWarn.calledWith('Partial Prompts feature is not supported for the tenant')).to.be + .true; + }); + + it('should handle 400 error with specific message and set IsFeatureSupported to false', async () => { + const error = { + statusCode: 400, + message: + 'This feature requires at least one custom domain to be configured for the tenant.', + }; + const callback = sandbox.stub().rejects(error); + const logWarn = sandbox.stub(log, 'warn'); + + const result = await handler.withErrorHandling(callback); + expect(result).to.deep.equal(null); + expect(handler.IsFeatureSupported).to.be.false; + expect( + logWarn.calledWith( + 'Partial Prompts feature requires at least one custom domain to be configured for the tenant' + ) + ).to.be.true; + }); + + it('should handle 429 error and log the appropriate message', async () => { + const error = { + statusCode: 429, + message: 'Rate limit exceeded', + }; + const callback = sandbox.stub().rejects(error); + const logError = sandbox.stub(log, 'error'); + + const result = await handler.withErrorHandling(callback); + expect(result).to.be.null; + expect( + logError.calledWith( + `The global rate limit has been exceeded, resulting in a ${error.statusCode} error. ${error.message}. Although this is an error, it is not blocking the pipeline.` + ) + ).to.be.true; + }); + + it('should rethrow other errors', async () => { + const error = new Error('some other error'); + const callback = sandbox.stub().rejects(error); + + try { + await handler.withErrorHandling(callback); + throw new Error('Expected method to throw.'); + } catch (err) { + expect(err).to.equal(error); + } + }); + + it('should handle errors correctly in partialHttpRequest with Get Method', async () => { + const method = 'get'; + const options = [{ prompt: 'test-prompt' }]; + const error = new Error('Request failed'); + sandbox.stub(handler.promptClient, method).rejects(error); + + try { + await handler.partialHttpRequest(method, options); + throw new Error('Expected method to throw.'); + } catch (err) { + expect(err).to.equal(error); + } + }); + + it('should handle errors correctly in partialHttpRequest with Put Method', async () => { + const method = 'put'; + const options = [{ prompt: 'test-prompt' }, { key: 'value' }]; + const error = new Error('Request failed'); + sandbox.stub(handler.promptClient, 'invoke').rejects(error); + + try { + await handler.partialHttpRequest(method, options); + throw new Error('Expected method to throw.'); + } catch (err) { + expect(err).to.equal(error); + } + }); + + it('should make an HTTP request with the correct headers with Get Method', async () => { + const method = 'get'; + const options = [{ prompt: 'test-prompt' }]; + const mockResponse = { data: 'response' }; + sandbox.stub(handler.promptClient, method).resolves(mockResponse); + + const result = await handler.partialHttpRequest(method, options); + expect(result).to.deep.equal(mockResponse); + }); + + it('should make an HTTP request with the correct headers with Put Method', async () => { + const method = 'put'; + const options = [{ prompt: 'test-prompt' }, { key: 'value' }]; + const mockResponse = { data: 'response' }; + sandbox.stub(handler.promptClient, 'invoke').resolves(mockResponse); + + const result = await handler.partialHttpRequest(method, options); + expect(result).to.deep.equal(mockResponse); + }); + + it('should not make a request if the feature is not supported', async () => { + handler.IsFeatureSupported = false; + const putStub = sandbox.stub(handler, 'partialHttpRequest'); + + await handler.updateCustomPartials({ + prompt: 'login', + body: {} as CustomPromptPartialsScreens, }); + + expect(putStub.called).to.be.false; + }); + + it('should make a request if the feature is supported', async () => { + handler.IsFeatureSupported = true; + const body = { key: 'value' }; + const putStub = sandbox.stub(handler, 'partialHttpRequest').resolves(); + + await handler.updateCustomPartials({ prompt: 'login', body }); + + expect(putStub.calledOnce).to.be.true; + const { args } = putStub.getCall(0); + expect(args[0]).to.equal('put'); + expect(args[1]).to.deep.equal([{ prompt: 'login' }, body]); + }); + + it('should return empty object if feature is not supported', async () => { + handler.IsFeatureSupported = false; + + const result = await handler.getCustomPartial({ + prompt: 'login' as CustomPartialsPromptTypes, + }); + expect(result).to.deep.equal({}); + }); + + it('should return custom partial data if feature is supported', async () => { + handler.IsFeatureSupported = true; + + const mockResponse = { + 'form-content-end': '
TEST
', + }; + sandbox.stub(handler, 'partialHttpRequest').resolves(mockResponse); + + const result = await handler.getCustomPartial({ prompt: 'login' }); + + expect(result).to.deep.equal(mockResponse); + expect(handler.partialHttpRequest.calledOnceWith('get', [{ prompt: 'login' }])).to.be.true; + }); + + it('should handle errors correctly', async () => { + handler.IsFeatureSupported = true; + + const error = new Error('Request failed'); + sandbox.stub(handler, 'partialHttpRequest').rejects(error); + + try { + await handler.getCustomPartial({ prompt: 'login' as CustomPartialsPromptTypes }); + throw new Error('Expected method to throw.'); + } catch (err) { + expect(err).to.equal(error); + } }); }); }); diff --git a/test/tools/auth0/index.test.ts b/test/tools/auth0/index.test.ts index e6c04230e..20e8bb53b 100644 --- a/test/tools/auth0/index.test.ts +++ b/test/tools/auth0/index.test.ts @@ -2,7 +2,13 @@ import { expect } from 'chai'; import Auth0 from '../../../src/tools/auth0'; import { Auth0APIClient, Assets } from '../../../src/types'; -const mockEmptyClient = {} as Auth0APIClient; +const mockEmptyClient = { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, +} as Auth0APIClient; const mockEmptyAssets = {} as Assets; describe('#Auth0 class', () => { diff --git a/test/tools/auth0/validator.tests.js b/test/tools/auth0/validator.tests.js index f88a2be73..7d3892e4c 100644 --- a/test/tools/auth0/validator.tests.js +++ b/test/tools/auth0/validator.tests.js @@ -3,7 +3,7 @@ import Auth0 from '../../../src/tools/auth0'; import constants from '../../../src/tools/constants'; import { mockTheme } from './handlers/themes.tests'; -const mockConfigFn = () => {}; +const mockConfigFn = () => { }; describe('#schema validation tests', () => { const client = { @@ -25,6 +25,11 @@ describe('#schema validation tests', () => { roles: { getAll: async () => ({ client_grants: [] }), }, + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, }; const failedCb = (done) => (err) => done(err || 'test failed'); @@ -42,7 +47,17 @@ describe('#schema validation tests', () => { }; const checkRequired = (field, data, done) => { - const auth0 = new Auth0({}, data, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + data, + mockConfigFn + ); auth0 .validate() @@ -50,7 +65,17 @@ describe('#schema validation tests', () => { }; const checkEnum = (data, done) => { - const auth0 = new Auth0({}, data, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + data, + mockConfigFn + ); auth0 .validate() @@ -58,7 +83,17 @@ describe('#schema validation tests', () => { }; const checkTypeError = (field, expectedType, data, done) => { - const auth0 = new Auth0({}, data, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + data, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, `should be ${expectedType}`, field)); }; @@ -71,7 +106,17 @@ describe('#schema validation tests', () => { }, ]; - const auth0 = new Auth0({}, { branding: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { branding: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should be object')); }); @@ -127,7 +172,17 @@ describe('#schema validation tests', () => { }, ]; - const auth0 = new Auth0({}, { clientGrants: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { clientGrants: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should be array')); }); @@ -163,7 +218,17 @@ describe('#schema validation tests', () => { }, ]; - const auth0 = new Auth0({}, { clients: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { clients: data }, + mockConfigFn + ); auth0 .validate() @@ -256,7 +321,17 @@ describe('#schema validation tests', () => { }, ]; - const auth0 = new Auth0({}, { emailProvider: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { emailProvider: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should be object')); }); @@ -545,7 +620,17 @@ describe('#schema validation tests', () => { }, ]; - const auth0 = new Auth0({}, { prompts: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { prompts: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should be object')); }); @@ -610,7 +695,17 @@ describe('#schema validation tests', () => { }, ]; - const auth0 = new Auth0({}, { rules: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { rules: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should match pattern')); }); @@ -668,7 +763,17 @@ describe('#schema validation tests', () => { }, ]; - const auth0 = new Auth0({}, { rulesConfigs: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { rulesConfigs: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should match pattern')); }); @@ -703,7 +808,17 @@ describe('#schema validation tests', () => { }, ]; - const auth0 = new Auth0({}, { hooks: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { hooks: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should match pattern')); }); @@ -751,7 +866,17 @@ describe('#schema validation tests', () => { }, ]; - const auth0 = new Auth0({}, { tenant: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { tenant: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should be object')); }); @@ -769,7 +894,17 @@ describe('#schema validation tests', () => { it('should fail validation if migrations is not an object', (done) => { const data = ''; - const auth0 = new Auth0({}, { migrations: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { migrations: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should be object')); }); @@ -779,7 +914,17 @@ describe('#schema validation tests', () => { migration_flag: 'string', }; - const auth0 = new Auth0({}, { migrations: data }, mockConfigFn); + const auth0 = new Auth0( + { + prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + }), + }, + }, + { migrations: data }, + mockConfigFn + ); auth0.validate().then(failedCb(done), passedCb(done, 'should be boolean')); }); diff --git a/test/utils.js b/test/utils.js index 4a61197a3..7aeba1dfd 100644 --- a/test/utils.js +++ b/test/utils.js @@ -120,11 +120,15 @@ export function mockMgmtClient() { }, logStreams: { getAll: () => [] }, prompts: { + _getRestClient: (endpoint) => ({ + get: (...options) => Promise.resolve({ endpoint, method: 'get', options }), + + }), getCustomTextByLanguage: () => new Promise((res) => { res({}); }), - getSettings: () => {}, + getSettings: () => { }, }, customDomains: { getAll: () => [] }, }; @@ -144,7 +148,34 @@ export function createDir(repoDir, files) { const configDir = path.resolve(repoDir, type); cleanThenMkdir(configDir); Object.entries(files[type]).forEach(([name, content]) => { - fs.writeFileSync(path.join(configDir, name), content); + const filePath = path.join(configDir, name); + fs.writeFileSync(filePath, content); + }); + }); +} + +export function createDirWithNestedDir(repoDir, files) { + Object.keys(files).forEach((type) => { + const typeDir = path.resolve(repoDir, type); + cleanThenMkdir(typeDir); + + Object.entries(files[type]).forEach(([subtype, content]) => { + const subtypeDir = path.join(typeDir, subtype); + + if (typeof content === 'string') { + fs.writeFileSync(subtypeDir, content); + } else if (typeof content === 'object') { + cleanThenMkdir(subtypeDir); + Object.entries(content).forEach(([fileName, fileContent]) => { + const filePath = path.join(subtypeDir, fileName); + if (typeof fileContent !== 'string') { + throw new TypeError(`Expected content to be a string, but received ${typeof fileContent}`); + } + fs.writeFileSync(filePath, fileContent); + }); + } else { + throw new TypeError(`Expected content to be a string or object, but received ${typeof content}`); + } }); }); }