mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-06 22:40:14 -05:00
Webhooks support for subscriber events (#9230)
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
This commit is contained in:
parent
9e60ac639b
commit
bffb3dbd90
21 changed files with 753 additions and 26 deletions
|
@ -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
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
155
core/server/api/webhooks.js
Normal file
155
core/server/api/webhooks.js
Normal file
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -29,7 +29,8 @@ models = [
|
|||
'subscriber',
|
||||
'tag',
|
||||
'user',
|
||||
'invite'
|
||||
'invite',
|
||||
'webhook'
|
||||
];
|
||||
|
||||
function init() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}, {
|
||||
|
||||
|
|
68
core/server/models/webhook.js
Normal file
68
core/server/models/webhook.js
Normal file
|
@ -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)
|
||||
};
|
57
core/server/services/webhooks.js
Normal file
57
core/server/services/webhooks.js
Normal file
|
@ -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
|
||||
};
|
|
@ -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": {
|
||||
|
|
115
core/test/functional/routes/api/webhooks_spec.js
Normal file
115
core/test/functional/routes/api/webhooks_spec.js
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
147
core/test/integration/api/api_webhooks_spec.js
Normal file
147
core/test/integration/api/api_webhooks_spec.js
Normal file
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
84
core/test/unit/services/webhooks_spec.js
Normal file
84
core/test/unit/services/webhooks_spec.js
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}());
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue