From bffb3dbd90bfe572798f81645e3cea908570b978 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Tue, 21 Nov 2017 15:43:14 +0000 Subject: [PATCH] Webhooks support for subscriber events (#9230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no issue Support for http://resthooks.org style webhooks that can be used with Zapier triggers. This can currently be used in two ways: a) adding a webhook record to the DB manually b) using the API with password auth and POSTing to /webhooks/ (this is private API so not documented) ⚠️ only _https_ URLs are supported in the webhook `target_url` field 🚨 - add `webhooks` table to store event names and target urls - add `POST` and `DELETE` endpoints for `/webhooks/` - configure `subscribers.added` and `subscribers.deleted` events to trigger registered webhooks --- core/server/api/index.js | 7 +- core/server/api/routes.js | 4 + core/server/api/webhooks.js | 155 ++++++++++++++++++ .../versions/1.18/1-add-webhooks-table.js | 21 +++ .../server/data/schema/fixtures/fixtures.json | 13 +- core/server/data/schema/schema.js | 9 + core/server/index.js | 6 +- core/server/models/index.js | 3 +- core/server/models/subscriber.js | 18 +- core/server/models/webhook.js | 68 ++++++++ core/server/services/webhooks.js | 57 +++++++ core/server/translations/en.json | 3 + .../functional/routes/api/webhooks_spec.js | 115 +++++++++++++ .../test/integration/api/api_webhooks_spec.js | 147 +++++++++++++++++ core/test/integration/migration_spec.js | 2 +- .../test/unit/migration_fixture_utils_spec.js | 14 +- core/test/unit/migration_spec.js | 4 +- core/test/unit/services/webhooks_spec.js | 84 ++++++++++ core/test/utils/api.js | 3 +- core/test/utils/fixtures/data-generator.js | 39 ++++- core/test/utils/index.js | 7 + 21 files changed, 753 insertions(+), 26 deletions(-) create mode 100644 core/server/api/webhooks.js create mode 100644 core/server/data/migrations/versions/1.18/1-add-webhooks-table.js create mode 100644 core/server/models/webhook.js create mode 100644 core/server/services/webhooks.js create mode 100644 core/test/functional/routes/api/webhooks_spec.js create mode 100644 core/test/integration/api/api_webhooks_spec.js create mode 100644 core/test/unit/services/webhooks_spec.js diff --git a/core/server/api/index.js b/core/server/api/index.js index 4a380de0a2..37e8a1dfb0 100644 --- a/core/server/api/index.js +++ b/core/server/api/index.js @@ -29,6 +29,7 @@ var _ = require('lodash'), uploads = require('./upload'), exporter = require('../data/export'), slack = require('./slack'), + webhooks = require('./webhooks'), http, addHeaders, @@ -139,6 +140,9 @@ locationHeader = function locationHeader(req, result) { } else if (result.hasOwnProperty('tags')) { newObject = result.tags[0]; location = utils.url.urlJoin(apiRoot, 'tags', newObject.id, '/'); + } else if (result.hasOwnProperty('webhooks')) { + newObject = result.webhooks[0]; + location = utils.url.urlJoin(apiRoot, 'webhooks', newObject.id, '/'); } } @@ -312,7 +316,8 @@ module.exports = { slack: slack, themes: themes, invites: invites, - redirects: redirects + redirects: redirects, + webhooks: webhooks }; /** diff --git a/core/server/api/routes.js b/core/server/api/routes.js index 524e99d794..02ce2ada82 100644 --- a/core/server/api/routes.js +++ b/core/server/api/routes.js @@ -201,5 +201,9 @@ module.exports = function apiRoutes() { api.http(api.redirects.upload) ); + // ## Webhooks (RESTHooks) + apiRouter.post('/webhooks', mw.authenticatePrivate, api.http(api.webhooks.add)); + apiRouter.del('/webhooks/:id', mw.authenticatePrivate, api.http(api.webhooks.destroy)); + return apiRouter; }; diff --git a/core/server/api/webhooks.js b/core/server/api/webhooks.js new file mode 100644 index 0000000000..f0b34dc843 --- /dev/null +++ b/core/server/api/webhooks.js @@ -0,0 +1,155 @@ +// # Webhooks API +// RESTful API for creating webhooks +// also known as "REST Hooks", see http://resthooks.org +var Promise = require('bluebird'), + _ = require('lodash'), + https = require('https'), + url = require('url'), + pipeline = require('../utils/pipeline'), + apiUtils = require('./utils'), + models = require('../models'), + errors = require('../errors'), + logging = require('../logging'), + i18n = require('../i18n'), + docName = 'webhooks', + webhooks; + +// TODO: Use the request util. Do we want retries here? +function makeRequest(webhook, payload, options) { + var event = webhook.get('event'), + targetUrl = webhook.get('target_url'), + webhookId = webhook.get('id'), + reqOptions, reqPayload, req; + + reqOptions = url.parse(targetUrl); + reqOptions.method = 'POST'; + reqOptions.headers = {'Content-Type': 'application/json'}; + + reqPayload = JSON.stringify(payload); + + logging.info('webhook.trigger', event, targetUrl); + req = https.request(reqOptions); + + req.write(reqPayload); + req.on('error', function (err) { + // when a webhook responds with a 410 Gone response we should remove the hook + if (err.status === 410) { + logging.info('webhook.destroy (410 response)', event, targetUrl); + return models.Webhook.destroy({id: webhookId}, options); + } + + // TODO: use i18n? + logging.error(new errors.GhostError({ + err: err, + context: { + id: webhookId, + event: event, + target_url: targetUrl, + payload: payload + } + })); + }); + req.end(); +} + +function makeRequests(webhooksCollection, payload, options) { + _.each(webhooksCollection.models, function (webhook) { + makeRequest(webhook, payload, options); + }); +} + +/** + * ## Webhook API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +webhooks = { + + /** + * ### Add + * @param {Webhook} object the webhook to create + * @returns {Promise(Webhook)} newly created Webhook + */ + add: function add(object, options) { + var tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return models.Webhook.getByEventAndTarget(options.data.webhooks[0].event, options.data.webhooks[0].target_url, _.omit(options, ['data'])) + .then(function (webhook) { + if (webhook) { + return Promise.reject(new errors.ValidationError({message: i18n.t('errors.api.webhooks.webhookAlreadyExists')})); + } + + return models.Webhook.add(options.data.webhooks[0], _.omit(options, ['data'])); + }) + .then(function onModelResponse(model) { + return { + webhooks: [model.toJSON(options)] + }; + }); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + apiUtils.validate(docName), + apiUtils.handlePermissions(docName, 'add'), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, object, options); + }, + + /** + * ## Destroy + * + * @public + * @param {{id, context}} options + * @return {Promise} + */ + destroy: function destroy(options) { + var tasks; + + /** + * ### Delete Webhook + * Make the call to the Model layer + * @param {Object} options + */ + function doQuery(options) { + return models.Webhook.destroy(options).return(null); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + apiUtils.validate(docName, {opts: apiUtils.idDefaultOptions}), + apiUtils.handlePermissions(docName, 'destroy'), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options); + }, + + trigger: function trigger(event, payload, options) { + var tasks; + + function doQuery(options) { + return models.Webhook.findAllByEvent(event, options); + } + + tasks = [ + doQuery, + _.partialRight(makeRequests, payload, options) + ]; + + return pipeline(tasks, options); + } +}; + +module.exports = webhooks; diff --git a/core/server/data/migrations/versions/1.18/1-add-webhooks-table.js b/core/server/data/migrations/versions/1.18/1-add-webhooks-table.js new file mode 100644 index 0000000000..c593caed8c --- /dev/null +++ b/core/server/data/migrations/versions/1.18/1-add-webhooks-table.js @@ -0,0 +1,21 @@ +'use strict'; + +const logging = require('../../../../logging'), + commands = require('../../../schema').commands, + table = 'webhooks', + message = 'Adding table: ' + table; + +module.exports = function addWebhooksTable(options) { + let transacting = options.transacting; + + return transacting.schema.hasTable(table) + .then(function (exists) { + if (exists) { + logging.warn(message); + return Promise.resolve(); + } + + logging.info(message); + return commands.createTable(table, transacting); + }); +}; diff --git a/core/server/data/schema/fixtures/fixtures.json b/core/server/data/schema/fixtures/fixtures.json index 76c32e4dc2..fe8b4b11ba 100644 --- a/core/server/data/schema/fixtures/fixtures.json +++ b/core/server/data/schema/fixtures/fixtures.json @@ -421,6 +421,16 @@ "name": "Upload redirects", "action_type": "upload", "object_type": "redirect" + }, + { + "name": "Add webhooks", + "action_type": "add", + "object_type": "webhook" + }, + { + "name": "Delete webhooks", + "action_type": "destroy", + "object_type": "webhook" } ] }, @@ -472,7 +482,8 @@ "client": "all", "subscriber": "all", "invite": "all", - "redirect": "all" + "redirect": "all", + "webhook": "all" }, "Editor": { "post": "all", diff --git a/core/server/data/schema/schema.js b/core/server/data/schema/schema.js index aaa67ef79d..9e9e381659 100644 --- a/core/server/data/schema/schema.js +++ b/core/server/data/schema/schema.js @@ -239,5 +239,14 @@ module.exports = { lastRequest: {type: 'bigInteger'}, lifetime: {type: 'bigInteger'}, count: {type: 'integer'} + }, + webhooks: { + id: {type: 'string', maxlength: 24, nullable: false, primary: true}, + event: {type: 'string', maxlength: 50, nullable: false, validations: {isLowercase: true}}, + target_url: {type: 'string', maxlength: 2000, nullable: false}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'string', maxlength: 24, nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'string', maxlength: 24, nullable: true} } }; diff --git a/core/server/index.js b/core/server/index.js index 8364d1559b..dfa63243fa 100644 --- a/core/server/index.js +++ b/core/server/index.js @@ -31,7 +31,8 @@ var debug = require('ghost-ignition').debug('boot:init'), urlService = require('./services/url'), apps = require('./services/apps'), xmlrpc = require('./services/xmlrpc'), - slack = require('./services/slack'); + slack = require('./services/slack'), + webhooks = require('./services/webhooks'); // ## Initialise Ghost function init() { @@ -64,9 +65,10 @@ function init() { xmlrpc.listen(), // Initialize slack ping slack.listen(), + // Initialize webhook pings + webhooks.listen(), // Url Service urlService.init() - ); }).then(function () { debug('Apps, XMLRPC, Slack done'); diff --git a/core/server/models/index.js b/core/server/models/index.js index d429accfcd..b39df5678c 100644 --- a/core/server/models/index.js +++ b/core/server/models/index.js @@ -29,7 +29,8 @@ models = [ 'subscriber', 'tag', 'user', - 'invite' + 'invite', + 'webhook' ]; function init() { diff --git a/core/server/models/subscriber.js b/core/server/models/subscriber.js index 570d5b57da..6ce6fae800 100644 --- a/core/server/models/subscriber.js +++ b/core/server/models/subscriber.js @@ -9,8 +9,10 @@ var ghostBookshelf = require('./base'), Subscriber = ghostBookshelf.Model.extend({ tableName: 'subscribers', - emitChange: function emitChange(event) { - events.emit('subscriber' + '.' + event, this); + emitChange: function emitChange(event, options) { + options = options || {}; + + events.emit('subscriber' + '.' + event, this, options); }, defaults: function defaults() { @@ -19,16 +21,16 @@ Subscriber = ghostBookshelf.Model.extend({ }; }, - onCreated: function onCreated(model) { - model.emitChange('added'); + onCreated: function onCreated(model, response, options) { + model.emitChange('added', options); }, - onUpdated: function onUpdated(model) { - model.emitChange('edited'); + onUpdated: function onUpdated(model, response, options) { + model.emitChange('edited', options); }, - onDestroyed: function onDestroyed(model) { - model.emitChange('deleted'); + onDestroyed: function onDestroyed(model, response, options) { + model.emitChange('deleted', options); } }, { diff --git a/core/server/models/webhook.js b/core/server/models/webhook.js new file mode 100644 index 0000000000..2da56ace4d --- /dev/null +++ b/core/server/models/webhook.js @@ -0,0 +1,68 @@ +var ghostBookshelf = require('./base'), + events = require('../events'), + Promise = require('bluebird'), + Webhook, + Webhooks; + +Webhook = ghostBookshelf.Model.extend({ + tableName: 'webhooks', + + emitChange: function emitChange(event, options) { + options = options || {}; + + events.emit('webhook' + '.' + event, this, options); + }, + + onCreated: function onCreated(model, response, options) { + model.emitChange('added', options); + }, + + onUpdated: function onUpdated(model, response, options) { + model.emitChange('edited', options); + }, + + onDestroyed: function onDestroyed(model, response, options) { + model.emitChange('deleted', options); + } +}, { + findAllByEvent: function findAllByEvent(event, options) { + var webhooksCollection = Webhooks.forge(); + + options = this.filterOptions(options, 'findAll'); + + return webhooksCollection + .query('where', 'event', '=', event) + .fetch(options); + }, + + getByEventAndTarget: function getByEventAndTarget(event, targetUrl, options) { + options = options || {}; + options.require = true; + + return Webhooks.forge(options).fetch(options).then(function then(webhooks) { + var webhookWithEventAndTarget = webhooks.find(function findWebhook(webhook) { + return webhook.get('event').toLowerCase() === event.toLowerCase() + && webhook.get('target_url').toLowerCase() === targetUrl.toLowerCase(); + }); + + if (webhookWithEventAndTarget) { + return webhookWithEventAndTarget; + } + }).catch(function (error) { + if (error.message === 'NotFound' || error.message === 'EmptyResponse') { + return Promise.resolve(); + } + + return Promise.reject(error); + }); + } +}); + +Webhooks = ghostBookshelf.Collection.extend({ + model: Webhook +}); + +module.exports = { + Webhook: ghostBookshelf.model('Webhook', Webhook), + Webhooks: ghostBookshelf.collection('Webhooks', Webhooks) +}; diff --git a/core/server/services/webhooks.js b/core/server/services/webhooks.js new file mode 100644 index 0000000000..00826028be --- /dev/null +++ b/core/server/services/webhooks.js @@ -0,0 +1,57 @@ +var _ = require('lodash'), + events = require('../events'), + api = require('../api'), + modelAttrs; + +// TODO: this can be removed once all events pass a .toJSON object through +modelAttrs = { + subscriber: ['id', 'name', 'email'] +}; + +// TODO: this works for basic models but we eventually want a full API response +// with embedded models (?include=tags) and so on +function generatePayload(event, model) { + var modelName = event.split('.')[0], + pluralModelName = modelName + 's', + action = event.split('.')[1], + payload = {}, + data; + + if (action === 'deleted') { + data = {}; + modelAttrs[modelName].forEach(function (key) { + if (model._previousAttributes[key] !== undefined) { + data[key] = model._previousAttributes[key]; + } + }); + } else { + data = model.toJSON(); + } + + payload[pluralModelName] = [data]; + + return payload; +} + +function listener(event, model, options) { + var payload = generatePayload(event, model); + + // avoid triggering webhooks when importing + if (options && options.importing) { + return; + } + + api.webhooks.trigger(event, payload, options); +} + +// TODO: use a wildcard with the new event emitter or use the webhooks API to +// register listeners only for events that have webhooks +function listen() { + events.on('subscriber.added', _.partial(listener, 'subscriber.added')); + events.on('subscriber.deleted', _.partial(listener, 'subscriber.deleted')); +} + +// Public API +module.exports = { + listen: listen +}; diff --git a/core/server/translations/en.json b/core/server/translations/en.json index 4c8c191312..357a3d2866 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -417,6 +417,9 @@ }, "notAllowedToInviteOwner": "Not allowed to invite an owner user.", "notAllowedToInvite": "Not allowed to invite this role." + }, + "webhooks": { + "webhookAlreadyExists": "A webhook for requested event with supplied target_url already exists." } }, "data": { diff --git a/core/test/functional/routes/api/webhooks_spec.js b/core/test/functional/routes/api/webhooks_spec.js new file mode 100644 index 0000000000..fde42ba85c --- /dev/null +++ b/core/test/functional/routes/api/webhooks_spec.js @@ -0,0 +1,115 @@ +var should = require('should'), + supertest = require('supertest'), + testUtils = require('../../../utils'), + config = require('../../../../../core/server/config'), + ghost = testUtils.startGhost, + request; + +describe('Webhooks API', function () { + var ghostServer; + + describe('As Owner', function () { + var ownerAccessToken = ''; + + before(function (done) { + // starting ghost automatically populates the db + // TODO: prevent db init, and manage bringing up the DB with fixtures ourselves + ghost().then(function (_ghostServer) { + ghostServer = _ghostServer; + return ghostServer.start(); + }).then(function () { + request = supertest.agent(config.get('url')); + }).then(function () { + return testUtils.doAuth(request); + }).then(function (token) { + ownerAccessToken = token; + done(); + }).catch(done); + }); + + after(function () { + return testUtils.clearData() + .then(function () { + return ghostServer.stop(); + }); + }); + + describe('Add', function () { + var newWebhook = { + event: 'test.create', + target_url: 'http://example.com/webhooks/test/1' + }; + + it('creates a new webhook', function (done) { + request.post(testUtils.API.getApiQuery('webhooks/')) + .set('Authorization', 'Bearer ' + ownerAccessToken) + .send({webhooks: [newWebhook]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .end(function (err, res) { + if (err) { + return done(err); + } + + var jsonResponse = res.body; + + should.exist(jsonResponse.webhooks); + + testUtils.API.checkResponse(jsonResponse.webhooks[0], 'webhook'); + + jsonResponse.webhooks[0].event.should.equal(newWebhook.event); + jsonResponse.webhooks[0].target_url.should.equal(newWebhook.target_url); + + done(); + }); + }); + }); + + describe('Delete', function () { + var newWebhook = { + event: 'test.create', + // a different target_url from above is needed to avoid an "already exists" error + target_url: 'http://example.com/webhooks/test/2' + }; + + it('deletes a webhook', function (done) { + // create the webhook that is to be deleted + request.post(testUtils.API.getApiQuery('webhooks/')) + .set('Authorization', 'Bearer ' + ownerAccessToken) + .send({webhooks: [newWebhook]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(201) + .end(function (err, res) { + if (err) { + return done(err); + } + + var location = res.headers.location; + var jsonResponse = res.body; + + should.exist(jsonResponse.webhooks); + testUtils.API.checkResponse(jsonResponse.webhooks[0], 'webhook'); + + jsonResponse.webhooks[0].event.should.equal(newWebhook.event); + jsonResponse.webhooks[0].target_url.should.equal(newWebhook.target_url); + + // begin delete test + request.del(location) + .set('Authorization', 'Bearer ' + ownerAccessToken) + .expect(204) + .end(function (err, res) { + if (err) { + return done(err); + } + + res.body.should.be.empty(); + + done(); + }); + }); + }); + }); + }); +}); diff --git a/core/test/integration/api/api_webhooks_spec.js b/core/test/integration/api/api_webhooks_spec.js new file mode 100644 index 0000000000..36fc3b85f0 --- /dev/null +++ b/core/test/integration/api/api_webhooks_spec.js @@ -0,0 +1,147 @@ +var _ = require('lodash'), + should = require('should'), + sinon = require('sinon'), + testUtils = require('../../utils'), + Promise = require('bluebird'), + WebhookAPI = require('../../../server/api/webhooks'), + sandbox = sinon.sandbox.create(); + +describe('Webhooks API', function () { + beforeEach(testUtils.teardown); + beforeEach(testUtils.setup('webhooks', 'users:roles', 'perms:webhook', 'perms:init')); + + afterEach(function () { + sandbox.restore(); + }); + + after(testUtils.teardown); + + function checkForErrorType(type, done) { + return function checkForErrorType(error) { + if (Array.isArray(error)) { + error = error[0]; + } + + if (error.errorType) { + error.errorType.should.eql(type); + done(); + } else { + done(error); + } + }; + } + + describe('Validations', function () { + it('Prevents mixed case event names', function (done) { + WebhookAPI.add({webhooks: [{ + event: 'Mixed.Case', + target_url: 'https://example.com/hooks/test' + }]}, testUtils.context.owner) + .then(function () { + done(new Error('Should not allow mixed case event names')); + }).catch(checkForErrorType('ValidationError', done)); + }); + + it('Prevents duplicate event/target pairs', function (done) { + var duplicate = testUtils.DataGenerator.Content.webhooks[0]; + + WebhookAPI.add({webhooks: [{ + event: duplicate.event, + target_url: duplicate.target_url + }]}, testUtils.context.owner) + .then(function () { + done(new Error('Should not allow duplicate event/target')); + }).catch(checkForErrorType('ValidationError', done)); + }); + }); + + describe('Permissions', function () { + var firstWebhook = testUtils.DataGenerator.Content.webhooks[0].id; + var newWebhook; + + function checkAddResponse(response) { + should.exist(response); + should.exist(response.webhooks); + should.not.exist(response.meta); + + response.webhooks.should.have.length(1); + testUtils.API.checkResponse(response.webhooks[0], 'webhook'); + response.webhooks[0].created_at.should.be.an.instanceof(Date); + } + + beforeEach(function () { + newWebhook = { + event: 'test.added', + target_url: 'https://example.com/webhooks/test-added' + }; + }); + + describe('Owner', function () { + it('Can add', function (done) { + WebhookAPI.add({webhooks: [newWebhook]}, testUtils.context.owner) + .then(function (response) { + checkAddResponse(response); + done(); + }).catch(done); + }); + + it('Can delete', function (done) { + WebhookAPI.destroy(_.extend({}, testUtils.context.owner, {id: firstWebhook})) + .then(function (results) { + should.not.exist(results); + done(); + }); + }); + }); + + describe('Admin', function () { + it('Can add', function (done) { + WebhookAPI.add({webhooks: [newWebhook]}, testUtils.context.admin) + .then(function (response) { + checkAddResponse(response); + done(); + }).catch(done); + }); + + it('Can delete', function (done) { + WebhookAPI.destroy(_.extend({}, testUtils.context.admin, {id: firstWebhook})) + .then(function (results) { + should.not.exist(results); + done(); + }); + }); + }); + + describe('Editor', function () { + it('CANNOT add', function (done) { + WebhookAPI.add({webhooks: [newWebhook]}, testUtils.context.editor) + .then(function () { + done(new Error('Editor should not be able to add a webhook')); + }).catch(checkForErrorType('NoPermissionError', done)); + }); + + it('CANNOT delete', function (done) { + WebhookAPI.destroy(_.extend({}, testUtils.context.editor, {id: firstWebhook})) + .then(function () { + done(new Error('Editor should not be able to delete a webhook')); + }).catch(checkForErrorType('NoPermissionError', done)); + }); + }); + + describe('Author', function () { + it('CANNOT add', function (done) { + WebhookAPI.add({webhooks: [newWebhook]}, testUtils.context.author) + .then(function () { + done(new Error('Author should not be able to add a webhook')); + }).catch(checkForErrorType('NoPermissionError', done)); + }); + + it('CANNOT delete', function (done) { + WebhookAPI.destroy(_.extend({}, testUtils.context.author, {id: firstWebhook})) + .then(function () { + done(new Error('Editor should not be able to delete a webhook')); + }).catch(checkForErrorType('NoPermissionError', done)); + }); + }); + }); +}); diff --git a/core/test/integration/migration_spec.js b/core/test/integration/migration_spec.js index 171000af67..0483a1e321 100644 --- a/core/test/integration/migration_spec.js +++ b/core/test/integration/migration_spec.js @@ -224,7 +224,7 @@ describe('Database Migration (special functions)', function () { result.roles.at(3).get('name').should.eql('Owner'); // Permissions - result.permissions.length.should.eql(51); + result.permissions.length.should.eql(53); result.permissions.toJSON().should.be.CompletePermissions(); done(); diff --git a/core/test/unit/migration_fixture_utils_spec.js b/core/test/unit/migration_fixture_utils_spec.js index a258cf6081..e32a2ee7fb 100644 --- a/core/test/unit/migration_fixture_utils_spec.js +++ b/core/test/unit/migration_fixture_utils_spec.js @@ -151,19 +151,19 @@ describe('Migration Fixture Utils', function () { fixtureUtils.addFixturesForRelation(fixtures.relations[0]).then(function (result) { should.exist(result); result.should.be.an.Object(); - result.should.have.property('expected', 33); - result.should.have.property('done', 33); + result.should.have.property('expected', 34); + result.should.have.property('done', 34); // Permissions & Roles permsAllStub.calledOnce.should.be.true(); rolesAllStub.calledOnce.should.be.true(); - dataMethodStub.filter.callCount.should.eql(33); + dataMethodStub.filter.callCount.should.eql(34); dataMethodStub.find.callCount.should.eql(3); - baseUtilAttachStub.callCount.should.eql(33); + baseUtilAttachStub.callCount.should.eql(34); - fromItem.related.callCount.should.eql(33); - fromItem.findWhere.callCount.should.eql(33); - toItem[0].get.callCount.should.eql(66); + fromItem.related.callCount.should.eql(34); + fromItem.findWhere.callCount.should.eql(34); + toItem[0].get.callCount.should.eql(68); done(); }).catch(done); diff --git a/core/test/unit/migration_spec.js b/core/test/unit/migration_spec.js index 14c0ae7653..74df14347d 100644 --- a/core/test/unit/migration_spec.js +++ b/core/test/unit/migration_spec.js @@ -19,8 +19,8 @@ var should = require('should'), // jshint ignore:line // both of which are required for migrations to work properly. describe('DB version integrity', function () { // Only these variables should need updating - var currentSchemaHash = '0de1eaa8bc79046a9f43927917c294c3', - currentFixturesHash = 'e2c71e808c3d33660c498d164639dc8c'; + var currentSchemaHash = '329f9b498944c459040426e16fc65b11', + currentFixturesHash = '90925e0004a0cedd1e6ea789c81ec67d'; // If this test is failing, then it is likely a change has been made that requires a DB version bump, // and the values above will need updating as confirmation diff --git a/core/test/unit/services/webhooks_spec.js b/core/test/unit/services/webhooks_spec.js new file mode 100644 index 0000000000..4d8b115b84 --- /dev/null +++ b/core/test/unit/services/webhooks_spec.js @@ -0,0 +1,84 @@ +var _ = require('lodash'), + should = require('should'), + sinon = require('sinon'), + rewire = require('rewire'), + testUtils = require('../../utils'), + + // Stuff we test + webhooks = rewire('../../../server/services/webhooks'), + events = require('../../../server/events'), + + sandbox = sinon.sandbox.create(); + +describe('Webhooks', function () { + var eventStub; + + beforeEach(function () { + eventStub = sandbox.stub(events, 'on'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('listen() should initialise events correctly', function () { + webhooks.listen(); + eventStub.calledTwice.should.be.true(); + }); + + it('listener() with "subscriber.added" event calls api.webhooks.trigger with toJSONified model', function () { + var testSubscriber = _.clone(testUtils.DataGenerator.Content.subscribers[0]), + testModel = { + toJSON: function () { + return testSubscriber; + } + }, + apiStub = { + webhooks: { + trigger: sandbox.stub() + } + }, + resetWebhooks = webhooks.__set__('api', apiStub), + listener = webhooks.__get__('listener'), + triggerArgs; + + listener('subscriber.added', testModel); + + apiStub.webhooks.trigger.calledOnce.should.be.true(); + + triggerArgs = apiStub.webhooks.trigger.getCall(0).args; + triggerArgs[0].should.eql('subscriber.added'); + triggerArgs[1].should.deepEqual({ + subscribers: [testSubscriber] + }); + + resetWebhooks(); + }); + + it('listener() with "subscriber.deleted" event calls api.webhooks.trigger with _previousAttributes values', function () { + var testSubscriber = _.clone(testUtils.DataGenerator.Content.subscribers[1]), + testModel = { + _previousAttributes: testSubscriber + }, + apiStub = { + webhooks: { + trigger: sandbox.stub() + } + }, + resetWebhooks = webhooks.__set__('api', apiStub), + listener = webhooks.__get__('listener'), + triggerArgs; + + listener('subscriber.deleted', testModel); + + apiStub.webhooks.trigger.calledOnce.should.be.true(); + + triggerArgs = apiStub.webhooks.trigger.getCall(0).args; + triggerArgs[0].should.eql('subscriber.deleted'); + triggerArgs[1].should.deepEqual({ + subscribers: [testSubscriber] + }); + + resetWebhooks(); + }); +}); diff --git a/core/test/utils/api.js b/core/test/utils/api.js index 62780952f6..00c90dd60f 100644 --- a/core/test/utils/api.js +++ b/core/test/utils/api.js @@ -53,7 +53,8 @@ var _ = require('lodash'), notification: ['type', 'message', 'status', 'id', 'dismissible', 'location'], theme: ['name', 'package', 'active'], themes: ['themes'], - invites: _(schema.invites).keys().without('token').value() + invites: _(schema.invites).keys().without('token').value(), + webhook: _.keys(schema.webhooks) }; function getApiQuery(route) { diff --git a/core/test/utils/fixtures/data-generator.js b/core/test/utils/fixtures/data-generator.js index 3fd6295277..35274236f6 100644 --- a/core/test/utils/fixtures/data-generator.js +++ b/core/test/utils/fixtures/data-generator.js @@ -329,6 +329,19 @@ DataGenerator.Content = { id: ObjectId.generate(), email: 'subscriber2@test.com' } + ], + + webhooks: [ + { + id: ObjectId.generate(), + event: 'subscriber.added', + target_url: 'https://example.com/webhooks/subscriber-added' + }, + { + id: ObjectId.generate(), + event: 'subscriber.removed', + target_url: 'https://example.com/webhooks/subscriber-removed' + } ] }; @@ -344,7 +357,8 @@ DataGenerator.forKnex = (function () { users, roles_users, clients, - invites; + invites, + webhooks; function createBasic(overrides) { var newObj = _.cloneDeep(overrides); @@ -522,6 +536,20 @@ DataGenerator.forKnex = (function () { }); } + function createWebhook(overrides) { + var newObj = _.cloneDeep(overrides); + + return _.defaults(newObj, { + id: ObjectId.generate(), + event: 'test', + target_url: 'https://example.com/hooks/test', + created_by: DataGenerator.Content.users[0].id, + created_at: new Date(), + updated_by: DataGenerator.Content.users[0].id, + updated_at: new Date() + }); + } + posts = [ createPost(DataGenerator.Content.posts[0]), createPost(DataGenerator.Content.posts[1]), @@ -626,6 +654,11 @@ DataGenerator.forKnex = (function () { createInvite({email: 'test2@ghost.org', role_id: DataGenerator.Content.roles[2].id}) ]; + webhooks = [ + createWebhook(DataGenerator.Content.webhooks[0]), + createWebhook(DataGenerator.Content.webhooks[1]) + ]; + return { createPost: createPost, createGenericPost: createGenericPost, @@ -644,6 +677,7 @@ DataGenerator.forKnex = (function () { createSubscriber: createBasic, createInvite: createInvite, createTrustedDomain: createTrustedDomain, + createWebhook: createWebhook, invites: invites, posts: posts, @@ -654,7 +688,8 @@ DataGenerator.forKnex = (function () { roles: roles, users: users, roles_users: roles_users, - clients: clients + clients: clients, + webhooks: webhooks }; }()); diff --git a/core/test/utils/index.js b/core/test/utils/index.js index 628d891211..e3bb3ed660 100644 --- a/core/test/utils/index.js +++ b/core/test/utils/index.js @@ -435,6 +435,10 @@ fixtures = { insertInvites: function insertInvites() { return db.knex('invites').insert(DataGenerator.forKnex.invites); + }, + + insertWebhooks: function insertWebhooks() { + return db.knex('webhooks').insert(DataGenerator.forKnex.webhooks); } }; @@ -540,6 +544,9 @@ toDoList = { }, themes: function loadThemes() { return themes.loadAll(); + }, + webhooks: function insertWebhooks() { + return fixtures.insertWebhooks(); } };