diff --git a/package-lock.json b/package-lock.json index b4e8cb9..fc37b45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "auth0-source-control-extension-tools", - "version": "4.1.12", + "version": "5.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4364,9 +4364,9 @@ "integrity": "sha1-/sfervF+fDoKVeHaBCgD4l2RdF0=" }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, "lodash.clonedeep": { "version": "4.5.0", diff --git a/package.json b/package.json index 665ea82..9acf72d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auth0-source-control-extension-tools", - "version": "4.1.12", + "version": "5.0.0", "description": "Supporting tools for the Source Control extensions", "main": "lib/index.js", "scripts": { @@ -20,6 +20,7 @@ "auth0-extension-tools": "^1.5.0", "babel-preset-env": "^1.7.0", "dot-prop": "^4.2.0", + "lodash": "^4.17.20", "promise-pool-executor": "^1.1.1", "rimraf": "^2.5.4", "winston": "^2.2.0" diff --git a/src/auth0/handlers/actionBindings.js b/src/auth0/handlers/actionBindings.js new file mode 100644 index 0000000..a15f4df --- /dev/null +++ b/src/auth0/handlers/actionBindings.js @@ -0,0 +1,195 @@ +import DefaultHandler from './default'; +import log from '../../logger'; + +const triggers = [ + 'post-login', + 'credentials-exchange' +]; + +function wait(n) { return new Promise(resolve => setTimeout(resolve, n)); } + +async function createBindingWithRetryAndTimeout(client, params, data, retry) { + let binding = {}; + try { + if (retry > 0) { + binding = await client.createActionBinding(params, data); + } + } catch (err) { + await wait(1000); + binding = await createBindingWithRetryAndTimeout(client, params, data, retry - 1); + } + return binding; +} + +export default class ActionBindingHandler extends DefaultHandler { + constructor(options) { + super({ + ...options, + type: 'actionBindings' + }); + } + + async getType() { + if (this.existing) { + return this.existing; + } + + // in case client version does not support actions + if ( + !this.client.actionBindings + || typeof this.client.actionBindings.getAll !== 'function' + ) { + return []; + } + const bindingsResult = []; + try { + await Promise.all( + triggers.map(trigger => this.client.actionBindings + .getAll({ trigger_id: trigger, detached: true }) + .then(b => b.bindings.forEach(binding => bindingsResult.push({ detached: true, ...binding })))) + ); + // get all attached bindings + await Promise.all( + triggers.map(trigger => this.client.actionBindings + .getAll({ trigger_id: trigger, detached: false }) + .then(b => b.bindings.forEach(binding => bindingsResult.push({ detached: false, ...binding })))) + ); + this.existing = bindingsResult; + return this.existing; + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 501) { + return []; + } + throw err; + } + } + + async detachBinding(binding, detached) { + const params = { trigger_id: binding.trigger_id }; + const existing = await this.getType(); + let attachedList = []; + existing.forEach((existingBinding) => { + if (!existingBinding.detached) { + attachedList.push({ id: existingBinding.id }); + } + }); + if (!detached) { + attachedList.push({ id: binding.id }); + } else { + attachedList = attachedList.filter(id => id === binding.id); + } + delete params.binding_id; + await this.client.actionBindings.updateList(params, { + bindings: attachedList + }); + } + + // Create Binding creates a atacched binding + async createActionBinding(data) { + const retries = 10; + const params = { trigger_id: data.trigger_id }; + const actionBinding = { action_id: data.action_id, display_name: data.display_name }; + const created = await createBindingWithRetryAndTimeout(this.client, params, actionBinding, retries); + + if (!created) { + throw new Error(`Couldn't create binding after ${retries} retries`); + } + // connect binding + await this.detachBinding(created, false); + + return created; + } + + async createActionBindings(creates) { + await this.client.pool + .addEachTask({ + data: creates || [], + generator: item => this.createActionBinding(item) + .then((data) => { + this.didCreate({ binding_id: data.id }); + this.created += 1; + }) + .catch((err) => { + throw new Error( + `Problem creating ${this.type} ${this.objString(item)}\n${err}` + ); + }) + }) + .promise(); + } + + async deleteActionBinding(binding) { + // detach binding + await this.detachBinding(binding, true); + // delete binding + await this.client.actionBindings.delete({ + trigger_id: binding.trigger_id, + binding_id: binding.id + }); + } + + async deleteActionBindings(dels) { + if (this.config('AUTH0_ALLOW_DELETE') === 'true' || this.config('AUTH0_ALLOW_DELETE') === true) { + await this.client.pool + .addEachTask({ + data: dels || [], + generator: item => this.deleteActionBinding(item) + .then(() => { + this.didDelete({ binding_id: item.id }); + this.deleted += 1; + }) + .catch((err) => { + throw new Error(`Problem deleting ${this.type} ${this.objString(item)}\n${err}`); + }) + }) + .promise(); + } else { + log.warn(`Detected the following actions bindings should be deleted. Doing so may be destructive.\nYou can enable deletes by setting 'AUTH0_ALLOW_DELETE' to true in the config + \n${dels.map(i => this.objString(i)).join('\n')}`); + } + } + + async calcChanges(action, bindingsTriggers, existing) { + // Calculate the changes required between two sets of assets. + let del = [ ...existing ]; + const create = []; + + bindingsTriggers.forEach((binding) => { + const found = existing.find( + existingActionBinding => existingActionBinding.trigger_id === binding.trigger_id + ); + if (found) { + del = del.filter(e => e !== found); + } else { + create.push({ + display_name: action.name, + action_id: action.id, + trigger_id: binding.trigger_id + }); + } + }); + // Figure out what needs to be deleted and created + return { del, create }; + } + + async processChanges(changes) { + log.info( + `Start processChanges for actions bindings [delete:${changes.del.length}], [create:${changes.create.length}]` + ); + const myChanges = [ { del: changes.del }, { create: changes.create } ]; + await Promise.all( + myChanges.map(async (change) => { + switch (true) { + case change.del && change.del.length > 0: + await this.deleteActionBindings(change.del); + break; + case change.create && change.create.length > 0: + await this.createActionBindings(changes.create); + break; + default: + break; + } + }) + ); + } +} diff --git a/src/auth0/handlers/actionVersions.js b/src/auth0/handlers/actionVersions.js new file mode 100644 index 0000000..6a39d97 --- /dev/null +++ b/src/auth0/handlers/actionVersions.js @@ -0,0 +1,154 @@ +import DefaultHandler from './default'; +import log from '../../logger'; +import { isArrayEqual } from '../../utils'; + +export default class ActionVersionHandler extends DefaultHandler { + constructor(options) { + super({ + ...options, + type: 'actionVersions' + }); + } + + async getType(actionId) { + if (!actionId) { + return []; + } + + if (this.existing) { + return this.existing; + } + + // in case client version does not support actionVersions + if (!this.client.actionVersions || typeof this.client.actionVersions.getAll !== 'function') { + return []; + } + + try { + this.existing = await this.client.actionVersions.getAll({ action_id: actionId }); + return this.existing; + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 501) { + return []; + } + throw err; + } + } + + async getVersionById(actionId, currentVersion) { + // in case client version does not support actionVersions + if (!this.client.actionVersions || typeof this.client.actionVersions.get !== 'function') { + return null; + } + // in case action doesn't have a current version yet + if (!currentVersion) { + return null; + } + + try { + return await this.client.actionVersions.get({ action_id: actionId, version_id: currentVersion.id }); + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 501) { + return null; + } + throw err; + } + } + + async createActionVersion(version) { + const actionId = version.action_id; + const versionToCreate = { + code: version.code, + dependencies: version.dependencies, + secrets: version.secrets, + runtime: version.runtime + }; + const newVersion = await this.client.actionVersions.create({ action_id: actionId }, versionToCreate); + // create draft version + await this.client.actionVersions.upsertDraft({ action_id: actionId, version_id: 'draft' }, versionToCreate); + return newVersion; + } + + async createActionVersions(creates) { + await this.client.pool.addEachTask({ + data: creates || [], + generator: item => this.createActionVersion(item).then((data) => { + this.didCreate({ version_id: data.id }); + this.created += 1; + }).catch((err) => { + throw new Error(`Problem creating ${this.type} ${this.objString(item)}\n${err}`); + }) + }).promise(); + } + + async deleteActionVersion(version) { + await this.client.actionVersions.delete({ action_id: version.action.id, version_id: version.id }); + } + + async deleteActionVersions(dels) { + if (this.config('AUTH0_ALLOW_DELETE') === 'true' || this.config('AUTH0_ALLOW_DELETE') === true) { + await this.client.pool.addEachTask({ + data: dels || [], + generator: actionVersion => this.deleteActionVersion(actionVersion).then(() => { + this.didDelete({ version_id: actionVersion.id }); + this.deleted += 1; + }).catch((err) => { + throw new Error(`Problem deleting ${this.type} ${this.objString(actionVersion)}\n${err}`); + }) + }).promise(); + } else { + log.warn(`Detected the following action versions should be deleted. Doing so may be destructive.\nYou can enable deletes by setting 'AUTH0_ALLOW_DELETE' to true in the config + \n${dels.map(i => this.objString(i)).join('\n')}`); + } + } + + calcCurrentVersionChanges(actionId, currentVersionAssets, existing) { + const del = []; + const create = []; + + // Figure out what needs to be deleted or created + if (!currentVersionAssets && !existing) { + return { del, create }; + } + + if (!currentVersionAssets && existing) { + del.push({ ...existing, action_id: actionId }); + return { del, create }; + } + + if (currentVersionAssets && !existing) { + create.push({ ...currentVersionAssets, action_id: actionId }); + return { del, create }; + } + + if (currentVersionAssets.code !== existing.code + || currentVersionAssets.runtime !== existing.runtime + || !isArrayEqual(currentVersionAssets.dependencies, existing.dependencies) + || !isArrayEqual((currentVersionAssets.secrets || []).map(s => s.name), (existing.secrets || []).map(s => s.name))) { + create.push({ ...currentVersionAssets, action_id: actionId }); + } + + return { + del, + create + }; + } + + async processChanges(changes) { + log.info(`Start processChanges for action versions [delete:${changes.del.length}], [create:${changes.create.length}]`); + + const myChanges = [ { del: changes.del }, { create: changes.create } ]; + await Promise.all(myChanges.map(async (change) => { + switch (true) { + case change.del && change.del.length > 0: + await this.deleteActionVersions(change.del); + break; + case change.create && change.create.length > 0: + await this.createActionVersions(changes.create); + break; + default: + break; + } + })); + } +} diff --git a/src/auth0/handlers/actions.js b/src/auth0/handlers/actions.js new file mode 100644 index 0000000..5443739 --- /dev/null +++ b/src/auth0/handlers/actions.js @@ -0,0 +1,295 @@ +/* eslint-disable consistent-return */ +import DefaultHandler, { order } from './default'; +import log from '../../logger'; +import ActionVersionHandler from './actionVersions'; +import ActionBindingsHandler from './actionBindings'; + +// With this schema, we can only validate property types but not valid properties on per type basis +export const schema = { + type: 'array', + items: { + type: 'object', + required: [ 'name', 'supported_triggers' ], + additionalProperties: false, + properties: { + name: { type: 'string', default: '' }, + supported_triggers: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', default: '' }, + version: { type: 'string' }, + url: { type: 'string' } + } + } + }, + required_configuration: { + type: 'array', + items: { + type: 'object', + required: [ 'name', 'label', 'type' ], + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + type: { type: 'string' }, + placeholder: { type: 'string' }, + description: { type: 'string' }, + default_value: { type: 'string' } + } + } + }, + required_secrets: { + type: 'array', + items: { + type: 'object', + required: [ 'name', 'label', 'type' ], + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + type: { type: 'string' }, + placeholder: { type: 'string' }, + description: { type: 'string' }, + default_value: { type: 'string' } + } + } + }, + current_version: { + type: 'object', + properties: { + code: { type: 'string', default: '' }, + dependencies: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + name: { type: 'string' }, + version: { type: 'string' }, + registry_url: { type: 'string' } + } + } + }, + runtime: { type: 'string', default: '' }, + secrets: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + value: { type: 'string' }, + updated_at: { type: 'string', format: 'date-time' } + } + } + } + }, + required: [ 'code', 'dependencies', 'runtime' ] + }, + bindings: { + type: 'array', + items: { + type: 'object', + required: [ 'trigger_id' ], + properties: { + trigger_id: { type: 'string' } + } + } + } + } + } +}; + +export default class ActionHandler extends DefaultHandler { + constructor(options) { + super({ + ...options, + type: 'actions' + }); + this.actionVersionHandler = new ActionVersionHandler(options); + this.actionBindingsHandler = new ActionBindingsHandler(options); + } + + async getType() { + if (this.existing) { + return this.existing; + } + + // in case client version does not support actions + if (!this.client.actions || typeof this.client.actions.getAll !== 'function') { + return []; + } + + try { + const actions = await this.client.actions.getAll(); + // need to get complete current version and all bindings for each action + // the current_version inside the action doesn't have all the necessary information + this.existing = await Promise.all(actions.actions.map(action => this.actionVersionHandler.getVersionById(action.id, action.current_version) + .then(async (currentVersion) => { + const bindings = await this.getActionBinding(action.id); + return ({ ...action, current_version: currentVersion, bindings: bindings }); + }))); + return this.existing; + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 501) { + return []; + } + throw err; + } + } + + async createAction(data) { + const action = { ...data }; + const currentVersion = action.current_version; + // eslint-disable-next-line prefer-destructuring + const bindings = action.bindings; + const actionToCreate = { + name: action.name, + supported_triggers: action.supported_triggers + }; + + const created = await this.client.actions.create(actionToCreate); + if (currentVersion) { + await this.actionVersionHandler.createActionVersions([ { ...currentVersion, action_id: created.id } ]); + } + + if (bindings) { + const bindingsToCreate = []; + bindings.forEach(f => bindingsToCreate.push({ trigger_id: f.trigger_id, display_name: action.name, action_id: created.id })); + await this.actionBindingsHandler.createActionBindings(bindingsToCreate); + } + return created; + } + + async createActions(creates) { + await this.client.pool.addEachTask({ + data: creates || [], + generator: item => this.createAction(item).then((data) => { + this.didCreate({ action_id: data.id }); + this.created += 1; + }).catch((err) => { + throw new Error(`Problem creating ${this.type} ${this.objString(item)}\n${err}`); + }) + }).promise(); + } + + async deleteAction(action) { + await this.actionBindingsHandler.deleteActionBindings(action.bindings); + await this.client.actions.delete({ action_id: action.id }); + } + + async deleteActions(dels) { + if (this.config('AUTH0_ALLOW_DELETE') === 'true' || this.config('AUTH0_ALLOW_DELETE') === true) { + await this.client.pool.addEachTask({ + data: dels || [], + generator: action => this.deleteAction(action).then(() => { + this.didDelete({ action_id: action.id }); + this.deleted += 1; + }).catch((err) => { + throw new Error(`Problem deleting ${this.type} ${this.objString({ action_id: action.id })}\n${err}`); + }) + }).promise(); + } else { + log.warn(`Detected the following actions should be deleted. Doing so may be destructive.\nYou can enable deletes by setting 'AUTH0_ALLOW_DELETE' to true in the config + \n${dels.map(i => this.objString(i)).join('\n')}`); + } + } + + async updateAction(action, existing) { + const found = existing.find(existingAction => existingAction.name === action.name); + + // update current version + const currentVersionChanges = this.actionVersionHandler.calcCurrentVersionChanges(found.id, action.current_version, found.current_version); + if (currentVersionChanges.del.length > 0 || currentVersionChanges.create.length > 0) { + await this.actionVersionHandler.processChanges(currentVersionChanges); + } + + const bindingChanges = await this.actionBindingsHandler.calcChanges(found, action.bindings, found.bindings); + if (bindingChanges.del.length > 0 || bindingChanges.create.length > 0) { + await this.actionBindingsHandler.processChanges(bindingChanges); + } + + return found; + } + + async updateActions(updates, actions) { + await this.client.pool.addEachTask({ + data: updates || [], + generator: item => this.updateAction(item, actions).then((data) => { + this.didUpdate({ action_id: data.id }); + this.updated += 1; + }).catch((err) => { + throw new Error(`Problem updating ${this.type} ${this.objString(item)}\n${err}`); + }) + }).promise(); + } + + async getActionBinding(actionId) { + const bindings = await this.actionBindingsHandler.getType(); + return bindings.filter(b => b.action.id === actionId); + } + + async calcChanges(actionsAssets, existing) { + // Calculate the changes required between two sets of assets. + const update = []; + let del = [ ...existing ]; + const create = []; + + actionsAssets.forEach(async (action) => { + const found = existing.find(existingAction => existingAction.name === action.name); + if (found) { + del = del.filter(e => e.id !== found.id); + // current version changes + const currentVersionChanges = this.actionVersionHandler.calcCurrentVersionChanges(found.id, action.current_version, found.current_version); + const hasCurrentVersionChanges = currentVersionChanges.del.length > 0 || currentVersionChanges.create.length > 0; + // bindings changes + const bindingChanges = await this.actionBindingsHandler.calcChanges(found, action.bindings, found.bindings); + const hasBindingChanges = bindingChanges.del.length > 0 || bindingChanges.create.length > 0; + if (action.name !== found.name + || hasCurrentVersionChanges + || hasBindingChanges) { + update.push(action); + } + } else { + create.push(action); + } + }); + + // Figure out what needs to be updated vs created + return { + del, + update, + create + }; + } + + @order('60') + async processChanges(assets) { + // eslint-disable-next-line prefer-destructuring + const actions = assets.actions; + + // Do nothing if not set + if (!actions) return {}; + + const existing = await this.getType(); + + const changes = await this.calcChanges(actions, existing); + + log.info(`Start processChanges for actions [delete:${changes.del.length}] [update:${changes.update.length}], [create:${changes.create.length}]`); + const myChanges = [ { del: changes.del }, { create: changes.create }, { update: changes.update } ]; + await Promise.all(myChanges.map(async (change) => { + switch (true) { + case change.del && change.del.length > 0: + await this.deleteActions(change.del); + break; + case change.create && change.create.length > 0: + await this.createActions(changes.create); + break; + case change.update && change.update.length > 0: + await this.updateActions(change.update, existing); + break; + default: + break; + } + })); + } +} diff --git a/src/auth0/handlers/index.js b/src/auth0/handlers/index.js index 9436175..f403214 100644 --- a/src/auth0/handlers/index.js +++ b/src/auth0/handlers/index.js @@ -20,6 +20,7 @@ import * as roles from './roles'; import * as branding from './branding'; import * as prompts from './prompts'; import * as migrations from './migrations'; +import * as actions from './actions'; export { rules, @@ -43,5 +44,6 @@ export { roles, branding, prompts, - migrations + migrations, + actions }; diff --git a/src/constants.js b/src/constants.js index 79bf831..a8a4854 100644 --- a/src/constants.js +++ b/src/constants.js @@ -9,6 +9,7 @@ constants.DEFAULT_RULE_STAGE = constants.RULES_STAGES[0]; // eslint-disable-lin constants.HOOKS_HIDDEN_SECRET_VALUE = '_VALUE_NOT_SHOWN_'; constants.HOOKS_DIRECTORY = 'hooks'; +constants.ACTIONS_DIRECTORY = 'actions'; constants.RULES_CONFIGS_DIRECTORY = 'rules-configs'; diff --git a/src/utils.js b/src/utils.js index 64cd470..b351c48 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,9 +1,9 @@ import path from 'path'; import fs from 'fs'; import dotProp from 'dot-prop'; +import _ from 'lodash'; import log from './logger'; - export function keywordReplace(input, mappings) { // Replace keywords with mappings within input. if (mappings && Object.keys(mappings).length > 0) { @@ -231,3 +231,7 @@ export function filterExcluded(changes, exclude) { conflicts: filter(conflicts) }; } + +export function isArrayEqual(x, y) { + return _(x).differenceWith(y, _.isEqual).isEmpty(); +} diff --git a/tests/auth0/handlers/actions.tests.js b/tests/auth0/handlers/actions.tests.js new file mode 100644 index 0000000..85becd1 --- /dev/null +++ b/tests/auth0/handlers/actions.tests.js @@ -0,0 +1,416 @@ +const { expect } = require('chai'); +const actions = require('../../../src/auth0/handlers/actions'); + +const pool = { + addEachTask: (data) => { + if (data.data && data.data.length) { + data.generator(data.data[0]); + } + return { promise: () => null }; + } +}; + +describe('#actions handler', () => { + const config = function(key) { + return config.data && config.data[key]; + }; + + config.data = { + AUTH0_ALLOW_DELETE: true + }; + + describe('#Actions validate', () => { + it('should not allow same names', (done) => { + const auth0 = { + actions: { + getAll: () => [] + } + }; + + const handler = new actions.default({ client: auth0, config }); + const stageFn = Object.getPrototypeOf(handler).validate; + const data = [ + { + name: 'actions-one', + supported_triggers: [ { + id: 'post-login', + version: 'v1' + } ] + }, + { + name: 'actions-one', + supported_triggers: [ { + id: 'credentials-exchange', + version: 'v1' + } ] + } + ]; + + stageFn.apply(handler, [ { actions: data } ]) + .then(() => done(new Error('Expecting error'))) + .catch((err) => { + expect(err).to.be.an('object'); + expect(err.message).to.include('Names must be unique'); + done(); + }); + }); + + it('should pass validation', async () => { + const auth0 = { + actions: { + getAll: () => [] + } + }; + + const handler = new actions.default({ client: auth0, config }); + const stageFn = Object.getPrototypeOf(handler).validate; + const data = [ + { + name: 'action-one', + supported_triggers: [ { + id: 'post-login', + version: 'v1' + } ], + current_version: { + code: 'some code', + dependencies: [], + secrets: [], + runtime: 'node12' + } + }, + { + name: 'action-two', + supported_triggers: [ { + id: 'post-login', + version: 'v1' + } ], + current_version: { + code: '/** @type {PostLoginAction} */\nmodule.exports = async (event, context) => {\n console.log(\'new version\');\n return {};\n };\n ', + dependencies: [], + secrets: [], + runtime: 'node12' + }, + bindings: [ { trigger_id: 'post-login' } ] + } + ]; + + await stageFn.apply(handler, [ { actions: data } ]); + }); + }); + + describe('#action process', () => { + it('should create action', async () => { + const version = { + code: 'action-code', + dependencies: [], + id: 'version-id', + runtime: 'node12', + secrets: [] + }; + + const actionId = 'new-action-id'; + const action = { + name: 'action-test', + supported_triggers: [ { + id: 'post-login', + version: 'v1' + } ], + current_version: { + code: 'some code', + dependencies: [], + secrets: [], + runtime: 'node12' + } + }; + + const auth0 = { + actions: { + get: (params) => { + expect(params.id).to.equal(actionId); + return Promise.resolve({ ...action, id: actionId }); + }, + create: (data) => { + expect(data).to.be.an('object'); + expect(data.name).to.equal('action-test'); + expect(data.supported_triggers[0].id).to.equal('post-login'); + expect(data.supported_triggers[0].version).to.equal('v1'); + return Promise.resolve({ ...data, id: actionId }); + }, + update: () => Promise.resolve([]), + delete: () => Promise.resolve([]), + getAll: () => { + if (!auth0.getAllCalled) { + auth0.getAllCalled = true; + return Promise.resolve({ actions: [] }); + } + + return Promise.resolve({ actions: [ { name: action.name, supported_triggers: action.supported_triggers, id: actionId } ] }); + } + }, + actionVersions: { + create: () => Promise.resolve(version), + upsertDraft: () => Promise.resolve(version) + }, + actionBindings: { + getAll: () => Promise.resolve({ bindings: [] }), + create: () => Promise.resolve({ + id: '35409a5b-0326-4e81-ad9b-ac19502cee58', + trigger_id: 'post-login', + created_at: '2020-12-08T21:26:21.982298158Z', + updated_at: '2020-12-08T21:26:21.982298158Z', + display_name: 'action-test' + }) + }, + pool, + getAllCalled: false + }; + + const handler = new actions.default({ client: auth0, config }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [ { actions: [ action ] } ]); + }); + + it('should get actions', async () => { + const code = 'action-code'; + + const version = { + code: code, + dependencies: [], + id: 'version-id', + runtime: 'node12', + secrets: [] + }; + + const actionsData = [ + { + id: 'action-id-1', + name: 'action-test-1', + supported_triggers: [ + { + id: 'post-login', + version: 'v1' + } + ], + current_version: { id: version.id }, + bindings: [] + } + ]; + + const auth0 = { + actions: { + getAll: () => Promise.resolve({ actions: actionsData }) + }, + actionVersions: { + get: (params) => { + expect(params.action_id).to.equal('action-id-1'); + expect(params.version_id).to.equal('version-id'); + return Promise.resolve(version); + } + } + }; + + const handler = new actions.default({ client: auth0, config }); + const data = await handler.getType(); + expect(data).to.deep.equal([ { ...actionsData[0], current_version: version } ]); + }); + + it('should return an empty array for 501 status code', async () => { + const auth0 = { + actions: { + getAll: () => { + const error = new Error('Feature is not yet implemented'); + error.statusCode = 501; + throw error; + } + }, + pool + }; + + const handler = new actions.default({ client: auth0, config }); + const data = await handler.getType(); + expect(data).to.deep.equal([]); + }); + + it('should return an empty array for 404 status code', async () => { + const auth0 = { + actions: { + getAll: () => { + const error = new Error('Not found'); + error.statusCode = 404; + throw error; + } + }, + pool + }; + + const handler = new actions.default({ client: auth0, config }); + const data = await handler.getType(); + expect(data).to.deep.equal([]); + }); + + it('should throw an error for all other failed requests', async () => { + const auth0 = { + actions: { + getAll: () => { + const error = new Error('Bad request'); + error.statusCode = 500; + throw error; + } + }, + pool + }; + + const handler = new actions.default({ client: auth0, config }); + try { + await handler.getType(); + } catch (error) { + expect(error).to.be.an.instanceOf(Error); + } + }); + + + it('should update action creating binding', async () => { + const action = { + name: 'action-test', + supported_triggers: [ { + id: 'post-login', + version: 'v1' + } ], + current_version: { + code: '/** @type {PostLoginAction} */\nmodule.exports = async (event, context) => {\n console.log(\'new version\');\n return {};\n };\n ', + dependencies: [], + secrets: [], + runtime: 'node12' + }, + bindings: [ { trigger_id: 'post-login' } ] + }; + + const auth0 = { + actions: { + create: () => Promise.resolve([]), + update: () => Promise.resolve([]), + delete: () => Promise.resolve([]), + getAll: () => Promise.resolve({ + actions: [ + { + id: '1', + name: 'action-test', + supported_triggers: [ + { + id: 'post-login', + version: 'v1' + } + ], + current_version: { + code: '/** @type {PostLoginAction} */\nmodule.exports = async (event, context) => {\n console.log(\'new version\');\n return {};\n };\n ', + dependencies: [], + secrets: [], + runtime: 'node12' + } + } + ] + }) + }, + actionBindings: { + getAll: () => Promise.resolve({ bindings: [] }), + updateList: () => Promise.resolve() + }, + actionVersions: { + get: () => Promise.resolve({ + action: {}, + code: + '/** @type {PostLoginAction} */\nmodule.exports = async (event, context) => {\n console.log(\'new version\');\n return {};\n };\n ', + dependencies: [], + runtime: 'node12', + id: '0906fe5b-f4d6-44ec-a8f1-3c05fc186483', + deployed: true, + number: 1, + built_at: '2020-12-03T15:20:54.413725492Z', + status: 'built', + created_at: '2020-12-03T15:20:52.094497448Z', + updated_at: '2020-12-03T15:20:54.415669983Z' + }) + }, + createActionBinding: () => Promise.resolve({ + id: '35409a5b-0326-4e81-ad9b-ac19502cee58', + trigger_id: 'post-login', + created_at: '2020-12-08T21:26:21.982298158Z', + updated_at: '2020-12-08T21:26:21.982298158Z', + display_name: 'action-test' + }), + pool + }; + + const handler = new actions.default({ client: auth0, config }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [ { actions: [ action ] } ]); + }); + + it('should remove action', async () => { + const auth0 = { + actions: { + create: () => Promise.resolve([]), + update: () => Promise.resolve([]), + delete: (data) => { + expect(data).to.be.an('object'); + expect(data.id).to.equal('action-1'); + return Promise.resolve(data); + }, + getAll: () => Promise.resolve({ + actions: [ + { + id: 'action-1', + name: 'action-test', + supported_triggers: [ + { + id: 'post-login', + version: 'v1' + } + ] + } + ] + }) + }, + actionBindings: { + getAll: () => Promise.resolve({ + bindings: [ { + id: '35409a5b-0326-4e81-ad9b-ac19502cee58', + trigger_id: 'post-login', + display_name: 'action-test' + } ] + }), + delete: (data) => { + expect(data).to.be.an('object'); + expect(data.id).to.equal('35409a5b-0326-4e81-ad9b-ac19502cee58'); + expect(data.trigger_id).to.equal('post-login'); + return Promise.resolve(data); + } + }, + actionVersions: { + get: () => Promise.resolve({ + action: {}, + code: + '/** @type {PostLoginAction} */\nmodule.exports = async (event, context) => {\n console.log(\'new version\');\n return {};\n };\n ', + dependencies: [], + runtime: 'node12', + id: '0906fe5b-f4d6-44ec-a8f1-3c05fc186483', + deployed: true, + number: 1, + built_at: '2020-12-03T15:20:54.413725492Z', + status: 'built', + created_at: '2020-12-03T15:20:52.094497448Z', + updated_at: '2020-12-03T15:20:54.415669983Z' + }) + }, + pool + }; + + const handler = new actions.default({ client: auth0, config }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [ { action: [] } ]); + }); + }); +});