diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cad4c68a88..41fca419f9 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -149,6 +149,8 @@ "couponCodeRequired": "The coupon code is required.", "eventRequired": "\"req.params.event\" is required.", "countRequired": "\"req.query.count\" is required.", + "invalidUrl": "invalid url", + "invalidEnabled": "the \"enabled\" parameter should be a boolean", "petsReleased": "Pets released.", "mountsAndPetsReleased": "Mounts and pets released", "mountsReleased": "Mounts released", diff --git a/common/script/index.js b/common/script/index.js index c043af6a0f..e4eaca082b 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -118,6 +118,9 @@ import purchase from './ops/purchase'; import purchaseHourglass from './ops/hourglassPurchase'; import readCard from './ops/readCard'; import openMysteryItem from './ops/openMysteryItem'; +import addWebhook from './ops/addWebhook'; +import updateWebhook from './ops/updateWebhook'; +import deleteWebhook from './ops/deleteWebhook'; import releasePets from './ops/releasePets'; import releaseBoth from './ops/releaseBoth'; import releaseMounts from './ops/releaseMounts'; @@ -144,6 +147,9 @@ api.ops = { purchaseHourglass, readCard, openMysteryItem, + addWebhook, + updateWebhook, + deleteWebhook, releasePets, releaseBoth, releaseMounts, diff --git a/common/script/ops/addWebhook.js b/common/script/ops/addWebhook.js index 99eaf49bba..ea6b32ed68 100644 --- a/common/script/ops/addWebhook.js +++ b/common/script/ops/addWebhook.js @@ -1,15 +1,22 @@ import refPush from '../libs/refPush'; +import validator from 'validator'; +import i18n from '../i18n'; +import { + BadRequest, +} from '../libs/errors'; +import _ from 'lodash'; -module.exports = function(user, req, cb) { - var wh; +module.exports = function addWebhook (user, req = {}) { + let wh; wh = user.preferences.webhooks; - refPush(wh, { + + if (!validator.isURL(_.get(req, 'body.url'))) throw new BadRequest(i18n.t('invalidUrl', req.language)); + if (!validator.isBoolean(_.get(req, 'body.enabled'))) throw new BadRequest(i18n.t('invalidEnabled', req.language)); + + user.markModified('preferences.webhooks'); + + return refPush(wh, { url: req.body.url, - enabled: req.body.enabled || true, - id: req.body.id + enabled: req.body.enabled, }); - if (typeof user.markModified === "function") { - user.markModified('preferences.webhooks'); - } - return typeof cb === "function" ? cb(null, user.preferences.webhooks) : void 0; }; diff --git a/common/script/ops/deleteWebhook.js b/common/script/ops/deleteWebhook.js index a187f08770..03eb622892 100644 --- a/common/script/ops/deleteWebhook.js +++ b/common/script/ops/deleteWebhook.js @@ -1,7 +1,6 @@ -module.exports = function(user, req, cb) { - delete user.preferences.webhooks[req.params.id]; - if (typeof user.markModified === "function") { - user.markModified('preferences.webhooks'); - } - return typeof cb === "function" ? cb(null, user.preferences.webhooks) : void 0; +import _ from 'lodash'; + +module.exports = function deleteWebhook (user, req) { + delete user.preferences.webhooks[_.get(req, 'params.id')]; + user.markModified('preferences.webhooks'); }; diff --git a/common/script/ops/updateWebhook.js b/common/script/ops/updateWebhook.js index e2775a40b4..0de2f5fa47 100644 --- a/common/script/ops/updateWebhook.js +++ b/common/script/ops/updateWebhook.js @@ -1,9 +1,16 @@ -import _ from 'lodash'; +import validator from 'validator'; +import i18n from '../i18n'; +import { + BadRequest, +} from '../libs/errors'; -module.exports = function(user, req, cb) { - _.merge(user.preferences.webhooks[req.params.id], req.body); - if (typeof user.markModified === "function") { - user.markModified('preferences.webhooks'); - } - return typeof cb === "function" ? cb(null, user.preferences.webhooks) : void 0; +module.exports = function updateWebhook (user, req) { + if (!validator.isURL(req.body.url)) throw new BadRequest(i18n.t('invalidUrl', req.language)); + if (!validator.isBoolean(req.body.enabled)) throw new BadRequest(i18n.t('invalidEnabled', req.language)); + + user.markModified('preferences.webhooks'); + user.preferences.webhooks[req.params.id].url = req.body.url; + user.preferences.webhooks[req.params.id].enabled = req.body.enabled; + + return user.preferences.webhooks[req.params.id]; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index ed79dfc879..3aaf961fb7 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -13,14 +13,12 @@ const COMMON_FILES = [ '!./common/script/ops/addPushDevice.js', '!./common/script/ops/addTag.js', '!./common/script/ops/addTask.js', - '!./common/script/ops/addWebhook.js', '!./common/script/ops/blockUser.js', '!./common/script/ops/clearCompleted.js', '!./common/script/ops/clearPMs.js', '!./common/script/ops/deletePM.js', '!./common/script/ops/deleteTag.js', '!./common/script/ops/deleteTask.js', - '!./common/script/ops/deleteWebhook.js', '!./common/script/ops/getTag.js', '!./common/script/ops/getTags.js', '!./common/script/ops/releaseBoth.js', @@ -33,7 +31,6 @@ const COMMON_FILES = [ '!./common/script/ops/update.js', '!./common/script/ops/updateTag.js', '!./common/script/ops/updateTask.js', - '!./common/script/ops/updateWebhook.js', '!./common/script/fns/crit.js', '!./common/script/fns/cron.js', '!./common/script/fns/dotGet.js', diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 09a974c7f5..f8472bbf9c 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -358,6 +358,10 @@ gulp.task('test:api-v3:integration', (done) => { pipe(runner); }); +gulp.task('test:api-v3:integration:watch', () => { + gulp.watch(['website/src/controllers/api-v3/**/*', 'test/api/v3/integration/**/*', 'common/script/ops/*'], ['test:api-v3:integration']); +}); + gulp.task('test:api-v3:integration:separate-server', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), diff --git a/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js b/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js new file mode 100644 index 0000000000..46844dd855 --- /dev/null +++ b/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js @@ -0,0 +1,23 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +let user; +let endpoint = '/user/webhook'; + +describe('DELETE /user/webhook', () => { + beforeEach(async () => { + user = await generateUser(); + }); + + it('succeeds', async () => { + let id = 'some-id'; + user.preferences.webhooks[id] = { url: 'http://some-url.com', enabled: true }; + await user.sync(); + expect(user.preferences.webhooks).to.eql({}); + let response = await user.del(`${endpoint}/${id}`); + expect(response).to.eql({}); + await user.sync(); + expect(user.preferences.webhooks).to.eql({}); + }); +}); diff --git a/test/api/v3/integration/user/POST-user_add_webhook.test.js b/test/api/v3/integration/user/POST-user_add_webhook.test.js new file mode 100644 index 0000000000..d13f15baa4 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_add_webhook.test.js @@ -0,0 +1,29 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +let user; +let endpoint = '/user/webhook'; + +describe('POST /user/webhook', () => { + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates', async () => { + await expect(user.post(endpoint, { enabled: true })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidUrl'), + }); + }); + + it('successfully adds the webhook', async () => { + expect(user.preferences.webhooks).to.eql({}); + let response = await user.post(endpoint, { enabled: true, url: 'http://some-url.com'}); + expect(response.id).to.exist; + await user.sync(); + expect(user.preferences.webhooks).to.not.eql({}); + }); +}); diff --git a/test/api/v3/integration/user/PUT-user_update_webhook.test.js b/test/api/v3/integration/user/PUT-user_update_webhook.test.js new file mode 100644 index 0000000000..13ca9ff00c --- /dev/null +++ b/test/api/v3/integration/user/PUT-user_update_webhook.test.js @@ -0,0 +1,32 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +let user; +let url = 'http://new-url.com'; +let enabled = true; + +describe('PUT /user/webhook/:id', () => { + beforeEach(async () => { + user = await generateUser(); + }); + + it('validation fails', async () => { + await expect(user.put('/user/webhook/some-id'), { enabled: true }).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidUrl'), + }); + }); + + it('succeeds', async () => { + let response = await user.post('/user/webhook', { enabled: true, url: 'http://some-url.com'}); + await user.sync(); + expect(user.preferences.webhooks[response.id].url).to.not.eql(url); + let response2 = await user.put(`/user/webhook/${response.id}`, {url, enabled}); + expect(response2.url).to.eql(url); + await user.sync(); + expect(user.preferences.webhooks[response.id].url).to.eql(url); + }); +}); diff --git a/test/common/ops/addWebhook.test.js b/test/common/ops/addWebhook.test.js new file mode 100644 index 0000000000..11d26e622b --- /dev/null +++ b/test/common/ops/addWebhook.test.js @@ -0,0 +1,57 @@ +import addWebhook from '../../../common/script/ops/addWebhook'; +import { + BadRequest, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.addWebhook', () => { + let user; + let req; + + beforeEach(() => { + user = generateUser(); + req = { body: { + enabled: true, + url: 'http://some-url.com', + } }; + }); + + context('adds webhook', () => { + it('validates req.body.url', (done) => { + delete req.body.url; + try { + addWebhook(user, req); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidUrl')); + done(); + } + }); + + it('validates req.body.enabled', (done) => { + delete req.body.enabled; + try { + addWebhook(user, req); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidEnabled')); + done(); + } + }); + + it('calls marksModified()', () => { + user.markModified = sinon.spy(); + addWebhook(user, req); + expect(user.markModified.called).to.eql(true); + }); + + it('succeeds', () => { + expect(user.preferences.webhooks).to.eql({}); + addWebhook(user, req); + expect(user.preferences.webhooks).to.not.eql({}); + }); + }); +}); diff --git a/test/common/ops/deleteWebhook.test.js b/test/common/ops/deleteWebhook.test.js new file mode 100644 index 0000000000..0a3178007a --- /dev/null +++ b/test/common/ops/deleteWebhook.test.js @@ -0,0 +1,20 @@ +import deleteWebhook from '../../../common/script/ops/deleteWebhook'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.deleteWebhook', () => { + let user; + let req; + + beforeEach(() => { + user = generateUser(); + req = { params: { id: 'some-id' } }; + }); + + it('succeeds', () => { + user.preferences.webhooks = { 'some-id': {} }; + deleteWebhook(user, req); + expect(user.preferences.webhooks).to.eql({}); + }); +}); diff --git a/test/common/ops/updateWebhook.test.js b/test/common/ops/updateWebhook.test.js new file mode 100644 index 0000000000..43c353626e --- /dev/null +++ b/test/common/ops/updateWebhook.test.js @@ -0,0 +1,42 @@ +import updateWebhook from '../../../common/script/ops/updateWebhook'; +import { + BadRequest, +} from '../../../common/script/libs/errors'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; + +describe('shared.ops.updateWebhook', () => { + let user; + let req; + let newUrl = 'http://new-url.com'; + + beforeEach(() => { + user = generateUser(); + req = { params: { + id: 'this-id', + }, body: { + url: newUrl, + enabled: true, + } }; + }); + + it('validates body', (done) => { + delete req.body.url; + try { + updateWebhook(user, req); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('invalidUrl')); + done(); + } + }); + + it('succeeds', () => { + let url = 'http://existing-url.com'; + user.preferences.webhooks = { 'this-id': { url } }; + updateWebhook(user, req); + expect(user.preferences.webhooks['this-id'].url).to.eql(newUrl); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 827e73c34e..1691d78f88 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -769,7 +769,64 @@ api.userOpenMysteryItem = { }; /** -* @api {post} /user/release-pets Releases pets. + * @api {post} /user/webhook + * @apiVersion 3.0.0 + * @apiName UserAddWebhook + * @apiGroup User + * @apiSuccess {} + **/ +api.addWebhook = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/webhook', + async handler (req, res) { + let user = res.locals.user; + let result = common.ops.addWebhook(user, req); + await user.save(); + res.respond(200, result); + }, +}; + +/** + * @api {put} /user/webhook/:id + * @apiVersion 3.0.0 + * @apiName UserUpdateWebhook + * @apiGroup User + * @apiSuccess {} + **/ +api.updateWebhook = { + method: 'PUT', + middlewares: [authWithHeaders()], + url: '/user/webhook/:id', + async handler (req, res) { + let user = res.locals.user; + let result = common.ops.updateWebhook(user, req); + await user.save(); + res.respond(200, result); + }, +}; + +/** + * @api {delete} /user/webhook/:id + * @apiVersion 3.0.0 + * @apiName UserDeleteWebhook + * @apiGroup User + * @apiSuccess {} + **/ +api.deleteWebhook = { + method: 'DELETE', + middlewares: [authWithHeaders()], + url: '/user/webhook/:id', + async handler (req, res) { + let user = res.locals.user; + common.ops.deleteWebhook(user, req); + await user.save(); + res.respond(200, {}); + }, +}; + + +/* @api {post} /user/release-pets Releases pets. * @apiVersion 3.0.0 * @apiName UserReleasePets * @apiGroup User