diff --git a/package-lock.json b/package-lock.json index 05fc947..65b5aa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "auth0-source-control-extension-tools", - "version": "4.4.3", + "version": "4.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 31dfb1e..7be5922 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auth0-source-control-extension-tools", - "version": "4.4.3", + "version": "4.5.0", "description": "Supporting tools for the Source Control extensions", "main": "lib/index.js", "scripts": { @@ -8,6 +8,7 @@ "prepare": "npm run build", "release": "git tag $npm_package_version && git push --tags && npm publish", "lint:js": "eslint --ignore-path .gitignore --ignore-pattern webpack .", + "lint:fix": "eslint --fix --ignore-path .gitignore --ignore-pattern webpack .", "test": "npm run test:pre && cross-env NODE_ENV=test nyc mocha tests/mocha.js './tests/**/*.tests.js'", "test:watch": "cross-env NODE_ENV=test mocha tests/mocha.js ./tests/**/*.tests.js ./tests/*.tests.js --watch", "test:pre": "npm run test:clean && npm run lint:js", diff --git a/src/auth0/handlers/index.js b/src/auth0/handlers/index.js index 9436175..16d0b8a 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 organizations from './organizations'; export { rules, @@ -43,5 +44,6 @@ export { roles, branding, prompts, - migrations + migrations, + organizations }; diff --git a/src/auth0/handlers/organizations.js b/src/auth0/handlers/organizations.js new file mode 100644 index 0000000..03ba427 --- /dev/null +++ b/src/auth0/handlers/organizations.js @@ -0,0 +1,191 @@ +import _ from 'lodash'; +import DefaultHandler, { order } from './default'; +import { calcChanges } from '../../utils'; +import log from '../../logger'; + +export const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + display_name: { type: 'string' }, + branding: { type: 'object' }, + metadata: { type: 'object' }, + connections: { + type: 'array', + items: { + type: 'object', + properties: { + connection_id: { type: 'string' }, + assign_membership_on_login: { type: 'boolean' } + } + } + } + }, + required: [ 'name' ] + } +}; + +export default class OrganizationsHandler extends DefaultHandler { + constructor(config) { + super({ + ...config, + type: 'organizations', + id: 'id', + identifiers: [ 'name' ] + }); + } + + async deleteOrganization(org) { + await this.client.organizations.delete({ id: org.id }); + } + + async deleteOrganizations(data) { + if (this.config('AUTH0_ALLOW_DELETE') === 'true' || this.config('AUTH0_ALLOW_DELETE') === true) { + await this.client.pool.addEachTask({ + data: data || [], + generator: item => this.deleteOrganization(item).then(() => { + this.didDelete(item); + this.deleted += 1; + }).catch((err) => { + throw new Error(`Problem deleting ${this.type} ${this.objString(item)}\n${err}`); + }) + }).promise(); + } else { + log.warn(`Detected the following organizations should be deleted. Doing so may be destructive.\nYou can enable deletes by setting 'AUTH0_ALLOW_DELETE' to true in the config + \n${data.map(i => this.objString(i)).join('\n')}`); + } + } + + async createOrganization(org) { + const organization = { ...org }; + delete organization.connections; + + const created = await this.client.organizations.create(organization); + + if (typeof org.connections !== 'undefined' && org.connections.length > 0) { + await Promise.all(org.connections.map(conn => this.client.organizations.addEnabledConnection({ id: created.id }, conn))); + } + + return created; + } + + async createOrganizations(creates) { + await this.client.pool.addEachTask({ + data: creates || [], + generator: item => this.createOrganization(item).then((data) => { + this.didCreate(data); + this.created += 1; + }).catch((err) => { + throw new Error(`Problem creating ${this.type} ${this.objString(item)}\n${err}`); + }) + }).promise(); + } + + async updateOrganization(org, organizations) { + const { connections: existingConnections } = await organizations.find(orgToUpdate => orgToUpdate.name === org.name); + + const params = { id: org.id }; + const { connections } = org; + + delete org.connections; + delete org.name; + delete org.id; + + await this.client.organizations.update(params, org); + + const connectionsToRemove = existingConnections.filter(c => !connections.find(x => x.connection_id === c.connection_id)); + const connectionsToAdd = connections.filter(c => !existingConnections.find(x => x.connection_id === c.connection_id)); + const connectionsToUpdate = connections.filter(c => existingConnections.find(x => x.connection_id === c.connection_id && x.assign_membership_on_login !== c.assign_membership_on_login)); + + // Handle updates first + await Promise.all(connectionsToUpdate.map(conn => this.client.organizations + .updateEnabledConnection(Object.assign({ connection_id: conn.connection_id }, params), { assign_membership_on_login: conn.assign_membership_on_login }) + .catch(() => { + throw new Error(`Problem updating Enabled Connection ${conn.connection_id} for organizations ${params.id}`); + }))); + + await Promise.all(connectionsToAdd.map(conn => this.client.organizations + .addEnabledConnection(params, _.omit(conn, 'connection')) + .catch(() => { + throw new Error(`Problem adding Enabled Connection ${conn.connection_id} for organizations ${params.id}`); + }))); + + await Promise.all(connectionsToRemove.map(conn => this.client.organizations + .removeEnabledConnection(Object.assign({ connection_id: conn.connection_id }, params)) + .catch(() => { + throw new Error(`Problem removing Enabled Connection ${conn.connection_id} for organizations ${params.id}`); + }))); + + return params; + } + + async updateOrganizations(updates, orgs) { + await this.client.pool.addEachTask({ + data: updates || [], + generator: item => this.updateOrganization(item, orgs).then((data) => { + this.didUpdate(data); + this.updated += 1; + }).catch((err) => { + throw new Error(`Problem updating ${this.type} ${this.objString(item)}\n${err}`); + }) + }).promise(); + } + + async getType() { + if (this.existing) { + return this.existing; + } + + if (!this.client.organizations || typeof this.client.organizations.getAll !== 'function') { + return []; + } + + try { + const organizations = await this.client.organizations.getAll({ pagination: true }); + for (let index = 0; index < organizations.length; index++) { + const connections = await this.client.organizations.connections.get({ id: organizations[index].id }); + organizations[index].connections = connections; + } + this.existing = organizations; + return this.existing; + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 501) { + return []; + } + throw err; + } + } + + // Run after connections + @order('70') + async processChanges(assets) { + const { organizations } = assets; + // Do nothing if not set + if (!organizations) return; + // Gets organizations from destination tenant + const existing = await this.getType(); + const changes = calcChanges(organizations, existing, [ 'id', 'name' ]); + + log.debug(`Start processChanges for organizations [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.deleteOrganizations(change.del); + break; + case change.create && change.create.length > 0: + await this.createOrganizations(changes.create); + break; + case change.update && change.update.length > 0: + await this.updateOrganizations(change.update, existing); + break; + default: + break; + } + })); + } +} diff --git a/tests/auth0/handlers/organizations.tests.js b/tests/auth0/handlers/organizations.tests.js new file mode 100644 index 0000000..b751d8e --- /dev/null +++ b/tests/auth0/handlers/organizations.tests.js @@ -0,0 +1,365 @@ +const { expect } = require('chai'); +const organizations = require('../../../src/auth0/handlers/organizations'); + +const pool = { + addEachTask: (data) => { + if (data.data && data.data.length) { + data.generator(data.data[0]); + } + return { promise: () => null }; + } +}; + +const sampleOrg = { + id: '123', + name: 'acme', + display_name: 'Acme Inc' +}; + +const sampleConnection = { + connection_id: 'con_123', assign_membership_on_login: true +}; +const sampleConnection2 = { + connection_id: 'con_456', assign_membership_on_login: false +}; + + +describe('#organizations handler', () => { + const config = function(key) { + return config.data && config.data[key]; + }; + + config.data = { + AUTH0_ALLOW_DELETE: true + }; + + describe('#organizations validate', () => { + it('should not allow same id', async () => { + const handler = new organizations.default({ client: {}, config }); + const stageFn = Object.getPrototypeOf(handler).validate; + const data = [ + { + id: '123', + name: 'Acme' + }, + { + id: '123', + name: 'Contoso' + } + ]; + + try { + await stageFn.apply(handler, [ { organizations: data } ]); + } catch (err) { + expect(err).to.be.an('object'); + expect(err.message).to.include('Only one rule must be defined for the same order number in a stage.'); + } + }); + + it('should not allow same names', async () => { + const handler = new organizations.default({ client: {}, config }); + const stageFn = Object.getPrototypeOf(handler).validate; + const data = [ + { + name: 'Acme' + }, + { + name: 'Acme' + } + ]; + + try { + await stageFn.apply(handler, [ { organizations: data } ]); + } catch (err) { + expect(err).to.be.an('object'); + expect(err.message).to.include('Names must be unique'); + } + }); + + it('should pass validation', async () => { + const handler = new organizations.default({ client: {}, config }); + const stageFn = Object.getPrototypeOf(handler).validate; + const data = [ + { + name: 'Acme' + } + ]; + + await stageFn.apply(handler, [ { organizations: data } ]); + }); + }); + + describe('#organizations process', () => { + it('should return empty if no organization asset', async () => { + const auth0 = { + organizations: { + }, + pool + }; + + const handler = new organizations.default({ client: auth0, config }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + const response = await stageFn.apply(handler, [ { } ]); + expect(response).to.equal(undefined); + }); + + it('should create organization', async () => { + const auth0 = { + organizations: { + create: (data) => { + expect(data).to.be.an('object'); + expect(data.name).to.equal('acme'); + expect(data.display_name).to.equal('Acme'); + expect(data.connections).to.equal(undefined); + data.id = 'fake'; + return Promise.resolve(data); + }, + update: () => Promise.resolve([]), + delete: () => Promise.resolve([]), + getAll: () => Promise.resolve([]), + addEnabledConnection: (org, connection) => { + expect(org.id).to.equal('fake'); + expect(connection).to.be.an('object'); + expect(connection.connection_id).to.equal('123'); + expect(connection.assign_membership_on_login).to.equal(true); + return Promise.resolve(connection); + } + }, + pool + }; + + const handler = new organizations.default({ client: auth0, config }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + await stageFn.apply(handler, [ + { + organizations: [ + { + name: 'acme', + display_name: 'Acme', + connections: [ + { + connection_id: '123', + assign_membership_on_login: true + } + ] + } + ] + } + ]); + }); + + it('should get organizations', async () => { + const auth0 = { + organizations: { + getAll: () => Promise.resolve([ sampleOrg ]), + connections: { + get: () => [ + sampleConnection + ] + } + }, + pool + }; + + const handler = new organizations.default({ client: auth0, config }); + const data = await handler.getType(); + expect(data).to.deep.equal([ Object.assign({}, sampleOrg, { connections: [ sampleConnection ] }) ]); + }); + + it('should get all organizations', async () => { + const organizationsPage1 = Array.from({ length: 50 }, (v, i) => ({ name: 'acme' + i, display_name: 'Acme ' + i })); + const organizationsPage2 = Array.from({ length: 40 }, (v, i) => ({ name: 'acme' + (i + 50), display_name: 'Acme ' + (i + 50) })); + + const auth0 = { + organizations: { + getAll: () => Promise.resolve([ ...organizationsPage2, ...organizationsPage1 ]), + connections: { + get: () => Promise.resolve({}) + } + }, + pool + }; + + const handler = new organizations.default({ client: auth0, config }); + const data = await handler.getType(); + expect(data).to.have.length(90); + }); + + it('should return an empty array for old versions of the sdk', async () => { + const auth0 = { + pool + }; + + const handler = new organizations.default({ client: auth0, config }); + const data = await handler.getType(); + expect(data).to.deep.equal([]); + }); + + it('should return an empty array for 501 status code', async () => { + const auth0 = { + organizations: { + getAll: () => { + const error = new Error('Feature is not yet implemented'); + error.statusCode = 501; + throw error; + } + }, + pool + }; + + const handler = new organizations.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 = { + organizations: { + getAll: () => { + const error = new Error('Not found'); + error.statusCode = 404; + throw error; + } + }, + pool + }; + + const handler = new organizations.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 = { + organizations: { + getAll: () => { + const error = new Error('Bad request'); + error.statusCode = 500; + throw error; + } + }, + pool + }; + + const handler = new organizations.default({ client: auth0, config }); + try { + await handler.getType(); + } catch (error) { + expect(error).to.be.an.instanceOf(Error); + } + }); + + it('should call getAll once', async () => { + let shouldThrow = false; + const auth0 = { + organizations: { + getAll: () => { + if (!shouldThrow) { + return [ sampleOrg ]; + } + + throw new Error('Unexpected'); + }, + connections: { + get: () => Promise.resolve([]) + } + }, + pool + }; + + const handler = new organizations.default({ client: auth0, config }); + let data = await handler.getType(); + expect(data).to.deep.equal([ sampleOrg ]); + + shouldThrow = true; + data = await handler.getType(); + expect(data).to.deep.equal([ sampleOrg ]); + }); + + it('should update organizations', async () => { + const auth0 = { + organizations: { + create: () => Promise.resolve([]), + update: (params, data) => { + expect(params).to.be.an('object'); + expect(params.id).to.equal('123'); + expect(data.display_name).to.equal('Acme 2'); + return Promise.resolve(data); + }, + delete: () => Promise.resolve([]), + getAll: () => Promise.resolve([ sampleOrg ]), + connections: { + get: () => [ + sampleConnection, + sampleConnection2 + ] + }, + addEnabledConnection: (params, data) => { + expect(params).to.be.an('object'); + expect(params.id).to.equal('123'); + expect(data).to.be.an('object'); + expect(data.connection_id).to.equal('con_789'); + expect(data.assign_membership_on_login).to.equal(false); + return Promise.resolve(data); + }, + removeEnabledConnection: (params) => { + expect(params).to.be.an('object'); + expect(params.id).to.equal('123'); + expect(params.connection_id).to.equal(sampleConnection2.connection_id); + return Promise.resolve(); + }, + updateEnabledConnection: (params, data) => { + expect(params).to.be.an('object'); + expect(params.id).to.equal('123'); + expect(params.connection_id).to.equal(sampleConnection.connection_id); + expect(data).to.be.an('object'); + expect(data.assign_membership_on_login).to.equal(false); + return Promise.resolve(data); + } + }, + pool + }; + + const handler = new organizations.default({ client: auth0, config }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + + await stageFn.apply(handler, [ + { + organizations: [ + { + id: '123', + name: 'acme', + display_name: 'Acme 2', + connections: [ + { connection_id: 'con_123', assign_membership_on_login: false }, + { connection_id: 'con_789', assign_membership_on_login: false } + ] + } + ] + } + ]); + }); + + it('should delete organizations', async () => { + const auth0 = { + organizations: { + create: () => Promise.resolve([]), + update: () => Promise.resolve([]), + delete: (data) => { + expect(data).to.be.an('object'); + expect(data.id).to.equal(sampleOrg.id); + return Promise.resolve(data); + }, + getAll: () => Promise.resolve([ sampleOrg ]), + connections: { + get: () => [] + } + }, + pool + }; + const handler = new organizations.default({ client: auth0, config }); + const stageFn = Object.getPrototypeOf(handler).processChanges; + await stageFn.apply(handler, [ { organizations: [ {} ] } ]); + }); + }); +});