From 38aecadbfc8270079e46c6c2180f74585bb3abca Mon Sep 17 00:00:00 2001 From: Preston Hales Date: Mon, 12 May 2025 11:56:59 -0600 Subject: [PATCH] Add support for exporting partial exports for journeys, scripts, and full AM/IDM configuration via callbacks --- src/api/AmConfigApi.ts | 116 +++--- src/api/ScriptApi.ts | 33 -- src/ops/AmConfigOps.test.ts | 17 +- src/ops/AmConfigOps.ts | 184 +++++----- src/ops/ConfigOps.test.ts | 41 ++- src/ops/ConfigOps.ts | 278 +++++++++------ src/ops/IdmConfigOps.test.ts | 7 +- src/ops/IdmConfigOps.ts | 337 +++++++++--------- src/ops/JourneyOps.test.ts | 7 +- src/ops/JourneyOps.ts | 241 ++++++------- src/ops/OpsTypes.ts | 6 + src/ops/ScriptOps.test.ts | 12 +- src/ops/ScriptOps.ts | 238 +++++++------ src/test/snapshots/ops/ConfigOps.test.js.snap | 40 +++ .../snapshots/ops/IdmConfigOps.test.js.snap | 10 + src/test/utils/TestUtils.ts | 6 + src/utils/ExportImportUtils.ts | 99 +++-- 17 files changed, 923 insertions(+), 749 deletions(-) diff --git a/src/api/AmConfigApi.ts b/src/api/AmConfigApi.ts index f4c588f7d..a00518de5 100644 --- a/src/api/AmConfigApi.ts +++ b/src/api/AmConfigApi.ts @@ -1,6 +1,8 @@ +import { FrodoError } from '../ops/FrodoError'; +import { ResultCallback } from '../ops/OpsTypes'; import Constants from '../shared/Constants'; import { State } from '../shared/State'; -import { printError, printMessage } from '../utils/Console'; +import { getResult } from '../utils/ExportImportUtils'; import { getRealmPathGlobal, getRealmsForExport, @@ -259,11 +261,10 @@ export async function getConfigEntity({ state.setRealm(currentRealm); return data; } catch (error) { - printError({ - error, - message: `Error getting config entity from resource path '${urlString}'`, - state, - }); + throw new FrodoError( + `Error getting config entity from resource path '${urlString}'`, + error + ); } } @@ -272,17 +273,20 @@ export async function getConfigEntity({ * @param {boolean} includeReadOnly Include read only config in the export * @param {boolean} onlyRealm Get config only from the active realm. If onlyGlobal is also active, then it will also get the global config. * @param {boolean} onlyGlobal Get global config only. If onlyRealm is also active, then it will also get the active realm config. + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise that resolves to a config object containing global and realm config entities */ export async function getConfigEntities({ includeReadOnly = false, onlyRealm = false, onlyGlobal = false, + resultCallback = void 0, state, }: { includeReadOnly: boolean; onlyRealm: boolean; onlyGlobal: boolean; + resultCallback: ResultCallback; state: State; }): Promise { const realms = await getRealmsForExport({ state }); @@ -305,8 +309,11 @@ export async function getConfigEntities({ entityInfo.global.deployments.includes(state.getDeploymentType())) || (entityInfo.global.deployments == undefined && deploymentAllowed)) ) { - try { - entities.global[key] = await getConfigEntity({ + entities.global[key] = await getResult( + resultCallback, + `Error getting '${key}' from resource path '${entityInfo.global.path}'`, + getConfigEntity, + { state, path: entityInfo.global.path, version: entityInfo.global.version, @@ -317,14 +324,8 @@ export async function getConfigEntities({ action: entityInfo.global.action ? entityInfo.global.action : entityInfo.action, - }); - } catch (e) { - printMessage({ - message: `Error getting '${key}' from resource path '${entityInfo.global.path}': ${e.message}`, - type: 'error', - state, - }); - } + } + ); } if ( (!onlyGlobal || onlyRealm) && @@ -342,8 +343,11 @@ export async function getConfigEntities({ ) { continue; } - try { - entities.realm[realms[i]][key] = await getConfigEntity({ + entities.realm[realms[i]][key] = await getResult( + resultCallback, + `Error getting '${key}' from resource path '${entityInfo.realm.path}'`, + getConfigEntity, + { state, path: entityInfo.realm.path, version: entityInfo.realm.version, @@ -355,14 +359,8 @@ export async function getConfigEntities({ action: entityInfo.realm.action ? entityInfo.realm.action : entityInfo.action, - }); - } catch (e) { - printMessage({ - message: `Error getting '${key}' from resource path '${entityInfo.realm.path}': ${e.message}`, - type: 'error', - state, - }); - } + } + ); } } } @@ -418,24 +416,26 @@ export async function putConfigEntity({ state.setRealm(currentRealm); return data; } catch (error) { - printError({ - error, - message: `Error putting config entity at resource path '${urlString}'`, - state, - }); + throw new FrodoError( + `Error putting config entity at resource path '${urlString}'`, + error + ); } } /** * Put all other AM config entities * @param {ConfigSkeleton} config the config object containing global and realm config entities + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise that resolves to a config object containing global and realm config entities */ export async function putConfigEntities({ config, + resultCallback = void 0, state, }: { config: ConfigSkeleton; + resultCallback: ResultCallback; state: State; }): Promise { const realms = config.realm ? Object.keys(config.realm) : []; @@ -459,12 +459,15 @@ export async function putConfigEntities({ config.global && config.global[key] ) { - try { - for (const [id, entityData] of Object.entries(config.global[key])) { - if (!entities.global[key]) { - entities.global[key] = {}; - } - entities.global[key][id] = await putConfigEntity({ + for (const [id, entityData] of Object.entries(config.global[key])) { + if (!entities.global[key]) { + entities.global[key] = {}; + } + entities.global[key][id] = await getResult( + resultCallback, + `Error putting entity '${id}' of type '${key}' to global resource path '${entityInfo.global.path}'`, + putConfigEntity, + { state, entityData: entityData as ConfigEntitySkeleton, path: @@ -473,14 +476,8 @@ export async function putConfigEntities({ version: entityInfo.global.version, protocol: entityInfo.global.protocol, ifMatch: entityInfo.global.ifMatch, - }); - } - } catch (e) { - printMessage({ - message: `Error putting '${key}' from resource path '${entityInfo.global.path}': ${e.message}`, - type: 'error', - state, - }); + } + ); } } if ( @@ -493,14 +490,17 @@ export async function putConfigEntities({ if (!config.realm[realms[i]][key]) { continue; } - try { - for (const [id, entityData] of Object.entries( - config.realm[realms[i]][key] - )) { - if (!entities.realm[realms[i]][key]) { - entities.realm[realms[i]][key] = {}; - } - entities.realm[realms[i]][key][id] = await putConfigEntity({ + for (const [id, entityData] of Object.entries( + config.realm[realms[i]][key] + )) { + if (!entities.realm[realms[i]][key]) { + entities.realm[realms[i]][key] = {}; + } + entities.realm[realms[i]][key][id] = await getResult( + resultCallback, + `Error putting entity '${id}' of type '${key}' to realm resource path '${entityInfo.realm.path}'`, + putConfigEntity, + { state, entityData: entityData as ConfigEntitySkeleton, path: @@ -510,14 +510,8 @@ export async function putConfigEntities({ protocol: entityInfo.realm.protocol, ifMatch: entityInfo.realm.ifMatch, realm: stateRealms[i], - }); - } - } catch (e) { - printMessage({ - message: `Error putting '${key}' from resource path '${entityInfo.realm.path}': ${e.message}`, - type: 'error', - state, - }); + } + ); } } } diff --git a/src/api/ScriptApi.ts b/src/api/ScriptApi.ts index dda96586a..23099c7a6 100644 --- a/src/api/ScriptApi.ts +++ b/src/api/ScriptApi.ts @@ -245,36 +245,3 @@ export async function deleteScriptByName({ state, }); } - -/** - * Delete all non-default scripts - * @returns {Promise} a promise that resolves to an array of script objects - */ -export async function deleteScripts({ - state, -}: { - state: State; -}): Promise { - const { result } = await getScripts({ state }); - //Unable to delete default scripts, so filter them out - const scripts = result.filter((s) => !s.default); - const deletedScripts = []; - const errors = []; - for (const script of scripts) { - try { - deletedScripts.push( - await deleteScript({ - scriptId: script._id, - state, - }) - ); - } catch (error) { - errors.push(error); - } - } - if (errors.length) { - const errorMessages = errors.map((error) => error.message).join('\n'); - throw new Error(`Delete error:\n${errorMessages}`); - } - return deletedScripts; -} diff --git a/src/ops/AmConfigOps.test.ts b/src/ops/AmConfigOps.test.ts index 35de6c05e..29fe9c9a3 100644 --- a/src/ops/AmConfigOps.test.ts +++ b/src/ops/AmConfigOps.test.ts @@ -50,6 +50,7 @@ import { filterRecording } from "../utils/PollyUtils"; import * as AmConfigOps from "./AmConfigOps"; import { state } from "../lib/FrodoLib"; import Constants from "../shared/Constants"; +import { snapshotResultCallback } from "../test/utils/TestUtils"; const ctx = autoSetupPolly(); @@ -98,28 +99,28 @@ describe('AmConfigOps', () => { }); test('1: Export AM Config Entities', async () => { - const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: false, onlyGlobal: false, state }); + const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: false, onlyGlobal: false, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); }); test('2: Export importable AM Config Entities', async () => { - const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: false, onlyRealm: false, onlyGlobal: false, state }); + const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: false, onlyRealm: false, onlyGlobal: false, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); }); test('3: Export alpha realm AM Config Entities', async () => { - const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: true, onlyGlobal: false, state }); + const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: true, onlyGlobal: false, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); }); test('4: Export global AM Config Entities', async () => { - const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: false, onlyGlobal: true, state }); + const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: false, onlyGlobal: true, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); @@ -149,28 +150,28 @@ describe('AmConfigOps', () => { describe('exportAmConfigEntities()', () => { test('5: Export AM Config Entities', async () => { - const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: false, onlyGlobal: false, state }); + const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: false, onlyGlobal: false, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); }); test('6: Export importable AM Config Entities', async () => { - const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: false, onlyRealm: false, onlyGlobal: false, state }); + const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: false, onlyRealm: false, onlyGlobal: false, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); }); test('7: Export root realm AM Config Entities', async () => { - const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: true, onlyGlobal: false, state }); + const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: true, onlyGlobal: false, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); }); test('8: Export global AM Config Entities', async () => { - const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: false, onlyGlobal: true, state }); + const response = await AmConfigOps.exportAmConfigEntities({ includeReadOnly: true, onlyRealm: false, onlyGlobal: true, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); diff --git a/src/ops/AmConfigOps.ts b/src/ops/AmConfigOps.ts index 5b82b03f3..88aaf500f 100644 --- a/src/ops/AmConfigOps.ts +++ b/src/ops/AmConfigOps.ts @@ -12,10 +12,9 @@ import { stopProgressIndicator, updateProgressIndicator, } from '../utils/Console'; -import { getMetadata } from '../utils/ExportImportUtils'; +import { getErrorCallback, getMetadata } from '../utils/ExportImportUtils'; import { getRealmsForExport } from '../utils/ForgeRockUtils'; -import { FrodoError } from './FrodoError'; -import { ExportMetaData } from './OpsTypes'; +import { ExportMetaData, ResultCallback } from './OpsTypes'; export type AmConfig = { /** @@ -30,20 +29,24 @@ export type AmConfig = { * @param {boolean} includeReadOnly Include read only config in the export * @param {boolean} onlyRealm Export config only from the active realm. If onlyGlobal is also active, then it will also export the global config. * @param {boolean} onlyGlobal Export global config only. If onlyRealm is also active, then it will also export the active realm config. + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} promise resolving to a ConfigEntityExportInterface object */ exportAmConfigEntities( includeReadOnly: boolean, onlyRealm: boolean, - onlyGlobal: boolean + onlyGlobal: boolean, + resultCallback?: ResultCallback ): Promise; /** * Import all other AM config entities * @param {ConfigEntityExportInterface} importData The config import data + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise that resolves to a config object containing global and realm config entities, or null if no import was performed */ importAmConfigEntities( - importData: ConfigEntityExportInterface + importData: ConfigEntityExportInterface, + resultCallback?: ResultCallback ): Promise; }; @@ -57,19 +60,22 @@ export default (state: State): AmConfig => { async exportAmConfigEntities( includeReadOnly = false, onlyRealm = false, - onlyGlobal = false + onlyGlobal = false, + resultCallback = void 0 ): Promise { return exportAmConfigEntities({ includeReadOnly, onlyRealm, onlyGlobal, + resultCallback, state, }); }, async importAmConfigEntities( - importData: ConfigEntityExportInterface + importData: ConfigEntityExportInterface, + resultCallback = void 0 ): Promise { - return importAmConfigEntities({ importData, state }); + return importAmConfigEntities({ importData, resultCallback, state }); }, }; }; @@ -110,126 +116,123 @@ export async function createConfigEntityExportTemplate({ * @param {boolean} includeReadOnly Include read only config in the export * @param {boolean} onlyRealm Export config only from the active realm. If onlyGlobal is also active, then it will also export the global config. * @param {boolean} onlyGlobal Export global config only. If onlyRealm is also active, then it will also export the active realm config. + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} promise resolving to a ConfigEntityExportInterface object */ export async function exportAmConfigEntities({ includeReadOnly = false, onlyRealm = false, onlyGlobal = false, + resultCallback = void 0, state, }: { includeReadOnly: boolean; onlyRealm: boolean; onlyGlobal: boolean; + resultCallback?: ResultCallback; state: State; }): Promise { - let indicatorId: string; - try { - debugMessage({ - message: `AmConfigOps.exportAmConfigEntities: start`, - state, - }); - const entities = await getConfigEntities({ - includeReadOnly, - onlyRealm, - onlyGlobal, - state, - }); - const exportData = await createConfigEntityExportTemplate({ - state, - realms: Object.keys(entities.realm), - }); - const totalEntities = - Object.keys(entities.global).length + - Object.values(entities.realm).reduce( - (total, realmEntities) => total + Object.keys(realmEntities).length, - 0 - ); - indicatorId = createProgressIndicator({ - total: totalEntities, - message: 'Exporting am config entities...', - state, - }); - exportData.global = processConfigEntitiesForExport({ - state, - indicatorId, - entities: entities.global, - }); - Object.entries(entities.realm).forEach( - ([key, value]) => - (exportData.realm[key] = processConfigEntitiesForExport({ - state, - indicatorId, - entities: value, - })) + debugMessage({ + message: `AmConfigOps.exportAmConfigEntities: start`, + state, + }); + const entities = await getConfigEntities({ + includeReadOnly, + onlyRealm, + onlyGlobal, + resultCallback: getErrorCallback(resultCallback), + state, + }); + const exportData = await createConfigEntityExportTemplate({ + state, + realms: Object.keys(entities.realm), + }); + const totalEntities = + Object.keys(entities.global).length + + Object.values(entities.realm).reduce( + (total, realmEntities) => total + Object.keys(realmEntities).length, + 0 ); - stopProgressIndicator({ - id: indicatorId, - message: `Exported ${totalEntities} am config entities.`, - state, - }); - debugMessage({ message: `AmConfigOps.exportAmConfigEntities: end`, state }); - return exportData; - } catch (error) { - stopProgressIndicator({ - id: indicatorId, - message: `Error exporting am config entities.`, - status: 'fail', - state, - }); - throw new FrodoError(`Error exporting am config entities`, error); - } + const indicatorId = createProgressIndicator({ + total: totalEntities, + message: 'Exporting am config entities...', + state, + }); + exportData.global = processConfigEntitiesForExport({ + state, + indicatorId, + entities: entities.global, + resultCallback, + }); + Object.entries(entities.realm).forEach( + ([key, value]) => + (exportData.realm[key] = processConfigEntitiesForExport({ + state, + indicatorId, + entities: value, + resultCallback, + })) + ); + stopProgressIndicator({ + id: indicatorId, + message: `Exported ${totalEntities} am config entities.`, + state, + }); + return exportData; } /** * Import all other AM config entities * @param {ConfigEntityExportInterface} importData The config import data + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise that resolves to a config object containing global and realm config entities, or null if no import was performed */ export async function importAmConfigEntities({ importData, + resultCallback = void 0, state, }: { importData: ConfigEntityExportInterface; + resultCallback?: ResultCallback; state: State; }): Promise { debugMessage({ message: `ServiceOps.importAmConfigEntities: start`, state, }); - try { - const result = await putConfigEntities({ - config: importData as unknown as ConfigSkeleton, - state, - }); - debugMessage({ message: `AmConfigOps.importAmConfigEntities: end`, state }); - // If no import was accomplished, return null - if ( - Object.keys(result.global).length === 0 && - !Object.values(result.realm).find((r) => Object.keys(r).length > 0) - ) { - return null; - } - return result; - } catch (error) { - throw new FrodoError(`Error importing am config entities`, error); + const result = await putConfigEntities({ + config: importData as unknown as ConfigSkeleton, + resultCallback, + state, + }); + debugMessage({ message: `AmConfigOps.importAmConfigEntities: end`, state }); + // If no import was accomplished, return null + if ( + Object.keys(result.global).length === 0 && + !Object.values(result.realm).find((r) => Object.keys(r).length > 0) + ) { + return null; } + return result; } /** * Helper to process the API results into export format * @param {AmConfigEntities} entities the entities being processed * @param {string} indicatorId the progress indicator id + * @param {ResultCallback} resultCallback Optional callback to process individual exports * @returns {Record} the processed entities */ function processConfigEntitiesForExport({ state, entities, indicatorId, + resultCallback, }: { state: State; entities: AmConfigEntitiesInterface; indicatorId: string; + resultCallback: ResultCallback; }): Record> { const exportedEntities = {}; const entries = Object.entries(entities); @@ -239,28 +242,33 @@ function processConfigEntitiesForExport({ message: `Exporting ${key}`, state, }); - const exportedValue = {}; if (!value) { continue; } if (!value.result) { if ((value as AmConfigEntityInterface)._id) { - exportedValue[(value as AmConfigEntityInterface)._id] = value; - exportedEntities[key] = exportedValue; + exportedEntities[key] = { + [(value as AmConfigEntityInterface)._id]: value, + }; } else if ( (value as AmConfigEntityInterface)._type && (value as AmConfigEntityInterface)._type._id ) { - exportedValue[(value as AmConfigEntityInterface)._type._id] = value; - exportedEntities[key] = exportedValue; + exportedEntities[key] = { + [(value as AmConfigEntityInterface)._type._id]: value, + }; } else { exportedEntities[key] = value; } - continue; + } else { + const { result } = value as PagedResult; + const exportedValue = {}; + result.forEach((o) => (exportedValue[o._id] = o)); + exportedEntities[key] = exportedValue; + } + if (resultCallback) { + resultCallback(undefined, exportedEntities[key]); } - const { result } = value as PagedResult; - result.forEach((o) => (exportedValue[o._id] = o)); - exportedEntities[key] = exportedValue; } return exportedEntities; } diff --git a/src/ops/ConfigOps.test.ts b/src/ops/ConfigOps.test.ts index 975cde4bf..e6f1f4bcf 100644 --- a/src/ops/ConfigOps.test.ts +++ b/src/ops/ConfigOps.test.ts @@ -51,6 +51,7 @@ import { filterRecording } from '../utils/PollyUtils'; import * as ConfigOps from "./ConfigOps"; import { state } from "../index"; import Constants from '../shared/Constants'; +import { snapshotResultCallback } from "../test/utils/TestUtils"; const ctx = autoSetupPolly(); @@ -89,7 +90,9 @@ describe('ConfigOps', () => { includeReadOnly: true, onlyRealm: false, onlyGlobal: false, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) @@ -107,7 +110,9 @@ describe('ConfigOps', () => { includeReadOnly: true, onlyRealm: false, onlyGlobal: false, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) @@ -125,7 +130,9 @@ describe('ConfigOps', () => { includeReadOnly: false, onlyRealm: false, onlyGlobal: false, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) @@ -143,7 +150,9 @@ describe('ConfigOps', () => { includeReadOnly: true, onlyRealm: true, onlyGlobal: false, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) @@ -161,7 +170,9 @@ describe('ConfigOps', () => { includeReadOnly: true, onlyRealm: false, onlyGlobal: true, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) @@ -200,7 +211,9 @@ describe('ConfigOps', () => { includeReadOnly: true, onlyRealm: false, onlyGlobal: false, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) @@ -218,7 +231,9 @@ describe('ConfigOps', () => { includeReadOnly: true, onlyRealm: false, onlyGlobal: false, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) @@ -236,7 +251,9 @@ describe('ConfigOps', () => { includeReadOnly: false, onlyRealm: false, onlyGlobal: false, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) @@ -254,7 +271,9 @@ describe('ConfigOps', () => { includeReadOnly: true, onlyRealm: true, onlyGlobal: false, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) @@ -272,7 +291,9 @@ describe('ConfigOps', () => { includeReadOnly: true, onlyRealm: false, onlyGlobal: true, - }, state + }, + resultCallback: snapshotResultCallback, + state }); expect(response).toMatchSnapshot({ meta: expect.any(Object) diff --git a/src/ops/ConfigOps.ts b/src/ops/ConfigOps.ts index e82e26fc5..369d4ed36 100644 --- a/src/ops/ConfigOps.ts +++ b/src/ops/ConfigOps.ts @@ -24,6 +24,7 @@ import { } from '../utils/Console'; import { exportWithErrorHandling, + getErrorCallback, getMetadata, importWithErrorHandling, } from '../utils/ExportImportUtils'; @@ -71,7 +72,6 @@ import { exportEmailTemplates, importEmailTemplates, } from './EmailTemplateOps'; -import { FrodoError } from './FrodoError'; import { exportConfigEntities, importConfigEntities } from './IdmConfigOps'; import { exportSocialIdentityProviders, @@ -98,7 +98,7 @@ import { exportOAuth2TrustedJwtIssuers, importOAuth2TrustedJwtIssuers, } from './OAuth2TrustedJwtIssuerOps'; -import { ExportMetaData } from './OpsTypes'; +import { ExportMetaData, ResultCallback } from './OpsTypes'; import { exportPolicies, importPolicies } from './PolicyOps'; import { exportPolicySets, importPolicySets } from './PolicySetOps'; import { exportRealms, importRealms } from './RealmOps'; @@ -117,23 +117,23 @@ export type Config = { /** * Export full configuration * @param {FullExportOptions} options export options - * @param {Error[]} collectErrors optional parameter to collect errors instead of having the function throw. Pass an empty array to collect errors and report on them but have the function perform all it can and return the export data even if it encounters errors. + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise resolving to a full export object */ exportFullConfiguration( options: FullExportOptions, - collectErrors?: Error[] + resultCallback: ResultCallback ): Promise; /** * Import full configuration * @param {FullExportInterface} importData import data * @param {FullImportOptions} options import options - * @param {Error[]} collectErrors optional parameter to collect errors instead of having the function throw. Pass an empty array to collect errors and report on them but have the function perform all it can and return the export data even if it encounters errors. + * @param {ResultCallback} resultCallback Optional callback to process individual results */ importFullConfiguration( importData: FullExportInterface, options: FullImportOptions, - collectErrors?: Error[] + resultCallback: ResultCallback ): Promise<(object | any[])[]>; }; @@ -151,9 +151,9 @@ export default (state: State): Config => { onlyRealm: false, onlyGlobal: false, }, - collectErrors: Error[] + resultCallback = void 0 ) { - return exportFullConfiguration({ options, collectErrors, state }); + return exportFullConfiguration({ options, resultCallback, state }); }, async importFullConfiguration( importData: FullExportInterface, @@ -164,12 +164,12 @@ export default (state: State): Config => { includeDefault: false, includeActiveValues: true, }, - collectErrors: Error[] + resultCallback = void 0 ): Promise<(object | any[])[]> { return importFullConfiguration({ importData, options, - collectErrors, + resultCallback, state, }); }, @@ -301,7 +301,7 @@ export interface FullRealmExportInterface extends AmConfigEntitiesInterface { /** * Export full configuration * @param {FullExportOptions} options export options - * @param {Error[]} collectErrors optional parameter to collect errors instead of having the function throw. Pass an empty array to collect errors and report on them but have the function perform all it can and return the export data even if it encounters errors. + * @param {ResultCallback} resultCallback Optional callback to process individual results */ export async function exportFullConfiguration({ options = { @@ -315,19 +315,13 @@ export async function exportFullConfiguration({ onlyRealm: false, onlyGlobal: false, }, - collectErrors, + resultCallback = void 0, state, }: { options: FullExportOptions; - collectErrors?: Error[]; + resultCallback: ResultCallback; state: State; }): Promise { - let errors: Error[] = []; - let throwErrors: boolean = true; - if (collectErrors && Array.isArray(collectErrors)) { - throwErrors = false; - errors = collectErrors; - } const { useStringArrays, noDecode, @@ -349,13 +343,20 @@ export async function exportFullConfiguration({ const isForgeOpsDeployment = state.getDeploymentType() === Constants.FORGEOPS_DEPLOYMENT_TYPE_KEY; const isPlatformDeployment = isCloudDeployment || isForgeOpsDeployment; + const errorCallback = getErrorCallback(resultCallback); - const config = await exportAmConfigEntities({ - includeReadOnly, - onlyRealm, - onlyGlobal, - state, - }); + const config = await exportWithErrorHandling( + exportAmConfigEntities, + { + includeReadOnly, + onlyRealm, + onlyGlobal, + errorCallback, + state, + }, + 'AM Config Entities', + resultCallback + ); let globalConfig = {} as FullGlobalExportInterface; if (!onlyRealm || onlyGlobal) { @@ -371,7 +372,8 @@ export async function exportFullConfiguration({ }, state, }, - errors, + 'Mappings', + resultCallback, isPlatformDeployment ); @@ -379,7 +381,8 @@ export async function exportFullConfiguration({ const serverExport = await exportWithErrorHandling( exportServers, { options: { includeDefault: true }, state }, - errors, + 'Servers', + resultCallback, isClassicDeployment ); if (serverExport) { @@ -392,7 +395,8 @@ export async function exportFullConfiguration({ await exportWithErrorHandling( exportAgents, globalStateObj, - errors, + 'Global Agents', + resultCallback, isClassicDeployment ) )?.agent, @@ -400,7 +404,8 @@ export async function exportFullConfiguration({ await exportWithErrorHandling( exportAuthenticationSettings, globalStateObj, - errors, + 'Global Authentication Settings', + resultCallback, isClassicDeployment ) )?.authentication, @@ -408,7 +413,8 @@ export async function exportFullConfiguration({ await exportWithErrorHandling( exportEmailTemplates, stateObj, - errors, + 'Email Templates', + resultCallback, isPlatformDeployment ) )?.emailTemplate, @@ -420,9 +426,11 @@ export async function exportFullConfiguration({ envReplaceParams: undefined, entitiesToExport: undefined, }, + resultCallback: errorCallback, state, }, - errors, + 'IDM Config Entities', + resultCallback, isPlatformDeployment ) )?.idm, @@ -430,7 +438,8 @@ export async function exportFullConfiguration({ await exportWithErrorHandling( exportInternalRoles, stateObj, - errors, + 'Internal Roles', + resultCallback, isPlatformDeployment ) )?.internalRole, @@ -439,7 +448,8 @@ export async function exportFullConfiguration({ await exportWithErrorHandling( exportRealms, stateObj, - errors, + 'Realms', + resultCallback, includeReadOnly || isClassicDeployment ) )?.realm, @@ -447,7 +457,8 @@ export async function exportFullConfiguration({ await exportWithErrorHandling( exportScriptTypes, stateObj, - errors, + 'Script Types', + resultCallback, includeReadOnly || isClassicDeployment ) )?.scripttype, @@ -455,7 +466,8 @@ export async function exportFullConfiguration({ await exportWithErrorHandling( exportSecrets, { options: { includeActiveValues, target }, state }, - errors, + 'ESV Secrets', + resultCallback, isCloudDeployment ) )?.secret, @@ -463,19 +475,26 @@ export async function exportFullConfiguration({ await exportWithErrorHandling( exportSecretStores, globalStateObj, - errors, + 'Global Secret Stores', + resultCallback, isClassicDeployment ) )?.secretstore, server: serverExport, service: ( - await exportWithErrorHandling(exportServices, globalStateObj, errors) + await exportWithErrorHandling( + exportServices, + globalStateObj, + 'Services', + resultCallback + ) )?.service, site: ( await exportWithErrorHandling( exportSites, stateObj, - errors, + 'Sites', + resultCallback, isClassicDeployment ) )?.site, @@ -487,7 +506,8 @@ export async function exportFullConfiguration({ noDecode, state, }, - errors, + 'ESV Variables', + resultCallback, isCloudDeployment ) )?.variable, @@ -527,13 +547,15 @@ export async function exportFullConfiguration({ (await exportWithErrorHandling( exportSaml2Providers, stateObj, - errors + 'SAML2 Providers', + resultCallback )) as CirclesOfTrustExportInterface )?.saml; const cotExport = await exportWithErrorHandling( exportCirclesOfTrust, stateObj, - errors + 'Circle of Trusts', + resultCallback ); if (saml) { saml.cot = cotExport?.saml.cot; @@ -542,10 +564,20 @@ export async function exportFullConfiguration({ } realmConfig[realm] = { agentGroup: ( - await exportWithErrorHandling(exportAgentGroups, stateObj, errors) + await exportWithErrorHandling( + exportAgentGroups, + stateObj, + 'Agent Groups', + resultCallback + ) )?.agentGroup, agent: ( - await exportWithErrorHandling(exportAgents, realmStateObj, errors) + await exportWithErrorHandling( + exportAgents, + realmStateObj, + 'Agents', + resultCallback + ) )?.agent, application: ( await exportWithErrorHandling( @@ -554,21 +586,24 @@ export async function exportFullConfiguration({ options: { deps: false, useStringArrays }, state, }, - errors + 'OAuth2 Client Applications', + resultCallback ) )?.application, authentication: ( await exportWithErrorHandling( exportAuthenticationSettings, realmStateObj, - errors + 'Authentication Settings', + resultCallback ) )?.authentication, idp: ( await exportWithErrorHandling( exportSocialIdentityProviders, stateObj, - errors + 'Social Identity Providers', + resultCallback ) )?.idp, trees: ( @@ -576,9 +611,11 @@ export async function exportFullConfiguration({ exportJourneys, { options: { deps: false, useStringArrays, coords }, + resultCallback: errorCallback, state, }, - errors + 'Journeys', + resultCallback ) )?.trees, managedApplication: ( @@ -588,7 +625,8 @@ export async function exportFullConfiguration({ options: { deps: false, useStringArrays }, state, }, - errors, + 'Applications', + resultCallback, isPlatformDeployment ) )?.managedApplication, @@ -599,7 +637,8 @@ export async function exportFullConfiguration({ options: { deps: false, prereqs: false, useStringArrays }, state, }, - errors + 'Policies', + resultCallback ) )?.policy, policyset: ( @@ -609,11 +648,17 @@ export async function exportFullConfiguration({ options: { deps: false, prereqs: false, useStringArrays }, state, }, - errors + 'Policy Sets', + resultCallback ) )?.policyset, resourcetype: ( - await exportWithErrorHandling(exportResourceTypes, stateObj, errors) + await exportWithErrorHandling( + exportResourceTypes, + stateObj, + 'Resource Types', + resultCallback + ) )?.resourcetype, saml, script: ( @@ -625,21 +670,29 @@ export async function exportFullConfiguration({ includeDefault, useStringArrays, }, + resultCallback: errorCallback, state, }, - errors + 'Scripts', + resultCallback ) )?.script, secretstore: ( await exportWithErrorHandling( exportSecretStores, realmStateObj, - errors, + 'Secret Stores', + resultCallback, isClassicDeployment ) )?.secretstore, service: ( - await exportWithErrorHandling(exportServices, realmStateObj, errors) + await exportWithErrorHandling( + exportServices, + realmStateObj, + 'Services', + resultCallback + ) )?.service, theme: ( await exportWithErrorHandling( @@ -647,7 +700,8 @@ export async function exportFullConfiguration({ { state, }, - errors, + 'Themes', + resultCallback, isPlatformDeployment ) )?.theme, @@ -658,7 +712,8 @@ export async function exportFullConfiguration({ options: { deps: false, useStringArrays }, state, }, - errors + 'Trusted JWT Issuers', + resultCallback ) )?.trustedJwtIssuer, ...config.realm[realm], @@ -675,22 +730,20 @@ export async function exportFullConfiguration({ state.setRealm(activeRealm); } - if (throwErrors && errors.length > 0) { - throw new FrodoError(`Error exporting full config`, errors); - } - - return { + const fullConfig = { meta: getMetadata(stateObj), global: globalConfig as FullGlobalExportInterface, realm: realmConfig, }; + + return fullConfig; } /** * Import full configuration * @param {FullExportInterface} importData import data * @param {FullImportOptions} options import options - * @param {Error[]} collectErrors optional parameter to collect errors instead of having the function throw. Pass an empty array to collect errors and report on them but have the function perform all it can and return the export data even if it encounters errors. + * @param {ResultCallback} resultCallback Optional callback to process individual results */ export async function importFullConfiguration({ importData, @@ -702,21 +755,15 @@ export async function importFullConfiguration({ includeActiveValues: true, source: '', }, - collectErrors, + resultCallback = void 0, state, }: { importData: FullExportInterface; options: FullImportOptions; - collectErrors?: Error[]; + resultCallback: ResultCallback; state: State; }): Promise<(object | any[])[]> { - const response: (object | any[])[] = []; - let errors: Error[] = []; - let throwErrors: boolean = true; - if (collectErrors && Array.isArray(collectErrors)) { - throwErrors = false; - errors = collectErrors; - } + let response: (object | any[])[] = []; const isClassicDeployment = state.getDeploymentType() === Constants.CLASSIC_DEPLOYMENT_TYPE_KEY; const isCloudDeployment = @@ -732,6 +779,7 @@ export async function importFullConfiguration({ includeActiveValues, source, } = options; + const errorCallback = getErrorCallback(resultCallback); // Import to global let indicatorId = createProgressIndicator({ total: 14, @@ -750,9 +798,9 @@ export async function importFullConfiguration({ }, state, }, - errors, indicatorId, 'Servers', + resultCallback, isClassicDeployment && !!importData.global.server ) ); @@ -765,9 +813,9 @@ export async function importFullConfiguration({ importData: importData.global, state, }, - errors, indicatorId, 'Sites', + resultCallback, isClassicDeployment && !!importData.global.site ) ); @@ -780,9 +828,9 @@ export async function importFullConfiguration({ importData: importData.global, state, }, - errors, indicatorId, 'Realms', + resultCallback, isClassicDeployment && !!importData.global.realm ) ); @@ -794,9 +842,9 @@ export async function importFullConfiguration({ importData: importData.global, state, }, - errors, indicatorId, 'Script Types', + resultCallback, isClassicDeployment && !!importData.global.scripttype ) ); @@ -809,9 +857,9 @@ export async function importFullConfiguration({ secretStoreId: '', state, }, - errors, indicatorId, 'Secret Stores', + resultCallback, isClassicDeployment && !!importData.global.secretstore ) ); @@ -826,9 +874,9 @@ export async function importFullConfiguration({ }, state, }, - errors, indicatorId, 'Secrets', + resultCallback, isCloudDeployment && !!importData.global.secret ) ); @@ -839,9 +887,9 @@ export async function importFullConfiguration({ importData: importData.global, state, }, - errors, indicatorId, 'Variables', + resultCallback, isCloudDeployment && !!importData.global.variable ) ); @@ -855,11 +903,12 @@ export async function importFullConfiguration({ entitiesToImport: undefined, validate: false, }, + resultCallback: errorCallback, state, }, - errors, indicatorId, 'IDM Config Entities', + resultCallback, isPlatformDeployment && !!importData.global.idm ) ); @@ -870,9 +919,9 @@ export async function importFullConfiguration({ importData: importData.global, state, }, - errors, indicatorId, 'Email Templates', + resultCallback, isPlatformDeployment && !!importData.global.emailTemplate ) ); @@ -884,9 +933,9 @@ export async function importFullConfiguration({ options: { deps: false }, state, }, - errors, indicatorId, 'Mappings', + resultCallback, isPlatformDeployment ) ); @@ -898,9 +947,9 @@ export async function importFullConfiguration({ options: { clean: cleanServices, global: true, realm: false }, state, }, - errors, indicatorId, 'Services', + resultCallback, !!importData.global.service ) ); @@ -908,9 +957,9 @@ export async function importFullConfiguration({ await importWithErrorHandling( importAgents, { importData: importData.global, globalConfig: true, state }, - errors, indicatorId, 'Agents', + resultCallback, isClassicDeployment && !!importData.global.agent ) ); @@ -922,9 +971,9 @@ export async function importFullConfiguration({ globalConfig: true, state, }, - errors, indicatorId, 'Authentication Settings', + resultCallback, isClassicDeployment && !!importData.global.authentication ) ); @@ -935,9 +984,9 @@ export async function importFullConfiguration({ importData: importData.global, state, }, - errors, indicatorId, 'Internal Roles', + resultCallback, isPlatformDeployment && !!importData.global.internalRole ) ); @@ -969,11 +1018,12 @@ export async function importFullConfiguration({ includeDefault, }, validate: false, + resultCallback: errorCallback, state, }, - errors, indicatorId, 'Scripts', + resultCallback, !!importData.realm[realm].script ) ); @@ -984,9 +1034,9 @@ export async function importFullConfiguration({ importData: importData.realm[realm], state, }, - errors, indicatorId, 'Themes', + resultCallback, isPlatformDeployment && !!importData.realm[realm].theme ) ); @@ -999,9 +1049,9 @@ export async function importFullConfiguration({ secretStoreId: '', state, }, - errors, indicatorId, 'Secret Stores', + resultCallback, isClassicDeployment && !!importData.realm[realm].secretstore ) ); @@ -1009,9 +1059,9 @@ export async function importFullConfiguration({ await importWithErrorHandling( importAgentGroups, { importData: importData.realm[realm], state }, - errors, indicatorId, 'Agent Groups', + resultCallback, !!importData.realm[realm].agentGroup ) ); @@ -1019,9 +1069,9 @@ export async function importFullConfiguration({ await importWithErrorHandling( importAgents, { importData: importData.realm[realm], globalConfig: false, state }, - errors, indicatorId, 'Agents', + resultCallback, !!importData.realm[realm].agent ) ); @@ -1032,9 +1082,9 @@ export async function importFullConfiguration({ importData: importData.realm[realm], state, }, - errors, indicatorId, 'Resource Types', + resultCallback, !!importData.realm[realm].resourcetype ) ); @@ -1045,9 +1095,9 @@ export async function importFullConfiguration({ importData: importData.realm[realm], state, }, - errors, indicatorId, 'Circles of Trust', + resultCallback, !!importData.realm[realm].saml && !!importData.realm[realm].saml.cot ) ); @@ -1059,9 +1109,9 @@ export async function importFullConfiguration({ options: { deps: false }, state, }, - errors, indicatorId, 'Saml2 Providers', + resultCallback, !!importData.realm[realm].saml ) ); @@ -1073,9 +1123,9 @@ export async function importFullConfiguration({ options: { deps: false }, state, }, - errors, indicatorId, 'Social Identity Providers', + resultCallback, !!importData.realm[realm].idp ) ); @@ -1087,9 +1137,9 @@ export async function importFullConfiguration({ options: { deps: false }, state, }, - errors, indicatorId, 'OAuth2 Clients', + resultCallback, !!importData.realm[realm].application ) ); @@ -1100,9 +1150,9 @@ export async function importFullConfiguration({ importData: importData.realm[realm], state, }, - errors, indicatorId, 'Trusted JWT Issuers', + resultCallback, !!importData.realm[realm].trustedJwtIssuer ) ); @@ -1114,9 +1164,9 @@ export async function importFullConfiguration({ options: { deps: false }, state, }, - errors, indicatorId, 'Applications', + resultCallback, isPlatformDeployment && !!importData.realm[realm].managedApplication ) ); @@ -1128,9 +1178,9 @@ export async function importFullConfiguration({ options: { deps: false, prereqs: false }, state, }, - errors, indicatorId, 'Policy Sets', + resultCallback, !!importData.realm[realm].policyset ) ); @@ -1142,9 +1192,9 @@ export async function importFullConfiguration({ options: { deps: false, prereqs: false }, state, }, - errors, indicatorId, 'Policies', + resultCallback, !!importData.realm[realm].policy ) ); @@ -1154,11 +1204,12 @@ export async function importFullConfiguration({ { importData: importData.realm[realm], options: { deps: false, reUuid: reUuidJourneys }, + resultCallback: errorCallback, state, }, - errors, indicatorId, 'Journeys', + resultCallback, !!importData.realm[realm].trees ) ); @@ -1170,9 +1221,9 @@ export async function importFullConfiguration({ options: { clean: cleanServices, global: false, realm: true }, state, }, - errors, indicatorId, 'Services', + resultCallback, !!importData.realm[realm].service ) ); @@ -1184,9 +1235,9 @@ export async function importFullConfiguration({ globalConfig: false, state, }, - errors, indicatorId, 'Authentication Settings', + resultCallback, !!importData.realm[realm].authentication ) ); @@ -1209,28 +1260,27 @@ export async function importFullConfiguration({ importAmConfigEntities, { importData: importData as unknown as ConfigEntityExportInterface, + resultCallback: errorCallback, state, }, - errors, indicatorId, - 'Other AM Config Entities' + 'Other AM Config Entities', + resultCallback ) ); - stopProgressIndicator({ - id: indicatorId, - message: `Finished Importing all other AM config entities!`, - status: 'success', - state, - }); - if (throwErrors && errors.length > 0) { - throw new FrodoError(`Error importing full config`, errors); - } // Filter out any null or empty results - return response.filter( + response = response.filter( (o) => o && (!Array.isArray(o) || o.length > 0) && (!(o as ServerExportInterface).server || Object.keys((o as ServerExportInterface).server).length > 0) ); + stopProgressIndicator({ + id: indicatorId, + message: `Finished Importing all other AM config entities!`, + status: 'success', + state, + }); + return response; } diff --git a/src/ops/IdmConfigOps.test.ts b/src/ops/IdmConfigOps.test.ts index 31b2927b0..e109aeb4a 100644 --- a/src/ops/IdmConfigOps.test.ts +++ b/src/ops/IdmConfigOps.test.ts @@ -35,6 +35,7 @@ import * as IdmConfigOps from './IdmConfigOps'; import { autoSetupPolly } from '../utils/AutoSetupPolly'; import { filterRecording } from '../utils/PollyUtils'; import { IdObjectSkeletonInterface } from '../api/ApiTypes'; +import { snapshotResultCallback } from '../test/utils/TestUtils'; const ctx = autoSetupPolly(); @@ -272,7 +273,7 @@ describe('IdmConfigOps', () => { }); test('1: Export config entities', async () => { - const response = await IdmConfigOps.exportConfigEntities({ state }); + const response = await IdmConfigOps.exportConfigEntities({ resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); @@ -347,6 +348,7 @@ describe('IdmConfigOps', () => { options: { validate: false, }, + resultCallback: snapshotResultCallback, state, }); expect(response).toMatchSnapshot(); @@ -365,6 +367,7 @@ describe('IdmConfigOps', () => { options: { validate: true, }, + resultCallback: snapshotResultCallback, state, }); expect(response).toMatchSnapshot(); @@ -388,6 +391,7 @@ describe('IdmConfigOps', () => { envReplaceParams: [['en', 'english']], validate: false, }, + resultCallback: snapshotResultCallback, state, }); expect(response).toMatchSnapshot(); @@ -407,6 +411,7 @@ describe('IdmConfigOps', () => { options: { validate: false, }, + resultCallback: snapshotResultCallback, state, }); expect(response).toMatchSnapshot(); diff --git a/src/ops/IdmConfigOps.ts b/src/ops/IdmConfigOps.ts index e04d4c532..3d4a287a3 100644 --- a/src/ops/IdmConfigOps.ts +++ b/src/ops/IdmConfigOps.ts @@ -20,17 +20,19 @@ import { State } from '../shared/State'; import { createProgressIndicator, debugMessage, - printError, - printMessage, stopProgressIndicator, updateProgressIndicator, } from '../utils/Console'; -import { getMetadata } from '../utils/ExportImportUtils'; +import { + getErrorCallback, + getMetadata, + getResult, +} from '../utils/ExportImportUtils'; import { stringify } from '../utils/JsonUtils'; import { areScriptHooksValid } from '../utils/ScriptValidationUtils'; import { FrodoError } from './FrodoError'; import { testConnectorServers as _testConnectorServers } from './IdmSystemOps'; -import { ExportMetaData } from './OpsTypes'; +import { ExportMetaData, ResultCallback } from './OpsTypes'; export type IdmConfig = { /** @@ -73,10 +75,12 @@ export type IdmConfig = { /** * Export all IDM config entities * @param {ConfigEntityExportOptions} options export options + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {ConfigEntityExportInterface} promise resolving to a ConfigEntityExportInterface object */ exportConfigEntities( - options?: ConfigEntityExportOptions + options?: ConfigEntityExportOptions, + resultCallback?: ResultCallback ): Promise; /** * Create config entity @@ -107,25 +111,32 @@ export type IdmConfig = { * @param {ConfigEntityExportInterface} importData idm config entity import data. * @param {string} entityId Optional entity id that, when provided, will only import the entity of that id from the importData * @param {ConfigEntityImportOptions} options import options + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise resolving to an array of config entity objects */ importConfigEntities( importData: ConfigEntityExportInterface, entityId?: string, - options?: ConfigEntityImportOptions + options?: ConfigEntityImportOptions, + resultCallback?: ResultCallback ): Promise; /** * Delete all config entities + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {IdObjectSkeletonInterface[]} promise reolving to an array of config entities */ - deleteConfigEntities(): Promise; + deleteConfigEntities( + resultCallback?: ResultCallback + ): Promise; /** * Delete all config entities of a type * @param {string} type config entity type + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {IdObjectSkeletonInterface[]} promise resolving to an array of config entities of a type */ deleteConfigEntitiesByType( - type: string + type: string, + resultCallback?: ResultCallback ): Promise; /** * Delete config entity @@ -260,9 +271,10 @@ export default (state: State): IdmConfig => { options: ConfigEntityExportOptions = { envReplaceParams: undefined, entitiesToExport: undefined, - } + }, + resultCallback: ResultCallback = void 0 ): Promise { - return exportConfigEntities({ options, state }); + return exportConfigEntities({ options, resultCallback, state }); }, async createConfigEntity( entityId: string, @@ -281,17 +293,27 @@ export default (state: State): IdmConfig => { async importConfigEntities( importData: ConfigEntityExportInterface, entityId?: string, - options: ConfigEntityImportOptions = { validate: false } + options: ConfigEntityImportOptions = { validate: false }, + resultCallback: ResultCallback = void 0 ): Promise { - return importConfigEntities({ entityId, importData, options, state }); + return importConfigEntities({ + entityId, + importData, + options, + resultCallback, + state, + }); }, - async deleteConfigEntities(): Promise { - return deleteConfigEntities({ state }); + async deleteConfigEntities( + resultCallback: ResultCallback = void 0 + ): Promise { + return deleteConfigEntities({ resultCallback, state }); }, async deleteConfigEntitiesByType( - type: string + type: string, + resultCallback: ResultCallback = void 0 ): Promise { - return deleteConfigEntitiesByType({ type, state }); + return deleteConfigEntitiesByType({ type, resultCallback, state }); }, async deleteConfigEntity( entityId: string @@ -537,101 +559,96 @@ export async function exportConfigEntity({ exportData.idm[entity._id] = entity; return exportData; } catch (error) { - printError(error); + throw new FrodoError(`Error getting config entity ${entityId}`, error); } } /** * Export all IDM config entities * @param {ConfigEntityExportOptions} options export options + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {ConfigEntityExportInterface} promise resolving to a ConfigEntityExportInterface object */ export async function exportConfigEntities({ options = { envReplaceParams: undefined, entitiesToExport: undefined }, + resultCallback = void 0, state, }: { options?: ConfigEntityExportOptions; + resultCallback?: ResultCallback; state: State; }): Promise { - let indicatorId: string; - try { - let configurations = await readConfigEntities({ state }); - if (options.entitiesToExport && options.entitiesToExport.length > 0) { - configurations = configurations.filter((c) => - options.entitiesToExport.includes(c._id) - ); - } - indicatorId = createProgressIndicator({ - total: configurations.length, - message: 'Exporting config entities...', + const exportData = createConfigEntityExportTemplate({ state }); + let configurations = await readConfigEntities({ state }); + if (options.entitiesToExport && options.entitiesToExport.length > 0) { + configurations = configurations.filter((c) => + options.entitiesToExport.includes(c._id) + ); + } + const indicatorId = createProgressIndicator({ + total: configurations.length, + message: 'Exporting config entities...', + state, + }); + const entityPromises: Promise[] = []; + for (const configEntity of configurations) { + updateProgressIndicator({ + id: indicatorId, + message: `Exporting config entity ${configEntity._id}`, state, }); - const entityPromises: Promise[] = []; - for (const configEntity of configurations) { - updateProgressIndicator({ - id: indicatorId, - message: `Exporting config entity ${configEntity._id}`, - state, - }); - entityPromises.push( - readConfigEntity({ entityId: configEntity._id, state }).catch( - (readConfigEntityError) => { - const error: FrodoError = readConfigEntityError; - if ( + entityPromises.push( + getResult( + getErrorCallback( + resultCallback, + (error) => + !( // operation is not available in PingOne Advanced Identity Cloud - !( + ( error.httpStatus === 403 && error.httpMessage === 'This operation is not available in PingOne Advanced Identity Cloud.' - ) && - // list of config entities, which do not exist by default or ever. - !( - IDM_UNAVAILABLE_ENTITIES.includes(configEntity._id) && - error.httpStatus === 404 && - error.httpErrorReason === 'Not Found' - ) && - // https://bugster.forgerock.org/jira/browse/OPENIDM-18270 - !( - error.httpStatus === 404 && - error.httpMessage === - 'No configuration exists for id org.apache.felix.fileinstall/openidm' ) - ) { - printMessage({ - message: readConfigEntityError.response?.data, - type: 'error', - state, - }); - printMessage({ - message: `Error getting config entity ${configEntity._id}: ${readConfigEntityError}`, - type: 'error', - state, - }); - } - } - ) + ) && + // list of config entities, which do not exist by default or ever. + !( + IDM_UNAVAILABLE_ENTITIES.includes(configEntity._id) && + error.httpStatus === 404 && + error.httpErrorReason === 'Not Found' + ) && + // https://bugster.forgerock.org/jira/browse/OPENIDM-18270 + !( + error.httpStatus === 404 && + error.httpMessage === + 'No configuration exists for id org.apache.felix.fileinstall/openidm' + ) + ), + `Error exporting idm config entity ${configEntity._id}`, + readConfigEntity, + { entityId: configEntity._id, state } + ) + ); + } + (await Promise.all(entityPromises)) + .filter((c) => c) + .forEach((entity) => { + const substitutedEntity = substituteEntityWithEnv( + entity as IdObjectSkeletonInterface, + options.envReplaceParams ); - } - const exportData = createConfigEntityExportTemplate({ state }); - (await Promise.all(entityPromises)) - .filter((c) => c) - .forEach((entity) => { - exportData.idm[(entity as IdObjectSkeletonInterface)._id] = - substituteEntityWithEnv( - entity as IdObjectSkeletonInterface, - options.envReplaceParams - ); - }); - stopProgressIndicator({ - id: indicatorId, - message: `Exported ${configurations.length} config entities.`, - status: 'success', - state, + exportData.idm[(entity as IdObjectSkeletonInterface)._id] = + substitutedEntity; + if (resultCallback) { + resultCallback(undefined, substitutedEntity); + } }); - return exportData; - } catch (error) { - printError(error); - } + stopProgressIndicator({ + id: indicatorId, + message: `Exported ${configurations.length} config entities.`, + status: 'success', + state, + }); + return exportData; } export async function createConfigEntity({ @@ -697,16 +714,17 @@ export async function importConfigEntities({ entitiesToImport: undefined, validate: false, }, + resultCallback = void 0, state, }: { entityId?: string; importData: ConfigEntityExportInterface; options: ConfigEntityImportOptions; + resultCallback?: ResultCallback; state: State; }): Promise { debugMessage({ message: `IdmConfigOps.importConfigEntities: start`, state }); const response = []; - const errors = []; let ids = Object.keys(importData.idm); if (options.entitiesToImport && options.entitiesToImport.length > 0) { ids = ids.filter((id) => options.entitiesToImport.includes(id)); @@ -732,40 +750,45 @@ export async function importConfigEntities({ `Invalid script hook in the config object '${id}'` ); } - try { - const result = await updateConfigEntity({ - entityId: id, - entityData, - state, - }); - response.push(result); - } catch (error) { - if ( - // protected entities (e.g. root realm email templates) - !( - state.getDeploymentType() === Constants.CLOUD_DEPLOYMENT_TYPE_KEY && - AIC_PROTECTED_ENTITIES.includes(id) && - error.httpStatus === 403 && - error.httpCode === 'ERR_BAD_REQUEST' - ) - ) { - throw error; - } + const result = await updateConfigEntity({ + entityId: id, + entityData, + state, + }); + response.push(result); + if (resultCallback) { + resultCallback(undefined, result); } } catch (error) { - errors.push(error); + if ( + // protected entities (e.g. root realm email templates) + !( + state.getDeploymentType() === Constants.CLOUD_DEPLOYMENT_TYPE_KEY && + AIC_PROTECTED_ENTITIES.includes(id) && + error.httpStatus === 403 && + error.httpCode === 'ERR_BAD_REQUEST' + ) + ) { + if (resultCallback) { + resultCallback(error, undefined); + } else { + throw new FrodoError( + `Error importing idm config entity ${id}`, + error + ); + } + } } } - if (errors.length > 0) { - throw new FrodoError(`Error importing config entities`, errors); - } debugMessage({ message: `IdmConfigOps.importConfigEntities: end`, state }); return response; } export async function deleteConfigEntities({ + resultCallback = void 0, state, }: { + resultCallback?: ResultCallback; state: State; }): Promise { debugMessage({ @@ -773,30 +796,23 @@ export async function deleteConfigEntities({ state, }); const result: IdObjectSkeletonInterface[] = []; - const errors = []; - try { - const configEntityStubs = await readConfigEntityStubs({ state }); - for (const configEntityStub of configEntityStubs) { - try { - debugMessage({ - message: `IdmConfigOps.deleteConfigEntities: '${configEntityStub['_id']}'`, + const configEntityStubs = await readConfigEntityStubs({ state }); + for (const configEntityStub of configEntityStubs) { + debugMessage({ + message: `IdmConfigOps.deleteConfigEntities: '${configEntityStub['_id']}'`, + state, + }); + result.push( + await getResult( + resultCallback, + `Error deleting idm config entity ${configEntityStub._id}`, + _deleteConfigEntity, + { + entityId: configEntityStub['_id'], state, - }); - result.push( - await _deleteConfigEntity({ - entityId: configEntityStub['_id'], - state, - }) - ); - } catch (error) { - errors.push(error); - } - } - } catch (error) { - errors.push(error); - } - if (errors.length > 0) { - throw new FrodoError(`Error deleting config entities`, errors); + } + ) + ); } debugMessage({ message: `IdmConfigOps.deleteConfigEntities: end`, @@ -807,9 +823,11 @@ export async function deleteConfigEntities({ export async function deleteConfigEntitiesByType({ type, + resultCallback = void 0, state, }: { type: string; + resultCallback?: ResultCallback; state: State; }): Promise { debugMessage({ @@ -817,40 +835,29 @@ export async function deleteConfigEntitiesByType({ state, }); const result: IdObjectSkeletonInterface[] = []; - const errors: Error[] = []; - try { - const configEntities = await readConfigEntitiesByType({ type, state }); - for (const configEntity of configEntities) { - try { - debugMessage({ - message: `IdmConfigOps.deleteConfigEntitiesByType: '${configEntity['_id']}'`, - state, - }); - result.push( - await _deleteConfigEntity({ - entityId: configEntity['_id'] as string, - state, - }) - ); - } catch (error) { - errors.push(error); - } - } - if (errors.length > 0) { - throw new FrodoError(`Error deleting config entities by type`, errors); - } + const configEntities = await readConfigEntitiesByType({ type, state }); + for (const configEntity of configEntities) { debugMessage({ - message: `IdmConfigOps.deleteConfigEntitiesByType: end`, + message: `IdmConfigOps.deleteConfigEntitiesByType: '${configEntity['_id']}'`, state, }); - return result; - } catch (error) { - // re-throw previously caught errors - if (errors.length > 0) { - throw error; - } - throw new FrodoError(`Error deleting config entities by type`, error); + result.push( + await getResult( + resultCallback, + `Error deleting idm config entity ${configEntity._id}`, + _deleteConfigEntity, + { + entityId: configEntity['_id'] as string, + state, + } + ) + ); } + debugMessage({ + message: `IdmConfigOps.deleteConfigEntitiesByType: end`, + state, + }); + return result; } export async function deleteConfigEntity({ @@ -899,7 +906,7 @@ export async function readSubConfigEntity({ } return subEntity; } catch (error) { - printError(error); + throw new FrodoError(`Error reading sub config ${entityId} ${name}`, error); } } diff --git a/src/ops/JourneyOps.test.ts b/src/ops/JourneyOps.test.ts index eddae2939..54969abe2 100644 --- a/src/ops/JourneyOps.test.ts +++ b/src/ops/JourneyOps.test.ts @@ -52,6 +52,7 @@ import { getJourney } from '../test/mocks/ForgeRockApiMockEngine'; import { autoSetupPolly } from '../utils/AutoSetupPolly'; import { filterRecording } from '../utils/PollyUtils'; import Constants from '../shared/Constants'; +import { snapshotResultCallback } from '../test/utils/TestUtils'; const ctx = autoSetupPolly(); @@ -347,21 +348,21 @@ describe('JourneyOps', () => { }); test('1: Export journeys w/o dependencies', async () => { - const response = await JourneyOps.exportJourneys({ options: { deps: false, useStringArrays: true, coords: true }, state }); + const response = await JourneyOps.exportJourneys({ options: { deps: false, useStringArrays: true, coords: true }, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); }); test('2: Export journeys w/ dependencies', async () => { - const response = await JourneyOps.exportJourneys({ options: { deps: true, useStringArrays: true, coords: true }, state }); + const response = await JourneyOps.exportJourneys({ options: { deps: true, useStringArrays: true, coords: true }, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); }); test('3: Export journeys w/ dependencies and w/o coordinates', async () => { - const response = await JourneyOps.exportJourneys({ options: { deps: true, useStringArrays: true, coords: false }, state }); + const response = await JourneyOps.exportJourneys({ options: { deps: true, useStringArrays: true, coords: false }, resultCallback: snapshotResultCallback, state }); expect(response).toMatchSnapshot({ meta: expect.any(Object), }); diff --git a/src/ops/JourneyOps.ts b/src/ops/JourneyOps.ts index 220d2d949..72e03ac36 100644 --- a/src/ops/JourneyOps.ts +++ b/src/ops/JourneyOps.ts @@ -58,6 +58,7 @@ import { convertTextArrayToBase64Url, findFilesByName, getMetadata, + getResult, getTypedFilename, } from '../utils/ExportImportUtils'; import { @@ -79,7 +80,7 @@ import { isPremiumNode, removeOrphanedNodes as _removeOrphanedNodes, } from './NodeOps'; -import { type ExportMetaData } from './OpsTypes'; +import { type ExportMetaData, ResultCallback } from './OpsTypes'; import { readSaml2ProviderStubs } from './Saml2Ops'; import { getLibraryScriptNames, @@ -113,10 +114,12 @@ export type Journey = { /** * Create export data for all trees/journeys with all their nodes and dependencies. The export data can be written to a file as is. * @param {TreeExportOptions} options export options + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise that resolves to an object containing the trees and all their nodes and dependencies */ exportJourneys( - options?: TreeExportOptions + options?: TreeExportOptions, + resultCallback?: ResultCallback ): Promise; /** * Read all journeys without dependencies. @@ -176,10 +179,12 @@ export type Journey = { * Import journeys * @param {MultiTreeExportInterface} importData map of trees object * @param {TreeImportOptions} options import options + * @param {ResultCallback} resultCallback Optional callback to process individual results */ importJourneys( importData: MultiTreeExportInterface, - options: TreeImportOptions + options: TreeImportOptions, + resultCallback?: ResultCallback ): Promise; /** * Get the node reference obbject for a node object. Node reference objects @@ -276,11 +281,15 @@ export type Journey = { /** * Delete all journeys * @param {Object} options deep=true also delete all the nodes and inner nodes, verbose=true print verbose info + * @param {ResultCallback} resultCallback Optional callback to process individual results */ - deleteJourneys(options: { - deep: boolean; - verbose: boolean; - }): Promise; + deleteJourneys( + options: { + deep: boolean; + verbose: boolean; + }, + resultCallback?: ResultCallback + ): Promise; /** * Enable a journey * @param journeyId journey id/name @@ -371,9 +380,10 @@ export default (state: State): Journey => { useStringArrays: true, deps: true, coords: true, - } + }, + resultCallback = void 0 ): Promise { - return exportJourneys({ options, state }); + return exportJourneys({ options, resultCallback, state }); }, async readJourneys(): Promise { return readJourneys({ state }); @@ -416,9 +426,15 @@ export default (state: State): Journey => { }, async importJourneys( treesMap: MultiTreeExportInterface, - options: TreeImportOptions + options: TreeImportOptions, + resultCallback = void 0 ): Promise { - return importJourneys({ importData: treesMap, options, state }); + return importJourneys({ + importData: treesMap, + options, + resultCallback, + state, + }); }, getNodeRef( nodeObj: NodeSkeleton, @@ -465,8 +481,11 @@ export default (state: State): Journey => { ) { return deleteJourney({ journeyId, options, state }); }, - async deleteJourneys(options: { deep: boolean; verbose: boolean }) { - return deleteJourneys({ options, state }); + async deleteJourneys( + options: { deep: boolean; verbose: boolean }, + resultCallback = void 0 + ) { + return deleteJourneys({ options, resultCallback, state }); }, async enableJourney(journeyId: string): Promise { return enableJourney({ journeyId, state }); @@ -1354,6 +1373,7 @@ export async function exportJourney({ /** * Create export data for all trees/journeys with all their nodes and dependencies. The export data can be written to a file as is. * @param {TreeExportOptions} options export options + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise that resolves to an object containing the trees and all their nodes and dependencies */ export async function exportJourneys({ @@ -1362,61 +1382,45 @@ export async function exportJourneys({ deps: true, coords: true, }, + resultCallback = void 0, state, }: { options?: TreeExportOptions; + resultCallback?: ResultCallback; state: State; }): Promise { - const errors: Error[] = []; - let indicatorId: string; - try { - const trees = await readJourneys({ state }); - const multiTreeExport = createMultiTreeExportTemplate({ state }); - indicatorId = createProgressIndicator({ - total: trees.length, - message: 'Exporting journeys...', - state, - }); - for (const tree of trees) { - try { - updateProgressIndicator({ - id: indicatorId, - message: `Exporting journey ${tree._id}`, - state, - }); - const exportData: SingleTreeExportInterface = await exportJourney({ - journeyId: tree._id, - options, - state, - }); - delete exportData.meta; - multiTreeExport.trees[tree._id] = exportData; - } catch (error) { - errors.push(error); - } - } - if (errors.length > 0) { - throw new FrodoError(`Error exporting journeys`, errors); - } - stopProgressIndicator({ - id: indicatorId, - message: `Exported ${trees.length} journeys.`, - state, - }); - return multiTreeExport; - } catch (error) { - stopProgressIndicator({ + const multiTreeExport = createMultiTreeExportTemplate({ state }); + const trees = await readJourneys({ state }); + const indicatorId = createProgressIndicator({ + total: trees.length, + message: 'Exporting journeys...', + state, + }); + for (const tree of trees) { + updateProgressIndicator({ id: indicatorId, - message: `Error exporting journeys.`, - status: 'fail', + message: `Exporting journey ${tree._id}`, state, }); - // re-throw previously caught errors - if (errors.length > 0) { - throw error; - } - throw new FrodoError(`Error exporting journeys`, error); + const exportData: SingleTreeExportInterface = await getResult( + resultCallback, + `Error exporting the journey ${tree._id}`, + exportJourney, + { + journeyId: tree._id, + options, + state, + } + ); + delete exportData.meta; + multiTreeExport.trees[tree._id] = exportData; } + stopProgressIndicator({ + id: indicatorId, + message: `Exported ${trees.length} journeys.`, + state, + }); + return multiTreeExport; } /** @@ -2390,18 +2394,20 @@ export async function resolveDependencies( * Import journeys * @param {MultiTreeExportInterface} importData map of trees object * @param {TreeImportOptions} options import options + * @param {ResultCallback} resultCallback Optional callback to process individual results */ export async function importJourneys({ importData, options, + resultCallback = void 0, state, }: { importData: MultiTreeExportInterface; options: TreeImportOptions; + resultCallback?: ResultCallback; state: State; }): Promise { const response = []; - const errors = []; const installedJourneys = (await readJourneys({ state })).map((x) => x._id); const unresolvedJourneys: { [k: string]: string[]; @@ -2452,26 +2458,18 @@ export async function importJourneys({ state, }); for (const tree of resolvedJourneys) { - try { - response.push( - await importJourney({ - importData: importData.trees[tree], - options, - state, - }) - ); - updateProgressIndicator({ id: indicatorId, message: `${tree}`, state }); - } catch (error) { - errors.push(error); - } - } - if (errors.length > 0) { - stopProgressIndicator({ - id: indicatorId, - message: 'Error importing journeys', - state, - }); - throw new FrodoError(`Error importing journeys`, errors); + const result = await getResult( + resultCallback, + `Error importing the journey ${tree._id}`, + importJourney, + { + importData: importData.trees[tree], + options, + state, + } + ); + response.push(result); + updateProgressIndicator({ id: indicatorId, message: `${tree}`, state }); } stopProgressIndicator({ id: indicatorId, @@ -2998,28 +2996,30 @@ export type DeleteJourneysStatus = { /** * Delete all journeys * @param {Object} options deep=true also delete all the nodes and inner nodes, verbose=true print verbose info + * @param {ResultCallback} resultCallback Optional callback to process individual results */ export async function deleteJourneys({ options, + resultCallback = void 0, state, }: { options?: { deep: boolean; verbose: boolean; }; + resultCallback: ResultCallback; state: State; }) { - let indicatorId: string; - try { - const { verbose } = options; - const status: DeleteJourneysStatus = {}; - const trees = (await getTrees({ state })).result; - indicatorId = createProgressIndicator({ - total: trees.length, - message: 'Deleting journeys...', - state, - }); - for (const tree of trees) { + const { verbose } = options; + const status: DeleteJourneysStatus = {}; + const trees = (await getTrees({ state })).result; + const indicatorId = createProgressIndicator({ + total: trees.length, + message: 'Deleting journeys...', + state, + }); + for (const tree of trees) { + try { if (verbose) printMessage({ message: '', state }); options['progress'] = false; status[tree._id] = await deleteJourney({ @@ -3038,38 +3038,39 @@ export async function deleteJourneys({ await new Promise((r) => { setTimeout(r, 100); }); - } - let journeyCount = 0; - let journeyErrorCount = 0; - let nodeCount = 0; - let nodeErrorCount = 0; - for (const journey of Object.keys(status)) { - journeyCount += 1; - if (status[journey].status === 'error') journeyErrorCount += 1; - for (const node of Object.keys(status[journey].nodes)) { - nodeCount += 1; - if (status[journey].nodes[node].status === 'error') nodeErrorCount += 1; + } catch (e) { + if (resultCallback) { + resultCallback(e, undefined); + } else { + throw new FrodoError(`Error deleting the journey ${tree._id}`, e); } } - stopProgressIndicator({ - id: indicatorId, - message: `Deleted ${ - journeyCount - journeyErrorCount - }/${journeyCount} journeys and ${ - nodeCount - nodeErrorCount - }/${nodeCount} nodes.`, - state, - }); - return status; - } catch (error) { - stopProgressIndicator({ - id: indicatorId, - message: `Error deleting journeys`, - status: 'fail', - state, - }); - throw new FrodoError(`Error deleting journeys`, error); } + let journeyCount = 0; + let journeyErrorCount = 0; + let nodeCount = 0; + let nodeErrorCount = 0; + for (const journey of Object.keys(status)) { + journeyCount += 1; + if (status[journey].status === 'error') journeyErrorCount += 1; + for (const node of Object.keys(status[journey].nodes)) { + nodeCount += 1; + if (status[journey].nodes[node].status === 'error') nodeErrorCount += 1; + } + if (resultCallback) { + resultCallback(undefined, status[journey]); + } + } + stopProgressIndicator({ + id: indicatorId, + message: `Deleted ${ + journeyCount - journeyErrorCount + }/${journeyCount} journeys and ${ + nodeCount - nodeErrorCount + }/${nodeCount} nodes.`, + state, + }); + return status; } /** diff --git a/src/ops/OpsTypes.ts b/src/ops/OpsTypes.ts index b1fd07ce6..030165389 100644 --- a/src/ops/OpsTypes.ts +++ b/src/ops/OpsTypes.ts @@ -1,3 +1,5 @@ +import { FrodoError } from './FrodoError'; + export interface ExportMetaData { origin: string; originAmVersion: string; @@ -6,3 +8,7 @@ export interface ExportMetaData { exportTool: string; exportToolVersion: string; } + +export type ResultCallback = (error: FrodoError, result: R) => void; + +export type ErrorFilter = (error: FrodoError) => boolean; diff --git a/src/ops/ScriptOps.test.ts b/src/ops/ScriptOps.test.ts index 2bd8d6593..456695f41 100644 --- a/src/ops/ScriptOps.test.ts +++ b/src/ops/ScriptOps.test.ts @@ -34,6 +34,7 @@ import * as ScriptOps from './ScriptOps'; import { autoSetupPolly } from '../utils/AutoSetupPolly'; import { filterRecording } from '../utils/PollyUtils'; import { ScriptSkeleton } from '../api/ScriptApi'; +import { snapshotResultCallback } from '../test/utils/TestUtils'; const ctx = autoSetupPolly(); @@ -483,6 +484,7 @@ describe('ScriptOps', () => { includeDefault: false, useStringArrays: true, }, + resultCallback: snapshotResultCallback, state, }); expect(response).toMatchSnapshot({ @@ -497,6 +499,7 @@ describe('ScriptOps', () => { includeDefault: true, useStringArrays: true, }, + resultCallback: snapshotResultCallback, state, }); expect(response).toMatchSnapshot({ @@ -521,6 +524,7 @@ describe('ScriptOps', () => { reUuid: false, includeDefault: true, }, + resultCallback: snapshotResultCallback, state, }); expect(outcome).toBeTruthy(); @@ -532,6 +536,7 @@ describe('ScriptOps', () => { scriptId: '', scriptName: import1.name, importData: import1.data, + resultCallback: snapshotResultCallback, state, }); expect(result).toMatchSnapshot(); @@ -543,6 +548,7 @@ describe('ScriptOps', () => { scriptId: import1.id, scriptName: '', importData: import1.data, + resultCallback: snapshotResultCallback, state, }); expect(result).toMatchSnapshot(); @@ -559,6 +565,7 @@ describe('ScriptOps', () => { reUuid: false, includeDefault: false, }, + resultCallback: snapshotResultCallback, state, }); expect(result).toMatchSnapshot(); @@ -603,7 +610,10 @@ describe('ScriptOps', () => { //TODO: Generate mock for this test (skip for meantime) test.skip(`1: delete all scripts`, async () => { expect.assertions(1); - const outcome = await ScriptOps.deleteScripts({ state }); + const outcome = await ScriptOps.deleteScripts({ + resultCallback: snapshotResultCallback, + state + }); expect(outcome).toBeTruthy(); }); }); diff --git a/src/ops/ScriptOps.ts b/src/ops/ScriptOps.ts index 2a770dfce..fa4989d5b 100644 --- a/src/ops/ScriptOps.ts +++ b/src/ops/ScriptOps.ts @@ -3,7 +3,6 @@ import { v4 as uuidv4 } from 'uuid'; import { deleteScript as _deleteScript, deleteScriptByName as _deleteScriptByName, - deleteScripts as _deleteScripts, getLibraryScriptConfigByName, getScript as _getScript, getScriptByName as _getScriptByName, @@ -11,7 +10,7 @@ import { putScript as _putScript, type ScriptSkeleton, } from '../api/ScriptApi'; -import { type ExportMetaData } from '../ops/OpsTypes'; +import { type ExportMetaData, ResultCallback } from '../ops/OpsTypes'; import { State } from '../shared/State'; import { decode, encode, isBase64Encoded } from '../utils/Base64Utils'; import { @@ -25,6 +24,7 @@ import { convertBase64TextToArray, convertTextArrayToBase64, getMetadata, + getResult, } from '../utils/ExportImportUtils'; import { applyNameCollisionPolicy } from '../utils/ForgeRockUtils'; import { isScriptValid } from '../utils/ScriptValidationUtils'; @@ -96,19 +96,27 @@ export type Script = { deleteScriptByName(scriptName: string): Promise; /** * Delete all non-default scripts - * @returns {Promise>} a promise that resolves to an array of script objects + * @param {ResultCallback} resultCallback Optional callback to process individual results + * @returns {Promise} a promise that resolves to an array of script objects */ - deleteScripts(): Promise; + deleteScripts( + resultCallback?: ResultCallback + ): Promise; /** * Export all scripts * @param {ScriptExportOptions} options script export options + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise that resolved to a ScriptExportInterface object */ - exportScripts(options?: ScriptExportOptions): Promise; + exportScripts( + options?: ScriptExportOptions, + resultCallback?: ResultCallback + ): Promise; /** * Export script by id * @param {string} scriptId script uuid * @param {ScriptExportOptions} options script export options + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise that resolved to a ScriptExportInterface object */ exportScript( @@ -132,6 +140,7 @@ export type Script = { * @param {ScriptExportInterface} importData Script import data * @param {ScriptImportOptions} options Script import options * @param {boolean} validate If true, validates Javascript scripts to ensure no errors exist in them. Default: false + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} the imported scripts */ importScripts( @@ -139,7 +148,8 @@ export type Script = { scriptName: string, importData: ScriptExportInterface, options?: ScriptImportOptions, - validate?: boolean + validate?: boolean, + resultCallback?: ResultCallback ): Promise; // Deprecated @@ -230,8 +240,8 @@ export default (state: State): Script => { async deleteScriptByName(scriptName: string): Promise { return deleteScriptByName({ scriptName, state }); }, - async deleteScripts(): Promise { - return deleteScripts({ state }); + async deleteScripts(resultCallback = void 0): Promise { + return deleteScripts({ resultCallback, state }); }, async exportScript( scriptId: string, @@ -258,9 +268,10 @@ export default (state: State): Script => { deps: true, includeDefault: false, useStringArrays: true, - } + }, + resultCallback = void 0 ): Promise { - return exportScripts({ options, state }); + return exportScripts({ options, resultCallback, state }); }, async importScripts( scriptId: string, @@ -271,7 +282,8 @@ export default (state: State): Script => { reUuid: false, includeDefault: false, }, - validate = false + validate = false, + resultCallback = void 0 ): Promise { return importScripts({ scriptId, @@ -279,6 +291,7 @@ export default (state: State): Script => { importData, options, validate, + resultCallback, state, }); }, @@ -613,18 +626,33 @@ export async function deleteScriptByName({ /** * Delete all non-default scripts - * @returns {Promise>} a promise that resolves to an array of script objects + * @param {ResultCallback} resultCallback Optional callback to process individual results + * @returns {Promise} a promise that resolves to an array of script objects */ export async function deleteScripts({ + resultCallback = void 0, state, }: { + resultCallback?: ResultCallback; state: State; }): Promise { - try { - return _deleteScripts({ state }); - } catch (error) { - throw new FrodoError(`Error deleting scripts`, error); + const result = await readScripts({ state }); + //Unable to delete default scripts, so filter them out + const scripts = result.filter((s) => !s.default); + const deletedScripts = []; + for (const script of scripts) { + const result = await getResult( + resultCallback, + `Error deleting script ${script.name}`, + deleteScript, + { + scriptId: script._id, + state, + } + ); + deletedScripts.push(result); } + return deletedScripts; } /** @@ -693,6 +721,7 @@ export async function exportScriptByName({ * Export all scripts * * @param {ScriptExportOptions} options script export options + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} a promise that resolved to a ScriptExportInterface object */ export async function exportScripts({ @@ -701,62 +730,46 @@ export async function exportScripts({ includeDefault: false, useStringArrays: true, }, + resultCallback = void 0, state, }: { options?: ScriptExportOptions; + resultCallback: ResultCallback; state: State; }): Promise { - const errors: Error[] = []; - let indicatorId: string; - try { - const { includeDefault, useStringArrays } = options; - let scriptList = await readScripts({ state }); - if (!includeDefault) - scriptList = scriptList.filter((script) => !script.default); - const exportData = createScriptExportTemplate({ state }); - indicatorId = createProgressIndicator({ - total: scriptList.length, - message: `Exporting ${scriptList.length} scripts...`, - state, - }); - for (const scriptData of scriptList) { - try { - updateProgressIndicator({ - id: indicatorId, - message: `Reading script ${scriptData.name}`, - state, - }); - exportData.script[scriptData._id] = await prepareScriptForExport({ - scriptData, - useStringArrays, - state, - }); - } catch (error) { - errors.push(error); - } - } - if (errors.length > 0) { - throw new FrodoError(``, errors); - } - stopProgressIndicator({ - id: indicatorId, - message: `Exported ${scriptList.length} scripts.`, - state, - }); - return exportData; - } catch (error) { - stopProgressIndicator({ + const { includeDefault, useStringArrays } = options; + let scriptList = await readScripts({ state }); + if (!includeDefault) + scriptList = scriptList.filter((script) => !script.default); + const exportData = createScriptExportTemplate({ state }); + const indicatorId = createProgressIndicator({ + total: scriptList.length, + message: `Exporting ${scriptList.length} scripts...`, + state, + }); + for (const scriptData of scriptList) { + updateProgressIndicator({ id: indicatorId, - message: `Error exporting scripts`, - status: 'fail', + message: `Reading script ${scriptData.name}`, state, }); - // re-throw previously caught error - if (errors.length > 0) { - throw error; - } - throw new FrodoError(`Error exporting scripts`, error); + exportData.script[scriptData._id] = await getResult( + resultCallback, + `Error exporting script ${scriptData.name}`, + prepareScriptForExport, + { + scriptData, + useStringArrays, + state, + } + ); } + stopProgressIndicator({ + id: indicatorId, + message: `Exported ${scriptList.length} scripts.`, + state, + }); + return exportData; } /** @@ -767,6 +780,7 @@ export async function exportScripts({ * @param {ScriptExportInterface} params.importData Script import data * @param {ScriptImportOptions} params.options Script import options * @param {boolean} params.validate If true, validates Javascript scripts to ensure no errors exist in them. Default: false + * @param {ResultCallback} resultCallback Optional callback to process individual results * @returns {Promise} the imported scripts */ export async function importScripts({ @@ -779,6 +793,7 @@ export async function importScripts({ includeDefault: false, }, validate = false, + resultCallback = void 0, state, }: { scriptId?: string; @@ -786,66 +801,61 @@ export async function importScripts({ importData: ScriptExportInterface; options?: ScriptImportOptions; validate?: boolean; + resultCallback?: ResultCallback; state: State; }): Promise { - const errors = []; - try { - debugMessage({ message: `ScriptOps.importScripts: start`, state }); - const response = []; - for (const existingId of Object.keys(importData.script)) { - try { - const scriptData = importData.script[existingId]; - const isDefault = !options.includeDefault && scriptData.default; - // Only import script if the scriptName matches the current script. Note this only applies if we are not importing dependencies since if there are dependencies then we want to import all the scripts in the file. - const shouldNotImportScript = - !options.deps && - ((scriptId && scriptId !== scriptData._id) || - (!scriptId && scriptName && scriptName !== scriptData.name)); - if (isDefault || shouldNotImportScript) continue; + debugMessage({ message: `ScriptOps.importScripts: start`, state }); + const response = []; + for (const existingId of Object.keys(importData.script)) { + try { + const scriptData = importData.script[existingId]; + const isDefault = !options.includeDefault && scriptData.default; + // Only import script if the scriptName matches the current script. Note this only applies if we are not importing dependencies since if there are dependencies then we want to import all the scripts in the file. + const shouldNotImportScript = + !options.deps && + ((scriptId && scriptId !== scriptData._id) || + (!scriptId && scriptName && scriptName !== scriptData.name)); + if (isDefault || shouldNotImportScript) continue; + debugMessage({ + message: `ScriptOps.importScripts: Importing script ${scriptData.name} (${existingId})`, + state, + }); + let newId = existingId; + if (options.reUuid) { + newId = uuidv4(); debugMessage({ - message: `ScriptOps.importScripts: Importing script ${scriptData.name} (${existingId})`, + message: `ScriptOps.importScripts: Re-uuid-ing script ${scriptData.name} ${existingId} => ${newId}...`, state, }); - let newId = existingId; - if (options.reUuid) { - newId = uuidv4(); - debugMessage({ - message: `ScriptOps.importScripts: Re-uuid-ing script ${scriptData.name} ${existingId} => ${newId}...`, - state, - }); - scriptData._id = newId; - } - if (validate) { - if (!isScriptValid({ scriptData, state })) { - errors.push( - new FrodoError( - `Error importing script '${scriptData.name}': Script is not valid` - ) - ); - } + scriptData._id = newId; + } + if (validate) { + if (!isScriptValid({ scriptData, state })) { + throw new FrodoError(`Script is invalid`); } - const result = await updateScript({ - scriptId: newId, - scriptData, - state, - }); - response.push(result); - } catch (error) { - errors.push(error); + } + const result = await updateScript({ + scriptId: newId, + scriptData, + state, + }); + if (resultCallback) { + resultCallback(undefined, result); + } + response.push(result); + } catch (e) { + if (resultCallback) { + resultCallback(e, undefined); + } else { + throw new FrodoError( + `Error importing script '${importData.script[existingId].name}'`, + e + ); } } - if (errors.length > 0) { - throw new FrodoError(`Error importing scripts`, errors); - } - debugMessage({ message: `ScriptOps.importScripts: end`, state }); - return response; - } catch (error) { - // re-throw previously caught errors - if (errors.length > 0) { - throw error; - } - throw new FrodoError(`Error importing scripts`, error); } + debugMessage({ message: `ScriptOps.importScripts: end`, state }); + return response; } /** diff --git a/src/test/snapshots/ops/ConfigOps.test.js.snap b/src/test/snapshots/ops/ConfigOps.test.js.snap index d3f5a4907..77983605d 100644 --- a/src/test/snapshots/ops/ConfigOps.test.js.snap +++ b/src/test/snapshots/ops/ConfigOps.test.js.snap @@ -122055,6 +122055,16 @@ exports[`ConfigOps Classic Tests exportFullConfiguration() 10: Export only globa `; exports[`ConfigOps Cloud Tests exportFullConfiguration() 1: Export everything with string arrays, decoding variables, including journey coordinates and default scripts 1`] = ` +"Error exporting idm config entity fidc/federation-EntraID + Error reading config entity fidc/federation-EntraID + HTTP client error + Code: ERR_BAD_REQUEST + Status: 403 + Reason: Forbidden + Message: Access denied" +`; + +exports[`ConfigOps Cloud Tests exportFullConfiguration() 1: Export everything with string arrays, decoding variables, including journey coordinates and default scripts 2`] = ` { "global": { "agent": undefined, @@ -197406,6 +197416,16 @@ isGoogleEligible; `; exports[`ConfigOps Cloud Tests exportFullConfiguration() 2: Export everything without string arrays, decoding variables, excluding journey coordinates and default scripts 1`] = ` +"Error exporting idm config entity fidc/federation-EntraID + Error reading config entity fidc/federation-EntraID + HTTP client error + Code: ERR_BAD_REQUEST + Status: 403 + Reason: Forbidden + Message: Access denied" +`; + +exports[`ConfigOps Cloud Tests exportFullConfiguration() 2: Export everything without string arrays, decoding variables, excluding journey coordinates and default scripts 2`] = ` { "global": { "agent": undefined, @@ -262181,6 +262201,16 @@ outcome = "true"; `; exports[`ConfigOps Cloud Tests exportFullConfiguration() 3: Export only importable config with string arrays, decoding variables, including journey coordinates and default scripts 1`] = ` +"Error exporting idm config entity fidc/federation-EntraID + Error reading config entity fidc/federation-EntraID + HTTP client error + Code: ERR_BAD_REQUEST + Status: 403 + Reason: Forbidden + Message: Access denied" +`; + +exports[`ConfigOps Cloud Tests exportFullConfiguration() 3: Export only importable config with string arrays, decoding variables, including journey coordinates and default scripts 2`] = ` { "global": { "agent": undefined, @@ -347651,6 +347681,16 @@ exports[`ConfigOps Cloud Tests exportFullConfiguration() 4: Export only alpha re `; exports[`ConfigOps Cloud Tests exportFullConfiguration() 5: Export only global config with string arrays, decoding variables, including journey coordinates and default scripts 1`] = ` +"Error exporting idm config entity fidc/federation-EntraID + Error reading config entity fidc/federation-EntraID + HTTP client error + Code: ERR_BAD_REQUEST + Status: 403 + Reason: Forbidden + Message: Access denied" +`; + +exports[`ConfigOps Cloud Tests exportFullConfiguration() 5: Export only global config with string arrays, decoding variables, including journey coordinates and default scripts 2`] = ` { "global": { "agent": undefined, diff --git a/src/test/snapshots/ops/IdmConfigOps.test.js.snap b/src/test/snapshots/ops/IdmConfigOps.test.js.snap index 0cbae1960..dcf7a2b7b 100644 --- a/src/test/snapshots/ops/IdmConfigOps.test.js.snap +++ b/src/test/snapshots/ops/IdmConfigOps.test.js.snap @@ -35,6 +35,16 @@ exports[`IdmConfigOps createConfigEntity() 2: Create a config entity 'emailTempl `; exports[`IdmConfigOps exportConfigEntities() 1: Export config entities 1`] = ` +"Error exporting idm config entity fidc/federation-EntraID + Error reading config entity fidc/federation-EntraID + HTTP client error + Code: ERR_BAD_REQUEST + Status: 403 + Reason: Forbidden + Message: Access denied" +`; + +exports[`IdmConfigOps exportConfigEntities() 1: Export config entities 2`] = ` { "idm": { "access": { diff --git a/src/test/utils/TestUtils.ts b/src/test/utils/TestUtils.ts index 279066989..6f849bc8e 100644 --- a/src/test/utils/TestUtils.ts +++ b/src/test/utils/TestUtils.ts @@ -242,3 +242,9 @@ export function printError(error: Error, message?: string) { break; } } + +export function snapshotResultCallback(error: FrodoError) { + if (error) { + expect(error.getCombinedMessage()).toMatchSnapshot(); + } +} diff --git a/src/utils/ExportImportUtils.ts b/src/utils/ExportImportUtils.ts index 7e288a6fa..720536094 100644 --- a/src/utils/ExportImportUtils.ts +++ b/src/utils/ExportImportUtils.ts @@ -5,7 +5,8 @@ import { Reader } from 'properties-reader'; import replaceall from 'replaceall'; import slugify from 'slugify'; -import { ExportMetaData } from '../ops/OpsTypes'; +import { FrodoError } from '../ops/FrodoError'; +import { ErrorFilter, ExportMetaData, ResultCallback } from '../ops/OpsTypes'; import Constants from '../shared/Constants'; import { State } from '../shared/State'; import { @@ -605,63 +606,48 @@ export function isValidUrl(urlString: string): boolean { } } -/** - * Helper that performs an export or import given a function with its parameters with custom error handling that will just print the error if one is thrown and return null. - * @param func The export or import function. - * @param parameters The parameters to call the export or import function with. By default, it is { state }. - * @param {Error[]} errors Parameter to collect errors that occur. - * @param perform Performs and returns the export if true, otherwise returns null. Default: true - * @returns {Promise} Returns the result of the export or import function, or null if an error is thrown - */ -async function exportOrImportWithErrorHandling

( - func: (params: P) => Promise, - parameters: P, - errors: Error[], - perform: boolean = true -): Promise { - try { - return perform ? await func(parameters) : null; - } catch (error) { - if (errors && Array.isArray(errors)) { - errors.push(error); - } - return null; - } -} - /** * Performs an export given a function with its parameters with custom error handling that will just print the error if one is thrown and return null. * @param func The export function. * @param parameters The parameters to call the export function with. By default, it is { state }. - * @param errors Parameter to collect errors that occur. + * @param type The type (plural) of the entities being imported + * @param {ResultCallback} resultCallback Optional callback to process individual results * @param perform Performs and returns the export if true, otherwise returns null. Default: true * @returns {Promise} Returns the result of the export function, or null if an error is thrown or perform is false */ export async function exportWithErrorHandling

( func: (params: P) => Promise, parameters: P, - errors: Error[], + type: string, + resultCallback = void 0, perform: boolean = true ): Promise { - return exportOrImportWithErrorHandling(func, parameters, errors, perform); + return perform + ? await getResult( + resultCallback, + `Error Exporting ${type}`, + func, + parameters + ) + : null; } /** * Performs an import given a function with its parameters with custom error handling that will just print the error if one is thrown and return null. * @param func The import function. * @param parameters The parameters to call the import function with. By default, it is { state }. - * @param errors Parameter to collect errors that occur. * @param id Indicator id for the progress indicator * @param type The type (plural) of the entities being imported + * @param {ResultCallback} resultCallback Optional callback to process individual results * @param perform Performs and returns the export if true, otherwise returns null. Default: true * @returns {Promise} Returns the result of the import function, or null if an error is thrown */ export async function importWithErrorHandling

( func: (params: P) => Promise, parameters: P, - errors: Error[], id: string, type: string, + resultCallback = void 0, perform: boolean = true ): Promise { updateProgressIndicator({ @@ -669,5 +655,56 @@ export async function importWithErrorHandling

( message: perform ? `Importing ${type}...` : `Skipping ${type}...`, state: parameters.state, }); - return exportOrImportWithErrorHandling(func, parameters, errors, perform); + return perform + ? await getResult( + resultCallback, + `Error Importing ${type}`, + func, + parameters + ) + : null; +} + +export async function getResult( + resultCallback: ResultCallback | undefined, + errorMessage: string, + func: (...params: any) => Promise, + ...parameters: any +): Promise { + try { + const result = await func(...parameters); + if (resultCallback) { + resultCallback(undefined, result); + } + return result; + } catch (e) { + const error = errorMessage ? new FrodoError(errorMessage, e) : e; + if (resultCallback) { + resultCallback(error, undefined); + } else { + throw error; + } + } +} + +/** + * Transforms a ResultCallback into another ResultCallback that handles only errors and ignores results. + * @param resultCallback The result callback function + * @param errorFilter Filter that returns true when the error should be handled, false otherwise + * @returns The new result callback function that handles only errors + */ +export function getErrorCallback( + resultCallback: ResultCallback, + errorFilter: ErrorFilter = () => true +): ResultCallback { + return (e: FrodoError) => { + if (!e || !errorFilter(e)) { + return; + } + if (resultCallback) { + resultCallback(e, undefined); + return; + } + throw e; + }; }