From 35b92f13a31c7eadd24219419f8da9a407c499f7 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 2 Oct 2016 09:16:22 -0500 Subject: [PATCH] Webhook improvements (#7879) * refactor: Move translate test utility to helpers directory * Add kind property to webhooks * feat: Add options to create webhook route * refactor: Move webhook ops into single file * refactor: Create webhook objects for specific webhook behavior * chore(tests): Add default sleep helper value of 1 second * feat(api): Add method for groups to send out webhook * feat(api): Add taskCreated webhook task creation * feat(api): Send chat webhooks after a chat is sent * refactor: Move webhook routes to own controller * lint: Correct linting errors * fix(api): Correct taskCreated webhook method * fix(api): Fix webhook logging to only log when there is an error * fix: Update groupChatRecieved webhook creation * chore: Add integration tests for webhooks * fix: Set webhook creation response to 201 * fix: Correct how task scored webhook data is sent * Revert group chat recieved webhook to only support one group * Remove quest activity option for webhooks * feat: Send webhook for each task created * feat: Allow webhooks without a type to default to taskScored * feat: Add logic for adding ids to webhook * feat: optimize webhook url check by shortcircuiting if no url is passed * refactor: Use full name for webhook variable * feat: Add missing params to client webhook * lint: Add missing semicolon * chore(tests): Fix inccorect webhook tests * chore: Add migration to update task scored webhooks * feat: Allow default value of webhook add route to be enabled * chore: Update webhook documentation * chore: Remove special handling for v2 * refactor: adjust addComputedStatsToJSONObject to work for webhooks * refactor: combine taskScored and taskActivity webhooks * feat(api): Add task activity to task update and delete routes * chore: Change references to taskScored to taskActivity * fix: Correct stats object being passed in for transform * chore: Remove extra line break * fix: Pass in the language to use for the translations * refactor(api): Move webhooks from user.preferences.webhooks to user.webhooks * chore: Update migration to set webhook array * lint: Correct brace spacing * chore: convert webhook lib to use user.webhooks * refactor(api): Consolidate filters * chore: clarify migration instructions * fix(test): Correct user creation in user anonymized tests * chore: add test that webhooks cannot be updated via PUT /user * refactor: Simplify default webhook id value * refactor(client): Push newly created webhook instead of doing a sync * chore(test): Add test file for webhook model * refactor: Remove webhook validation * refactor: Remove need for watch on webhooks * refactor(client): Update webhooks object without syncing * chore: update webhook documentation * Fix migrations issues * chore: remove v2 test helper * fix(api): Provide webhook type in task scored webhook * fix(client): Fix webhook deletion appearing to delete all webhooks * feat(api): add optional label field for webhooks * feat: provide empty string as default for webhook label * chore: Update webhook migration * chore: update webhook migration name --- .../20161002_add_missing_webhook_type.js | 114 +++++ migrations/utils/connect.js | 2 +- .../api/v3/integration/chat/POST-chat.test.js | 41 ++ .../integration/tasks/DELETE-tasks_id.test.js | 76 +++ .../POST-tasks_id_score_direction.test.js | 36 ++ .../integration/tasks/POST-tasks_user.test.js | 69 ++- .../v3/integration/tasks/PUT-tasks_id.test.js | 76 +++ .../user/DELETE-user_delete_webhook.test.js | 23 - .../user/GET-user_anonymized.test.js | 19 +- .../user/POST-user_add_webhook.test.js | 29 -- test/api/v3/integration/user/PUT-user.test.js | 1 + .../user/PUT-user_update_webhook.test.js | 32 -- .../DELETE-user_delete_webhook.test.js | 54 +++ .../webhook/POST-user_add_webhook.test.js | 211 +++++++++ .../webhook/PUT-user_update_webhook.test.js | 132 ++++++ test/api/v3/unit/libs/webhooks.test.js | 437 ++++++++++++++---- test/api/v3/unit/models/group.test.js | 160 +++++++ test/api/v3/unit/models/user.test.js | 2 +- test/api/v3/unit/models/webhook.test.js | 146 ++++++ test/common/ops/addWebhook.test.js | 57 --- test/common/ops/deleteWebhook.test.js | 21 - test/common/ops/updateWebhook.test.js | 42 -- .../api-integration/v3/external-server.js | 70 +++ test/helpers/api-integration/v3/index.js | 7 +- test/helpers/common.helper.js | 1 + test/helpers/sleep.js | 2 +- .../{api-integration => }/translate.js | 10 +- .../client-old/js/controllers/settingsCtrl.js | 24 +- .../client-old/js/services/userServices.js | 28 +- website/common/locales/en/settings.json | 7 +- website/common/script/index.js | 9 - website/common/script/ops/addWebhook.js | 23 - website/common/script/ops/deleteWebhook.js | 10 - website/common/script/ops/index.js | 6 - website/common/script/ops/updateWebhook.js | 16 - website/server/controllers/api-v3/chat.js | 2 + website/server/controllers/api-v3/members.js | 4 +- website/server/controllers/api-v3/tasks.js | 65 +-- website/server/controllers/api-v3/user.js | 72 +-- website/server/controllers/api-v3/webhook.js | 193 ++++++++ website/server/libs/webhook.js | 110 ++++- website/server/models/group.js | 28 ++ website/server/models/user/methods.js | 12 +- website/server/models/user/schema.js | 2 + website/server/models/webhook.js | 80 ++++ website/views/options/settings.jade | 10 +- 46 files changed, 2044 insertions(+), 527 deletions(-) create mode 100644 migrations/20161002_add_missing_webhook_type.js delete mode 100644 test/api/v3/integration/user/DELETE-user_delete_webhook.test.js delete mode 100644 test/api/v3/integration/user/POST-user_add_webhook.test.js delete mode 100644 test/api/v3/integration/user/PUT-user_update_webhook.test.js create mode 100644 test/api/v3/integration/webhook/DELETE-user_delete_webhook.test.js create mode 100644 test/api/v3/integration/webhook/POST-user_add_webhook.test.js create mode 100644 test/api/v3/integration/webhook/PUT-user_update_webhook.test.js create mode 100644 test/api/v3/unit/models/webhook.test.js delete mode 100644 test/common/ops/addWebhook.test.js delete mode 100644 test/common/ops/deleteWebhook.test.js delete mode 100644 test/common/ops/updateWebhook.test.js create mode 100644 test/helpers/api-integration/v3/external-server.js rename test/helpers/{api-integration => }/translate.js (62%) delete mode 100644 website/common/script/ops/addWebhook.js delete mode 100644 website/common/script/ops/deleteWebhook.js delete mode 100644 website/common/script/ops/updateWebhook.js create mode 100644 website/server/controllers/api-v3/webhook.js create mode 100644 website/server/models/webhook.js diff --git a/migrations/20161002_add_missing_webhook_type.js b/migrations/20161002_add_missing_webhook_type.js new file mode 100644 index 0000000000..4f8aabd04a --- /dev/null +++ b/migrations/20161002_add_missing_webhook_type.js @@ -0,0 +1,114 @@ +'use strict'; + +/**************************************** + * Author: Blade Barringer @crookedneighbor + * + * Reason: Webhooks have been moved from + * being an object on preferences.webhooks + * to being an array on webhooks. In addition + * they support a type and options and label +* ***************************************/ + +global.Promise = require('bluebird'); +const TaskQueue = require('cwait').TaskQueue; +const logger = require('./utils/logger'); +const Timer = require('./utils/timer'); +const connectToDb = require('./utils/connect').connectToDb; +const closeDb = require('./utils/connect').closeDb; +const validator = require('validator'); + +const timer = new Timer(); +const MIGRATION_NAME = '20161002_add_missing_webhook_type.js'; + +// const DB_URI = 'mongodb://username:password@dsXXXXXX-a0.mlab.com:XXXXX,dsXXXXXX-a1.mlab.com:XXXXX/habitica?replicaSet=rs-dsXXXXXX'; +const DB_URI = 'mongodb://localhost/prod-copy-1'; + +const LOGGEDIN_DATE_RANGE = { + $gte: new Date("2016-09-30T00:00:00.000Z"), + // $lte: new Date("2016-09-25T00:00:00.000Z"), +}; + +let Users; + +connectToDb(DB_URI).then((db) => { + Users = db.collection('users'); +}) +.then(findUsersWithWebhooks) +.then(correctWebhooks) +.then(() => { + timer.stop(); + closeDb(); +}).catch(reportError); + +function reportError (err) { + logger.error('Uh oh, an error occurred'); + logger.error(err); + closeDb(); + timer.stop(); +} + +// Cached ids of users that need updating +const USER_IDS = require('../../ids_of_webhooks_to_update.json'); + +function findUsersWithWebhooks () { + logger.warn('Fetching users with webhooks...'); + + return Users.find({'_id': {$in: USER_IDS}}, ['preferences.webhooks']).toArray().then((docs) => { + // return Users.find({'preferences.webhooks': {$ne: {} }}, ['preferences.webhooks']).toArray().then((docs) => { + // TODO: Run this after the initial migration to catch any webhooks that may have been aded since the prod backup download + // return Users.find({'preferences.webhooks': {$ne: {} }, 'auth.timestamps.loggedin': LOGGEDIN_DATE_RANGE}, ['preferences.webhooks']).toArray().then((docs) => { + let updates = docs.map((user) => { + let oldWebhooks = user.preferences.webhooks; + let webhooks = Object.keys(oldWebhooks).map((id) => { + let webhook = oldWebhooks[id] + + webhook.type = 'taskActivity'; + webhook.label = ''; + webhook.options = { + created: false, + updated: false, + deleted: false, + scored: true, + }; + + return webhook; + }); + + return { + webhooks, + id: user._id, + } + }); + + return Promise.resolve(updates); + }); +} + +function updateUserById (user) { + let userId = user.id; + let webhooks = user.webhooks; + + return Users.findOneAndUpdate({ + _id: userId}, + {$set: {webhooks: webhooks, migration: MIGRATION_NAME} + }, {returnOriginal: false}) +} + +function correctWebhooks (users) { + let queue = new TaskQueue(Promise, 300); + + logger.warn('About to update', users.length, 'users...'); + + return Promise.map(users, queue.wrap(updateUserById)).then((result) => { + let updates = result.filter(res => res.lastErrorObject && res.lastErrorObject.updatedExisting) + let failures = result.filter(res => !(res.lastErrorObject && res.lastErrorObject.updatedExisting)); + + logger.warn(updates.length, 'users have been fixed'); + + if (failures.length > 0) { + logger.error(failures.length, 'users could not be found'); + } + + return Promise.resolve(); + }); +} diff --git a/migrations/utils/connect.js b/migrations/utils/connect.js index 0c1b104892..492b473708 100644 --- a/migrations/utils/connect.js +++ b/migrations/utils/connect.js @@ -25,7 +25,7 @@ function connectToDb (dbUri) { function closeDb () { if (db) db.close(); - logger.success('CLosed connection to the database'); + logger.success('Closed connection to the database'); return Promise.resolve(); } diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index 99d1469af8..bfea4d031c 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -1,7 +1,10 @@ import { createAndPopulateGroup, translate as t, + sleep, + server, } from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('POST /chat', () => { let user, groupWithChat, userWithChatRevoked, member; @@ -54,6 +57,44 @@ describe('POST /chat', () => { expect(message.message.id).to.exist; }); + it('sends group chat received webhooks', async () => { + let userUuid = generateUUID(); + let memberUuid = generateUUID(); + await server.start(); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${userUuid}`, + type: 'groupChatReceived', + enabled: true, + options: { + groupId: groupWithChat.id, + }, + }); + await member.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${memberUuid}`, + type: 'groupChatReceived', + enabled: true, + options: { + groupId: groupWithChat.id, + }, + }); + + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }); + + await sleep(); + + await server.close(); + + let userBody = server.getWebhookData(userUuid); + let memberBody = server.getWebhookData(memberUuid); + + [userBody, memberBody].forEach((body) => { + expect(body.group.id).to.eql(groupWithChat._id); + expect(body.group.name).to.eql(groupWithChat.name); + expect(body.chat).to.eql(message.message); + }); + }); + it('notifies other users of new messages for a guild', async () => { let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); let memberWithNotification = await member.get('/user'); diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 450572df7b..6f9ef63b67 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -1,7 +1,12 @@ import { generateUser, translate as t, + generateGroup, + sleep, + generateChallenge, + server, } from '../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:id', () => { let user; @@ -42,6 +47,77 @@ describe('DELETE /tasks/:id', () => { }); }); + context('sending task activity webhooks', () => { + before(async () => { + await server.start(); + }); + + after(async () => { + await server.close(); + }); + + it('sends task activity webhooks if task is user owned', async () => { + let uuid = generateUUID(); + + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + }); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'taskActivity', + enabled: true, + options: { + created: false, + deleted: true, + }, + }); + + await user.del(`/tasks/${task.id}`); + + await sleep(); + + let body = server.getWebhookData(uuid); + + expect(body.type).to.eql('deleted'); + expect(body.task).to.eql(task); + }); + + it('does not send task activity webhooks if task is not user owned', async () => { + let uuid = generateUUID(); + + await user.update({ + balance: 10, + }); + let guild = await generateGroup(user); + let challenge = await generateChallenge(user, guild); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'taskActivity', + enabled: true, + options: { + created: false, + deleted: true, + }, + }); + + let challengeTask = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + }); + + await user.del(`/tasks/${challengeTask.id}`); + + await sleep(); + + let body = server.getWebhookData(uuid); + + expect(body).to.not.exist; + }); + }); + context('task cannot be deleted', () => { it('cannot delete a non-existant task', async () => { await expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index e7c71c28d5..fdabc86752 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -1,6 +1,8 @@ import { generateUser, + sleep, translate as t, + server, } from '../../../../helpers/api-integration/v3'; import { v4 as generateUUID } from 'uuid'; @@ -45,6 +47,40 @@ describe('POST /tasks/:id/score/:direction', () => { message: t('invalidReqParams'), }); }); + + it('sends task scored webhooks', async () => { + let uuid = generateUUID(); + await server.start(); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'taskActivity', + enabled: true, + options: { + created: false, + scored: true, + }, + }); + + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + }); + + await user.post(`/tasks/${task.id}/score/up`); + + await sleep(); + + await server.close(); + + let body = server.getWebhookData(uuid); + + expect(body.user).to.have.all.keys('_id', '_tmp', 'stats'); + expect(body.user.stats).to.have.all.keys('hp', 'mp', 'exp', 'gp', 'lvl', 'class', 'points', 'str', 'con', 'int', 'per', 'buffs', 'training', 'maxHealth', 'maxMP', 'toNextLevel'); + expect(body.task.id).to.eql(task.id); + expect(body.direction).to.eql('up'); + expect(body.delta).to.be.greaterThan(0); + }); }); context('todos', () => { diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js index 994bf59b04..f249d101b7 100644 --- a/test/api/v3/integration/tasks/POST-tasks_user.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -1,13 +1,15 @@ import { generateUser, + sleep, translate as t, + server, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/user', () => { let user; - before(async () => { + beforeEach(async () => { user = await generateUser(); }); @@ -205,6 +207,71 @@ describe('POST /tasks/user', () => { }); }); + context('sending task activity webhooks', () => { + before(async () => { + await server.start(); + }); + + after(async () => { + await server.close(); + }); + + it('sends task activity webhooks', async () => { + let uuid = generateUUID(); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'taskActivity', + enabled: true, + options: { + created: true, + }, + }); + + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + }); + + await sleep(); + + let body = server.getWebhookData(uuid); + + expect(body.task).to.eql(task); + }); + + it('sends a task activity webhook for each task', async () => { + let uuid = generateUUID(); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'taskActivity', + enabled: true, + options: { + created: true, + }, + }); + + let tasks = await user.post('/tasks/user', [{ + text: 'test habit', + type: 'habit', + }, { + text: 'test todo', + type: 'todo', + }]); + + await sleep(); + + let taskBodies = [ + server.getWebhookData(uuid), + server.getWebhookData(uuid), + ]; + + expect(taskBodies.find(body => body.task.id === tasks[0].id)).to.exist; + expect(taskBodies.find(body => body.task.id === tasks[1].id)).to.exist; + }); + }); + context('all types', () => { it('can create reminders', async () => { let id1 = generateUUID(); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 1660bb6f7f..1c6e5734c8 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -3,6 +3,7 @@ import { generateGroup, sleep, generateChallenge, + server, } from '../../../../helpers/api-integration/v3'; import { v4 as generateUUID } from 'uuid'; @@ -145,6 +146,81 @@ describe('PUT /tasks/:id', () => { }); }); + context('sending task activity webhooks', () => { + before(async () => { + await server.start(); + }); + + after(async () => { + await server.close(); + }); + + it('sends task activity webhooks if task is user owned', async () => { + let uuid = generateUUID(); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'taskActivity', + enabled: true, + options: { + created: false, + updated: true, + }, + }); + + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + }); + + let updatedTask = await user.put(`/tasks/${task.id}`, { + text: 'updated text', + }); + + await sleep(); + + let body = server.getWebhookData(uuid); + + expect(body.type).to.eql('updated'); + expect(body.task).to.eql(updatedTask); + }); + + it('does not send task activity webhooks if task is not user owned', async () => { + let uuid = generateUUID(); + + await user.update({ + balance: 10, + }); + let guild = await generateGroup(user); + let challenge = await generateChallenge(user, guild); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'taskActivity', + enabled: true, + options: { + created: false, + updated: true, + }, + }); + + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + }); + + await user.put(`/tasks/${task.id}`, { + text: 'updated text', + }); + + await sleep(); + + let body = server.getWebhookData(uuid); + + expect(body).to.not.exist; + }); + }); + context('all types', () => { let daily; 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 deleted file mode 100644 index 46844dd855..0000000000 --- a/test/api/v3/integration/user/DELETE-user_delete_webhook.test.js +++ /dev/null @@ -1,23 +0,0 @@ -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/GET-user_anonymized.test.js b/test/api/v3/integration/user/GET-user_anonymized.test.js index d476cf326e..aae9eb9570 100644 --- a/test/api/v3/integration/user/GET-user_anonymized.test.js +++ b/test/api/v3/integration/user/GET-user_anonymized.test.js @@ -13,12 +13,19 @@ describe('GET /user/anonymized', () => { before(async () => { user = await generateUser(); - await user.update({ newMessages: ['some', 'new', 'messages'], 'profile.name': 'profile', 'purchased.plan': 'purchased plan', - contributor: 'contributor', invitations: 'invitations', 'items.special.nyeReceived': 'some', 'items.special.valentineReceived': 'some', - webhooks: 'some', 'achievements.challenges': 'some', - 'inbox.messages': [{ text: 'some text' }], - tags: [{ name: 'some name', challenge: 'some challenge' }], - }); + await user.update({ + newMessages: ['some', 'new', 'messages'], + 'profile.name': 'profile', + 'purchased.plan': 'purchased plan', + contributor: 'contributor', + invitations: 'invitations', + 'items.special.nyeReceived': 'some', + 'items.special.valentineReceived': 'some', + webhooks: [{url: 'https://somurl.com'}], + 'achievements.challenges': 'some', + 'inbox.messages': [{ text: 'some text' }], + tags: [{ name: 'some name', challenge: 'some challenge' }], + }); await generateHabit({ userId: user._id }); await generateHabit({ userId: user._id, text: generateUUID() }); 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 deleted file mode 100644 index d13f15baa4..0000000000 --- a/test/api/v3/integration/user/POST-user_add_webhook.test.js +++ /dev/null @@ -1,29 +0,0 @@ -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.test.js b/test/api/v3/integration/user/PUT-user.test.js index d967504fc7..7ef650dce4 100644 --- a/test/api/v3/integration/user/PUT-user.test.js +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -37,6 +37,7 @@ describe('PUT /user', () => { subscriptions: {'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000}, 'customization gem purchases': {'purchased.background.tavern': true, 'purchased.skin.bear': true}, notifications: [{type: 123}], + webhooks: {webhooks: [{url: 'https://foobar.com'}]}, }; each(protectedOperations, (data, testName) => { 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 deleted file mode 100644 index 13ca9ff00c..0000000000 --- a/test/api/v3/integration/user/PUT-user_update_webhook.test.js +++ /dev/null @@ -1,32 +0,0 @@ -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/api/v3/integration/webhook/DELETE-user_delete_webhook.test.js b/test/api/v3/integration/webhook/DELETE-user_delete_webhook.test.js new file mode 100644 index 0000000000..3126cd2b5a --- /dev/null +++ b/test/api/v3/integration/webhook/DELETE-user_delete_webhook.test.js @@ -0,0 +1,54 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +let user, webhookToDelete; +let endpoint = '/user/webhook'; + +describe('DELETE /user/webhook', () => { + beforeEach(async () => { + user = await generateUser(); + + webhookToDelete = await user.post('/user/webhook', { + url: 'http://some-url.com', + enabled: true, + }); + await user.post('/user/webhook', { + url: 'http://some-other-url.com', + enabled: false, + }); + + await user.sync(); + }); + + it('deletes a webhook', async () => { + expect(user.webhooks).to.have.a.lengthOf(2); + await user.del(`${endpoint}/${webhookToDelete.id}`); + + await user.sync(); + + expect(user.webhooks).to.have.a.lengthOf(1); + }); + + it('returns the remaining webhooks', async () => { + let [remainingWebhook] = await user.del(`${endpoint}/${webhookToDelete.id}`); + + await user.sync(); + + let webhook = user.webhooks[0]; + + expect(remainingWebhook.id).to.eql(webhook.id); + expect(remainingWebhook.url).to.eql(webhook.url); + expect(remainingWebhook.type).to.eql(webhook.type); + expect(remainingWebhook.options).to.eql(webhook.options); + }); + + it('returns an error if webhook with id does not exist', async () => { + await expect(user.del(`${endpoint}/id-that-does-not-exist`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noWebhookWithId', {id: 'id-that-does-not-exist'}), + }); + }); +}); diff --git a/test/api/v3/integration/webhook/POST-user_add_webhook.test.js b/test/api/v3/integration/webhook/POST-user_add_webhook.test.js new file mode 100644 index 0000000000..50187515b8 --- /dev/null +++ b/test/api/v3/integration/webhook/POST-user_add_webhook.test.js @@ -0,0 +1,211 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /user/webhook', () => { + let user, body; + + beforeEach(async () => { + user = await generateUser(); + body = { + id: generateUUID(), + url: 'https://example.com/endpoint', + type: 'taskActivity', + enabled: false, + }; + }); + + it('requires a url', async () => { + delete body.url; + + await expect(user.post('/user/webhook', body)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'User validation failed', + }); + }); + + it('requires custom id to be a uuid', async () => { + body.id = 'not-a-uuid'; + + await expect(user.post('/user/webhook', body)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'User validation failed', + }); + }); + + it('defaults id to a uuid', async () => { + delete body.id; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.id).to.exist; + }); + + it('requires type to be of an accetable type', async () => { + body.type = 'not a valid type'; + + await expect(user.post('/user/webhook', body)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'User validation failed', + }); + }); + + it('defaults enabled to true', async () => { + delete body.enabled; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.enabled).to.be.true; + }); + + it('can pass a label', async () => { + body.label = 'Custom Label'; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.label).to.equal('Custom Label'); + }); + + it('defaults type to taskActivity', async () => { + delete body.type; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.type).to.eql('taskActivity'); + }); + + it('successfully adds the webhook', async () => { + expect(user.webhooks).to.eql([]); + + let response = await user.post('/user/webhook', body); + + expect(response.id).to.eql(body.id); + expect(response.type).to.eql(body.type); + expect(response.url).to.eql(body.url); + expect(response.enabled).to.eql(body.enabled); + + await user.sync(); + + expect(user.webhooks).to.not.eql([]); + + let webhook = user.webhooks[0]; + + expect(webhook.enabled).to.be.false; + expect(webhook.type).to.eql('taskActivity'); + expect(webhook.url).to.eql(body.url); + }); + + it('defaults taskActivity options', async () => { + body.type = 'taskActivity'; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.options).to.eql({ + created: false, + updated: false, + deleted: false, + scored: true, + }); + }); + + it('can set taskActivity options', async () => { + body.type = 'taskActivity'; + body.options = { + created: true, + updated: true, + deleted: true, + scored: false, + }; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.options).to.eql({ + created: true, + updated: true, + deleted: true, + scored: false, + }); + }); + + it('discards extra properties in taskActivity options', async () => { + body.type = 'taskActivity'; + body.options = { + created: true, + updated: true, + deleted: true, + scored: false, + foo: 'bar', + }; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.options.foo).to.not.exist; + expect(webhook.options).to.eql({ + created: true, + updated: true, + deleted: true, + scored: false, + }); + }); + + ['created', 'updated', 'deleted', 'scored'].forEach((option) => { + it(`requires taskActivity option ${option} to be a boolean`, async () => { + body.type = 'taskActivity'; + body.options = { + [option]: 'not a boolean', + }; + + await expect(user.post('/user/webhook', body)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('webhookBooleanOption', { option }), + }); + }); + }); + + it('can set groupChatReceived options', async () => { + body.type = 'groupChatReceived'; + body.options = { + groupId: generateUUID(), + }; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.options).to.eql({ + groupId: body.options.groupId, + }); + }); + + it('groupChatReceived options requires a uuid for the groupId', async () => { + body.type = 'groupChatReceived'; + body.options = { + groupId: 'not-a-uuid', + }; + + await expect(user.post('/user/webhook', body)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('groupIdRequired'), + }); + }); + + it('discards extra properties in groupChatReceived options', async () => { + body.type = 'groupChatReceived'; + body.options = { + groupId: generateUUID(), + foo: 'bar', + }; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.options.foo).to.not.exist; + expect(webhook.options).to.eql({ + groupId: body.options.groupId, + }); + }); +}); diff --git a/test/api/v3/integration/webhook/PUT-user_update_webhook.test.js b/test/api/v3/integration/webhook/PUT-user_update_webhook.test.js new file mode 100644 index 0000000000..2e02562b5f --- /dev/null +++ b/test/api/v3/integration/webhook/PUT-user_update_webhook.test.js @@ -0,0 +1,132 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { v4 as generateUUID} from 'uuid'; + +describe('PUT /user/webhook/:id', () => { + let user, webhookToUpdate; + + beforeEach(async () => { + user = await generateUser(); + + webhookToUpdate = await user.post('/user/webhook', { + url: 'http://some-url.com', + label: 'Original Label', + enabled: true, + type: 'taskActivity', + options: { created: true, scored: true }, + }); + await user.post('/user/webhook', { + url: 'http://some-other-url.com', + enabled: false, + }); + + await user.sync(); + }); + + it('returns an error if webhook with id does not exist', async () => { + await expect(user.put('/user/webhook/id-that-does-not-exist')).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noWebhookWithId', {id: 'id-that-does-not-exist'}), + }); + }); + + it('returns an error if validation fails', async () => { + await expect(user.put(`/user/webhook/${webhookToUpdate.id}`, { url: 'foo', enabled: true })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'User validation failed', + }); + }); + + it('updates a webhook', async () => { + let url = 'http://a-new-url.com'; + let type = 'groupChatReceived'; + let label = 'New Label'; + let options = { groupId: generateUUID() }; + + await user.put(`/user/webhook/${webhookToUpdate.id}`, {url, type, options, label}); + + await user.sync(); + + let webhook = user.webhooks.find(hook => webhookToUpdate.id === hook.id); + + expect(webhook.url).to.equal(url); + expect(webhook.label).to.equal(label); + expect(webhook.type).to.equal(type); + expect(webhook.options).to.eql(options); + }); + + it('returns the updated webhook', async () => { + let url = 'http://a-new-url.com'; + let type = 'groupChatReceived'; + let options = { groupId: generateUUID() }; + + let response = await user.put(`/user/webhook/${webhookToUpdate.id}`, {url, type, options}); + + expect(response.url).to.eql(url); + expect(response.type).to.eql(type); + expect(response.options).to.eql(options); + }); + + it('cannot update the id', async () => { + let id = generateUUID(); + let url = 'http://a-new-url.com'; + + await user.put(`/user/webhook/${webhookToUpdate.id}`, {url, id}); + + await user.sync(); + + let webhook = user.webhooks.find(hook => webhookToUpdate.id === hook.id); + + expect(webhook.id).to.eql(webhookToUpdate.id); + expect(webhook.url).to.eql(url); + }); + + it('can update taskActivity options', async () => { + let type = 'taskActivity'; + let options = { + updated: false, + deleted: true, + }; + + let webhook = await user.put(`/user/webhook/${webhookToUpdate.id}`, {type, options}); + + expect(webhook.options).to.eql({ + created: true, // starting value + updated: false, + deleted: true, + scored: true, // default value + }); + }); + + it('errors if taskActivity option is not a boolean', async () => { + let type = 'taskActivity'; + let options = { + created: 'not a boolean', + updated: false, + deleted: true, + }; + + await expect(user.put(`/user/webhook/${webhookToUpdate.id}`, {type, options})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('webhookBooleanOption', { option: 'created' }), + }); + }); + + it('errors if groupChatRecieved groupId option is not a uuid', async () => { + let type = 'groupChatReceived'; + let options = { + groupId: 'not-a-uuid', + }; + + await expect(user.put(`/user/webhook/${webhookToUpdate.id}`, {type, options})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('groupIdRequired'), + }); + }); +}); diff --git a/test/api/v3/unit/libs/webhooks.test.js b/test/api/v3/unit/libs/webhooks.test.js index 85072792fb..49c3d0e24f 100644 --- a/test/api/v3/unit/libs/webhooks.test.js +++ b/test/api/v3/unit/libs/webhooks.test.js @@ -1,135 +1,376 @@ import request from 'request'; -import { sendTaskWebhook } from '../../../../../website/server/libs/webhook'; +import { + WebhookSender, + taskScoredWebhook, + groupChatReceivedWebhook, + taskActivityWebhook, +} from '../../../../../website/server/libs/webhook'; describe('webhooks', () => { + let webhooks; + beforeEach(() => { sandbox.stub(request, 'post'); + + webhooks = [{ + id: 'taskActivity', + url: 'http://task-scored.com', + enabled: true, + type: 'taskActivity', + options: { + created: true, + updated: true, + deleted: true, + scored: true, + }, + }, { + id: 'groupChatReceived', + url: 'http://group-chat-received.com', + enabled: true, + type: 'groupChatReceived', + options: { + groupId: 'group-id', + }, + }]; }); afterEach(() => { sandbox.restore(); }); - describe('sendTaskWebhook', () => { - let task = { - details: { _id: 'task-id' }, - delta: 1.4, - direction: 'up', - }; + describe('WebhookSender', () => { + it('creates a new WebhookSender object', () => { + let sendWebhook = new WebhookSender({ + type: 'custom', + }); - let data = { - task, - user: { _id: 'user-id' }, - }; + expect(sendWebhook.type).to.equal('custom'); + expect(sendWebhook).to.respondTo('send'); + }); - it('does not send if no webhook endpoints exist', () => { - let webhooks = { }; + it('provides default function for data transformation', () => { + sandbox.spy(WebhookSender, 'defaultTransformData'); + let sendWebhook = new WebhookSender({ + type: 'custom', + }); - sendTaskWebhook(webhooks, data); + let body = { foo: 'bar' }; + sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body); + + expect(WebhookSender.defaultTransformData).to.be.calledOnce; + expect(request.post).to.be.calledOnce; + expect(request.post).to.be.calledWithMatch({ + body, + }); + }); + + it('can pass in a data transformation function', () => { + sandbox.spy(WebhookSender, 'defaultTransformData'); + let sendWebhook = new WebhookSender({ + type: 'custom', + transformData (data) { + let dataToSend = Object.assign({baz: 'biz'}, data); + + return dataToSend; + }, + }); + + let body = { foo: 'bar' }; + + sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body); + + expect(WebhookSender.defaultTransformData).to.not.be.called; + expect(request.post).to.be.calledOnce; + expect(request.post).to.be.calledWithMatch({ + body: { + foo: 'bar', + baz: 'biz', + }, + }); + }); + + it('provieds a default filter function', () => { + sandbox.spy(WebhookSender, 'defaultWebhookFilter'); + let sendWebhook = new WebhookSender({ + type: 'custom', + }); + + let body = { foo: 'bar' }; + + sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body); + + expect(WebhookSender.defaultWebhookFilter).to.be.calledOnce; + }); + + it('can pass in a webhook filter function', () => { + sandbox.spy(WebhookSender, 'defaultWebhookFilter'); + let sendWebhook = new WebhookSender({ + type: 'custom', + webhookFilter (hook) { + return hook.url !== 'http://custom-url.com'; + }, + }); + + let body = { foo: 'bar' }; + + sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body); + + expect(WebhookSender.defaultWebhookFilter).to.not.be.called; expect(request.post).to.not.be.called; }); - it('does not send if no webhooks are enabled', () => { - let webhooks = { - 'some-id': { - sort: 0, - id: 'some-id', - enabled: false, - url: 'http://example.org/endpoint', + it('can pass in a webhook filter function that filters on data', () => { + sandbox.spy(WebhookSender, 'defaultWebhookFilter'); + let sendWebhook = new WebhookSender({ + type: 'custom', + webhookFilter (hook, data) { + return hook.options.foo === data.foo; }, - }; + }); - sendTaskWebhook(webhooks, data); + let body = { foo: 'bar' }; - expect(request.post).to.not.be.called; - }); - - it('does not send if webhook url is not valid', () => { - let webhooks = { - 'some-id': { - sort: 0, - id: 'some-id', - enabled: true, - url: 'http://malformedurl/endpoint', - }, - }; - - sendTaskWebhook(webhooks, data); - - expect(request.post).to.not.be.called; - }); - - it('sends task direction, task, task delta, and abridged user data', () => { - let webhooks = { - 'some-id': { - sort: 0, - id: 'some-id', - enabled: true, - url: 'http://example.org/endpoint', - }, - }; - - sendTaskWebhook(webhooks, data); + sendWebhook.send([ + { id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom', options: { foo: 'bar' }}, + { id: 'other-custom-webhook', url: 'http://other-custom-url.com', enabled: true, type: 'custom', options: { foo: 'foo' }}, + ], body); expect(request.post).to.be.calledOnce; - expect(request.post).to.be.calledWith({ - url: 'http://example.org/endpoint', - body: { - direction: 'up', - task: { _id: 'task-id' }, - delta: 1.4, - user: { - _id: 'user-id', - }, - }, + expect(request.post).to.be.calledWithMatch({ + url: 'http://custom-url.com', + }); + }); + + it('ignores disabled webhooks', () => { + let sendWebhook = new WebhookSender({ + type: 'custom', + }); + + let body = { foo: 'bar' }; + + sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: false, type: 'custom'}], body); + + expect(request.post).to.not.be.called; + }); + + it('ignores webhooks with invalid urls', () => { + let sendWebhook = new WebhookSender({ + type: 'custom', + }); + + let body = { foo: 'bar' }; + + sendWebhook.send([{id: 'custom-webhook', url: 'httxp://custom-url!!', enabled: true, type: 'custom'}], body); + + expect(request.post).to.not.be.called; + }); + + it('ignores webhooks of other types', () => { + let sendWebhook = new WebhookSender({ + type: 'custom', + }); + + let body = { foo: 'bar' }; + + sendWebhook.send([ + { id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}, + { id: 'other-webhook', url: 'http://other-url.com', enabled: true, type: 'other'}, + ], body); + + expect(request.post).to.be.calledOnce; + expect(request.post).to.be.calledWithMatch({ + url: 'http://custom-url.com', + body, json: true, }); }); - it('sends a post request for each webhook endpoint', () => { - let webhooks = { - 'some-id': { - sort: 0, - id: 'some-id', - enabled: true, - url: 'http://example.org/endpoint', - }, - 'second-webhook': { - sort: 1, - id: 'second-webhook', - enabled: true, - url: 'http://example.com/2/endpoint', - }, - }; + it('sends multiple webhooks of the same type', () => { + let sendWebhook = new WebhookSender({ + type: 'custom', + }); - sendTaskWebhook(webhooks, data); + let body = { foo: 'bar' }; + + sendWebhook.send([ + { id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}, + { id: 'other-custom-webhook', url: 'http://other-url.com', enabled: true, type: 'custom'}, + ], body); expect(request.post).to.be.calledTwice; - expect(request.post).to.be.calledWith({ - url: 'http://example.org/endpoint', - body: { - direction: 'up', - task: { _id: 'task-id' }, - delta: 1.4, - user: { - _id: 'user-id', - }, - }, + expect(request.post).to.be.calledWithMatch({ + url: 'http://custom-url.com', + body, json: true, }); - expect(request.post).to.be.calledWith({ - url: 'http://example.com/2/endpoint', - body: { - direction: 'up', - task: { _id: 'task-id' }, - delta: 1.4, - user: { - _id: 'user-id', - }, - }, + expect(request.post).to.be.calledWithMatch({ + url: 'http://other-url.com', + body, json: true, }); }); }); + + describe('taskScoredWebhook', () => { + let data; + + beforeEach(() => { + data = { + user: { + _id: 'user-id', + _tmp: {foo: 'bar'}, + stats: { + lvl: 5, + int: 10, + str: 5, + exp: 423, + toJSON () { + return this; + }, + }, + addComputedStatsToJSONObj () { + let mockStats = Object.assign({ + maxHealth: 50, + maxMP: 103, + toNextLevel: 40, + }, this.stats); + + delete mockStats.toJSON; + + return mockStats; + }, + }, + task: { + text: 'text', + }, + direction: 'up', + delta: 176, + }; + }); + + it('sends task and stats data', () => { + taskScoredWebhook.send(webhooks, data); + + expect(request.post).to.be.calledOnce; + expect(request.post).to.be.calledWithMatch({ + body: { + type: 'scored', + user: { + _id: 'user-id', + _tmp: {foo: 'bar'}, + stats: { + lvl: 5, + int: 10, + str: 5, + exp: 423, + toNextLevel: 40, + maxHealth: 50, + maxMP: 103, + }, + }, + task: { + text: 'text', + }, + direction: 'up', + delta: 176, + }, + }); + }); + + it('does not send task scored data if scored option is not true', () => { + webhooks[0].options.scored = false; + + taskScoredWebhook.send(webhooks, data); + + expect(request.post).to.not.be.called; + }); + }); + + describe('taskActivityWebhook', () => { + let data; + + beforeEach(() => { + data = { + task: { + text: 'text', + }, + }; + }); + + ['created', 'updated', 'deleted'].forEach((type) => { + it(`sends ${type} tasks`, () => { + data.type = type; + + taskActivityWebhook.send(webhooks, data); + + expect(request.post).to.be.calledOnce; + expect(request.post).to.be.calledWithMatch({ + body: { + type, + task: data.task, + }, + }); + }); + + it(`does not send task ${type} data if ${type} option is not true`, () => { + data.type = type; + webhooks[0].options[type] = false; + + taskActivityWebhook.send(webhooks, data); + + expect(request.post).to.not.be.called; + }); + }); + }); + + describe('groupChatReceivedWebhook', () => { + it('sends chat data', () => { + let data = { + group: { + id: 'group-id', + name: 'some group', + otherData: 'foo', + }, + chat: { + id: 'some-id', + text: 'message', + }, + }; + + groupChatReceivedWebhook.send(webhooks, data); + + expect(request.post).to.be.calledOnce; + expect(request.post).to.be.calledWithMatch({ + body: { + group: { + id: 'group-id', + name: 'some group', + }, + chat: { + id: 'some-id', + text: 'message', + }, + }, + }); + }); + + it('does not send chat data for group if not selected', () => { + let data = { + group: { + id: 'not-group-id', + name: 'some group', + otherData: 'foo', + }, + chat: { + id: 'some-id', + text: 'message', + }, + }; + + groupChatReceivedWebhook.send(webhooks, data); + + expect(request.post).to.not.be.called; + }); + }); }); diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index 270f4a5fa1..b11f8dc9a4 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -3,9 +3,11 @@ import { model as Group, INVITES_LIMIT } from '../../../../../website/server/mod import { model as User } from '../../../../../website/server/models/user'; import { BadRequest } from '../../../../../website/server/libs/errors'; import { quests as questScrolls } from '../../../../../website/common/script/content'; +import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook'; import * as email from '../../../../../website/server/libs/email'; import validator from 'validator'; import { TAVERN_ID } from '../../../../../website/common/script/'; +import { v4 as generateUUID } from 'uuid'; describe('Group Model', () => { let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; @@ -1217,5 +1219,163 @@ describe('Group Model', () => { }); }); }); + + describe('sendGroupChatReceivedWebhooks', () => { + beforeEach(() => { + sandbox.stub(groupChatReceivedWebhook, 'send'); + }); + + it('looks for users in specified guild with webhooks', () => { + sandbox.spy(User, 'find'); + + let guild = new Group({ + type: 'guild', + }); + + guild.sendGroupChatReceivedWebhooks({}); + + expect(User.find).to.be.calledWith({ + webhooks: { + $elemMatch: { + type: 'groupChatReceived', + 'options.groupId': guild._id, + }, + }, + guilds: guild._id, + }); + }); + + it('looks for users in specified party with webhooks', () => { + sandbox.spy(User, 'find'); + + party.sendGroupChatReceivedWebhooks({}); + + expect(User.find).to.be.calledWith({ + webhooks: { + $elemMatch: { + type: 'groupChatReceived', + 'options.groupId': party._id, + }, + }, + 'party._id': party._id, + }); + }); + + it('sends webhooks for users with webhooks', async () => { + let guild = new Group({ + name: 'some guild', + type: 'guild', + }); + + let chat = {message: 'text'}; + let memberWithWebhook = new User({ + guilds: [guild._id], + webhooks: [{ + type: 'groupChatReceived', + url: 'http://someurl.com', + options: { + groupId: guild._id, + }, + }], + }); + let memberWithoutWebhook = new User({ + guilds: [guild._id], + }); + let nonMemberWithWebhooks = new User({ + webhooks: [{ + type: 'groupChatReceived', + url: 'http://a-different-url.com', + options: { + groupId: generateUUID(), + }, + }], + }); + + await Promise.all([ + memberWithWebhook.save(), + memberWithoutWebhook.save(), + nonMemberWithWebhooks.save(), + ]); + + guild.leader = memberWithWebhook._id; + + await guild.save(); + + guild.sendGroupChatReceivedWebhooks(chat); + + await sleep(); + + expect(groupChatReceivedWebhook.send).to.be.calledOnce; + + let args = groupChatReceivedWebhook.send.args[0]; + let webhooks = args[0]; + let options = args[1]; + + expect(webhooks).to.have.a.lengthOf(1); + expect(webhooks[0].id).to.eql(memberWithWebhook.webhooks[0].id); + expect(options.group).to.eql(guild); + expect(options.chat).to.eql(chat); + }); + + it('sends webhooks for each user with webhooks in group', async () => { + let guild = new Group({ + name: 'some guild', + type: 'guild', + }); + + let chat = {message: 'text'}; + let memberWithWebhook = new User({ + guilds: [guild._id], + webhooks: [{ + type: 'groupChatReceived', + url: 'http://someurl.com', + options: { + groupId: guild._id, + }, + }], + }); + let memberWithWebhook2 = new User({ + guilds: [guild._id], + webhooks: [{ + type: 'groupChatReceived', + url: 'http://another-member.com', + options: { + groupId: guild._id, + }, + }], + }); + let memberWithWebhook3 = new User({ + guilds: [guild._id], + webhooks: [{ + type: 'groupChatReceived', + url: 'http://a-third-member.com', + options: { + groupId: guild._id, + }, + }], + }); + + await Promise.all([ + memberWithWebhook.save(), + memberWithWebhook2.save(), + memberWithWebhook3.save(), + ]); + + guild.leader = memberWithWebhook._id; + + await guild.save(); + + guild.sendGroupChatReceivedWebhooks(chat); + + await sleep(); + + expect(groupChatReceivedWebhook.send).to.be.calledThrice; + + let args = groupChatReceivedWebhook.send.args; + expect(args.find(arg => arg[0][0].id === memberWithWebhook.webhooks[0].id)).to.be.exist; + expect(args.find(arg => arg[0][0].id === memberWithWebhook2.webhooks[0].id)).to.be.exist; + expect(args.find(arg => arg[0][0].id === memberWithWebhook3.webhooks[0].id)).to.be.exist; + }); + }); }); }); diff --git a/test/api/v3/unit/models/user.test.js b/test/api/v3/unit/models/user.test.js index 64f6b3e332..861f8e0016 100644 --- a/test/api/v3/unit/models/user.test.js +++ b/test/api/v3/unit/models/user.test.js @@ -40,7 +40,7 @@ describe('User Model', () => { expect(userToJSON.stats.maxHealth).to.not.exist; expect(userToJSON.stats.toNextLevel).to.not.exist; - user.addComputedStatsToJSONObj(userToJSON); + user.addComputedStatsToJSONObj(userToJSON.stats); expect(userToJSON.stats.maxMP).to.exist; expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth); diff --git a/test/api/v3/unit/models/webhook.test.js b/test/api/v3/unit/models/webhook.test.js new file mode 100644 index 0000000000..ddb63e9caf --- /dev/null +++ b/test/api/v3/unit/models/webhook.test.js @@ -0,0 +1,146 @@ +import { model as Webhook } from '../../../../../website/server/models/webhook'; +import { BadRequest } from '../../../../../website/server/libs/errors'; +import { v4 as generateUUID } from 'uuid'; + +describe('Webhook Model', () => { + context('Instance Methods', () => { + describe('#formatOptions', () => { + let res; + + beforeEach(() => { + res = { + t: sandbox.spy(), + }; + }); + context('type is taskActivity', () => { + let config; + + beforeEach(() => { + config = { + type: 'taskActivity', + url: 'https//exmaple.com/endpoint', + options: { + created: true, + updated: true, + deleted: true, + scored: true, + }, + }; + }); + + it('it provides default values for options', () => { + delete config.options; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options).to.eql({ + created: false, + updated: false, + deleted: false, + scored: true, + }); + }); + + it('provides missing task options', () => { + delete config.options.created; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options).to.eql({ + created: false, + updated: true, + deleted: true, + scored: true, + }); + }); + + it('discards additional options', () => { + config.options.foo = 'another option'; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options.foo).to.not.exist; + expect(wh.options).to.eql({ + created: true, + updated: true, + deleted: true, + scored: true, + }); + }); + + ['created', 'updated', 'deleted', 'scored'].forEach((option) => { + it(`validates that ${option} is a boolean`, (done) => { + config.options[option] = 'not a boolean'; + + try { + let wh = new Webhook(config); + + wh.formatOptions(res); + } catch (err) { + expect(err).to.be.an.instanceOf(BadRequest); + expect(res.t).to.be.calledOnce; + expect(res.t).to.be.calledWith('webhookBooleanOption', { option }); + done(); + } + }); + }); + }); + + context('type is groupChatReceived', () => { + let config; + + beforeEach(() => { + config = { + type: 'groupChatReceived', + url: 'https//exmaple.com/endpoint', + options: { + groupId: generateUUID(), + }, + }; + }); + + it('creates options', () => { + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options).to.eql(config.options); + }); + + it('discards additional objects', () => { + config.options.foo = 'another thing'; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options.foo).to.not.exist; + expect(wh.options).to.eql({ + groupId: config.options.groupId, + }); + }); + + it('requires groupId option to be a uuid', (done) => { + config.options.groupId = 'not a uuid'; + + try { + let wh = new Webhook(config); + + wh.formatOptions(res); + } catch (err) { + expect(err).to.be.an.instanceOf(BadRequest); + expect(res.t).to.be.calledOnce; + expect(res.t).to.be.calledWith('groupIdRequired'); + done(); + } + }); + }); + }); + }); +}); diff --git a/test/common/ops/addWebhook.test.js b/test/common/ops/addWebhook.test.js deleted file mode 100644 index ce7dbdb3d0..0000000000 --- a/test/common/ops/addWebhook.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import addWebhook from '../../../website/common/script/ops/addWebhook'; -import { - BadRequest, -} from '../../../website/common/script/libs/errors'; -import i18n from '../../../website/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 deleted file mode 100644 index bacc5db459..0000000000 --- a/test/common/ops/deleteWebhook.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import deleteWebhook from '../../../website/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': {}, 'another-id': {} }; - let [data] = deleteWebhook(user, req); - expect(user.preferences.webhooks).to.eql({'another-id': {}}); - expect(data).to.equal(user.preferences.webhooks); - }); -}); diff --git a/test/common/ops/updateWebhook.test.js b/test/common/ops/updateWebhook.test.js deleted file mode 100644 index 2f7d680179..0000000000 --- a/test/common/ops/updateWebhook.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import updateWebhook from '../../../website/common/script/ops/updateWebhook'; -import { - BadRequest, -} from '../../../website/common/script/libs/errors'; -import i18n from '../../../website/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/test/helpers/api-integration/v3/external-server.js b/test/helpers/api-integration/v3/external-server.js new file mode 100644 index 0000000000..33991240f8 --- /dev/null +++ b/test/helpers/api-integration/v3/external-server.js @@ -0,0 +1,70 @@ +'use strict'; + +let express = require('express'); +let uuid = require('uuid'); +let bodyParser = require('body-parser'); +let app = express(); +let server = require('http').createServer(app); + +const PORT = process.env.TEST_WEBHOOK_APP_PORT || 3099; // eslint-disable-line no-process-env + +let webhookData = {}; + +app.use(bodyParser.urlencoded({ + extended: true, +})); +app.use(bodyParser.json()); + +app.post('/webhooks/:id', function (req, res) { + let id = req.params.id; + + if (!webhookData[id]) { + webhookData[id] = []; + } + + webhookData[id].push(req.body); + + res.status(200); +}); + +// Helps close down server from within mocha test +// See http://stackoverflow.com/a/37054753/2601552 +let sockets = {}; +server.on('connection', (socket) => { + let id = uuid.v4(); + sockets[id] = socket; + + socket.once('close', () => { + delete sockets[id]; + }); +}); + +function start () { + return new Promise((resolve) => { + server.listen(PORT, resolve); + }); +} + +function close () { + return new Promise((resolve) => { + server.close(resolve); + + Object.keys(sockets).forEach((socket) => { + sockets[socket].end(); + }); + }); +} + +function getWebhookData (id) { + if (!webhookData[id]) { + return null; + } + return webhookData[id].pop(); +} + +module.exports = { + start, + close, + getWebhookData, + port: PORT, +}; diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index bb1e058efe..a7692d4b19 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -1,11 +1,14 @@ /* eslint-disable no-use-before-define */ -// Import requester function, set it up for v2, export it +// Import requester function, set it up for v3, export it import { requester } from '../requester'; requester.setApiVersion('v3'); export { requester }; -export { translate } from '../translate'; +import server from './external-server'; +export { server }; + +export { translate } from '../../translate'; export { checkExistence, getProperty, resetHabiticaDB } from '../../mongo'; export * from './object-generators'; export { sleep } from '../../sleep'; diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 4612e18ba8..a0e9c129f2 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -8,6 +8,7 @@ import { RewardSchema, TodoSchema, } from '../../website/server/models/task'; +export {translate} from './translate'; export function generateUser (options = {}) { let user = new User(options).toObject(); diff --git a/test/helpers/sleep.js b/test/helpers/sleep.js index f8dd9ab165..a33970dfa2 100644 --- a/test/helpers/sleep.js +++ b/test/helpers/sleep.js @@ -1,4 +1,4 @@ -export async function sleep (seconds) { +export async function sleep (seconds = 1) { let milliseconds = seconds * 1000; return new Promise((resolve) => { diff --git a/test/helpers/api-integration/translate.js b/test/helpers/translate.js similarity index 62% rename from test/helpers/api-integration/translate.js rename to test/helpers/translate.js index a6001cd094..81d8aaccba 100644 --- a/test/helpers/api-integration/translate.js +++ b/test/helpers/translate.js @@ -1,13 +1,13 @@ -import i18n from '../../../website/common/script/i18n'; -i18n.translations = require('../../../website/server/libs/i18n').translations; +import i18n from '../../website/common/script/i18n'; +i18n.translations = require('../../website/server/libs/i18n').translations; + +const STRING_ERROR_MSG = 'Error processing the string. Please see Help > Report a Bug.'; +const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/; // Use this to verify error messages returned by the server // That way, if the translated string changes, the test // will not break. NOTE: it checks against errors with string as well. export function translate (key, variables) { - const STRING_ERROR_MSG = 'Error processing the string. Please see Help > Report a Bug.'; - const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/; - let translatedString = i18n.t(key, variables); expect(translatedString).to.not.be.empty; diff --git a/website/client-old/js/controllers/settingsCtrl.js b/website/client-old/js/controllers/settingsCtrl.js index 06eef0f561..5461d467b7 100644 --- a/website/client-old/js/controllers/settingsCtrl.js +++ b/website/client-old/js/controllers/settingsCtrl.js @@ -246,20 +246,26 @@ habitrpg.controller('SettingsCtrl', // ---- Webhooks ------ $scope._newWebhook = {url:''}; - $scope.$watch('user.preferences.webhooks',function(webhooks){ - $scope.hasWebhooks = _.size(webhooks); - }) $scope.addWebhook = function(url) { - User.addWebhook({body:{url:url, enabled:true}}); + User.addWebhook({ + id: Shared.uuid(), + type: 'taskActivity', + options: { + created: false, + updated: false, + deleted: false, + scored: true + }, + url: url, + enabled: true + }); $scope._newWebhook.url = ''; } - $scope.saveWebhook = function(id,webhook) { + $scope.saveWebhook = function(webhook) { delete webhook._editing; - User.updateWebhook({params:{id:id}, body:webhook}); - } - $scope.deleteWebhook = function(id) { - User.deleteWebhook({params:{id:id}}); + User.updateWebhook(webhook); } + $scope.deleteWebhook = User.deleteWebhook; $scope.applyCoupon = function(coupon){ $http.post(ApiUrl.get() + '/api/v3/coupons/validate/'+coupon) diff --git a/website/client-old/js/services/userServices.js b/website/client-old/js/services/userServices.js index ab41a596e2..96199560f7 100644 --- a/website/client-old/js/services/userServices.js +++ b/website/client-old/js/services/userServices.js @@ -543,15 +543,33 @@ angular.module('habitrpg') }, addWebhook: function (data) { - callOpsFunctionAndRequest('addWebhook', 'webhook', "POST", '', data); + return $http({ + method: 'POST', + url: '/api/v3/user/webhook', + data: data, + }).then(function (response) { + var webhook = response.data.data; + user.webhooks.push(webhook); + }); }, - updateWebhook: function (data) { - callOpsFunctionAndRequest('updateWebhook', 'webhook', "PUT", data.params.id, data); + updateWebhook: function (webhook, index) { + return $http({ + method: 'PUT', + url: '/api/v3/user/webhook/' + webhook.id, + data: webhook, + }).then(function (response) { + user.webhooks[index] = response.data.data; + }); }, - deleteWebhook: function (data) { - callOpsFunctionAndRequest('deleteWebhook', 'webhook', "DELETE", data.params.id, data); + deleteWebhook: function (webhook, index) { + return $http({ + method: 'DELETE', + url: '/api/v3/user/webhook/' + webhook.id, + }).then(function () { + user.webhooks.splice(index, index + 1); + }); }, sleep: function () { diff --git a/website/common/locales/en/settings.json b/website/common/locales/en/settings.json index e2c296c0e5..5fa2d2c072 100644 --- a/website/common/locales/en/settings.json +++ b/website/common/locales/en/settings.json @@ -154,7 +154,12 @@ "enabled": "Enabled", "webhookURL": "Webhook URL", "invalidUrl": "invalid url", - "invalidEnabled": "the \"enabled\" parameter should be a boolean", + "invalidEnabled": "the \"enabled\" parameter should be a boolean.", + "invalidWebhookId": "the \"id\" parameter should be a valid UUID.", + "missingWebhookId": "The webhook's id is required.", + "invalidWebhookType": "\"<%= type %>\" is not a valid value for the parameter \"type\".", + "webhookBooleanOption": "\"<%= option %>\" must be a Boolean value.", + "noWebhookWithId": "There is no webhook with the id <%= id %>.", "regIdRequired": "RegId is required", "invalidPushClient": "Invalid client. Only Official Habitica clients can receive push notifications.", "pushDeviceAdded": "Push device added successfully", diff --git a/website/common/script/index.js b/website/common/script/index.js index 7eec246080..1d0d90c6f1 100644 --- a/website/common/script/index.js +++ b/website/common/script/index.js @@ -136,9 +136,6 @@ 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'; @@ -175,9 +172,6 @@ api.ops = { purchaseHourglass, readCard, openMysteryItem, - addWebhook, - updateWebhook, - deleteWebhook, releasePets, releaseBoth, releaseMounts, @@ -264,9 +258,6 @@ api.wrap = function wrapUser (user, main = true) { sortTag: _.partial(importedOps.sortTag, user), updateTag: _.partial(importedOps.updateTag, user), deleteTag: _.partial(importedOps.deleteTag, user), - addWebhook: _.partial(importedOps.addWebhook, user), - updateWebhook: _.partial(importedOps.updateWebhook, user), - deleteWebhook: _.partial(importedOps.deleteWebhook, user), clearPMs: _.partial(importedOps.clearPMs, user), deletePM: _.partial(importedOps.deletePM, user), blockUser: _.partial(importedOps.blockUser, user), diff --git a/website/common/script/ops/addWebhook.js b/website/common/script/ops/addWebhook.js deleted file mode 100644 index aefdee65fd..0000000000 --- a/website/common/script/ops/addWebhook.js +++ /dev/null @@ -1,23 +0,0 @@ -import refPush from '../libs/refPush'; -import validator from 'validator'; -import i18n from '../i18n'; -import { - BadRequest, -} from '../libs/errors'; -import _ from 'lodash'; - -module.exports = function addWebhook (user, req = {}) { - let wh = user.preferences.webhooks; - - 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, - }), - ]; -}; diff --git a/website/common/script/ops/deleteWebhook.js b/website/common/script/ops/deleteWebhook.js deleted file mode 100644 index 9c4f67eba8..0000000000 --- a/website/common/script/ops/deleteWebhook.js +++ /dev/null @@ -1,10 +0,0 @@ -import _ from 'lodash'; - -module.exports = function deleteWebhook (user, req) { - delete user.preferences.webhooks[_.get(req, 'params.id')]; - user.markModified('preferences.webhooks'); - - return [ - user.preferences.webhooks, - ]; -}; diff --git a/website/common/script/ops/index.js b/website/common/script/ops/index.js index 5ce917ae0a..d82c9dc55a 100644 --- a/website/common/script/ops/index.js +++ b/website/common/script/ops/index.js @@ -12,9 +12,6 @@ import addTag from './addTag'; import sortTag from './sortTag'; import updateTag from './updateTag'; import deleteTag from './deleteTag'; -import addWebhook from './addWebhook'; -import updateWebhook from './updateWebhook'; -import deleteWebhook from './deleteWebhook'; import clearPMs from './clearPMs'; import deletePM from './deletePM'; import blockUser from './blockUser'; @@ -58,9 +55,6 @@ module.exports = { sortTag, updateTag, deleteTag, - addWebhook, - updateWebhook, - deleteWebhook, clearPMs, deletePM, blockUser, diff --git a/website/common/script/ops/updateWebhook.js b/website/common/script/ops/updateWebhook.js deleted file mode 100644 index 185f6ad404..0000000000 --- a/website/common/script/ops/updateWebhook.js +++ /dev/null @@ -1,16 +0,0 @@ -import validator from 'validator'; -import i18n from '../i18n'; -import { - BadRequest, -} from '../libs/errors'; - -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/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index 89c6aafa6c..bac1304cda 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -124,6 +124,8 @@ api.postChat = { } else { res.respond(200, {message: savedGroup.chat[0]}); } + + group.sendGroupChatReceivedWebhooks(newChatMessage); }, }; diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index bb6e0fcb21..dc1a8a9311 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -51,7 +51,7 @@ api.getMember = { // manually call toJSON with minimize: true so empty paths aren't returned let memberToJSON = member.toJSON({minimize: true}); - member.addComputedStatsToJSONObj(memberToJSON); + member.addComputedStatsToJSONObj(memberToJSON.stats); res.respond(200, memberToJSON); }, @@ -145,7 +145,7 @@ function _getMembersForItem (type) { // manually call toJSON with minimize: true so empty paths aren't returned let membersToJSON = members.map(member => { let memberToJSON = member.toJSON({minimize: true}); - if (addComputedStats) member.addComputedStatsToJSONObj(memberToJSON); + if (addComputedStats) member.addComputedStatsToJSONObj(memberToJSON.stats); return memberToJSON; }); diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 48f5f1812b..eb2d4c3ad5 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -1,5 +1,8 @@ import { authWithHeaders } from '../../middlewares/auth'; -import { sendTaskWebhook } from '../../libs/webhook'; +import { + taskActivityWebhook, + taskScoredWebhook, +} from '../../libs/webhook'; import { removeFromArray } from '../../libs/collectionManipulators'; import * as Tasks from '../../models/task'; import { model as Challenge } from '../../models/challenge'; @@ -37,7 +40,15 @@ api.createUserTasks = { async handler (req, res) { let user = res.locals.user; let tasks = await createTasks(req, res, {user}); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); + + tasks.forEach((task) => { + taskActivityWebhook.send(user.webhooks, { + type: 'created', + task, + }); + }); }, }; @@ -245,35 +256,18 @@ api.updateTask = { } res.respond(200, savedTask); - if (challenge) challenge.updateTask(savedTask); + + if (challenge) { + challenge.updateTask(savedTask); + } else { + taskActivityWebhook.send(user.webhooks, { + type: 'updated', + task: savedTask, + }); + } }, }; -function _generateWebhookTaskData (task, direction, delta, stats, user) { - let extendedStats = _.extend(stats, { - toNextLevel: common.tnl(user.stats.lvl), - maxHealth: common.maxHealth, - maxMP: common.statsComputed(user).maxMP, - }); - - let userData = { - _id: user._id, - _tmp: user._tmp, - stats: extendedStats, - }; - - let taskData = { - details: task, - direction, - delta, - }; - - return { - task: taskData, - user: userData, - }; -} - /** * @api {post} /api/v3/tasks/:taskId/score/:direction Score a task * @apiVersion 3.0.0 @@ -335,7 +329,12 @@ api.scoreTask = { let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); res.respond(200, resJsonData); - sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); + taskScoredWebhook.send(user.webhooks, { + task, + direction, + delta, + user, + }); if (task.challenge && task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { // Wrapping everything in a try/catch block because if an error occurs using `await` it MUST NOT bubble up because the request has already been handled @@ -869,7 +868,15 @@ api.deleteTask = { } res.respond(200, {}); - if (challenge) challenge.removeTask(task); + + if (challenge) { + challenge.removeTask(task); + } else { + taskActivityWebhook.send(user.webhooks, { + type: 'deleted', + task, + }); + } }, }; diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index e6844a3148..15aa1faada 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -36,7 +36,7 @@ api.getUser = { // Remove apiToken from response TODO make it private at the user level? returned in signup/login delete userToJSON.apiToken; - user.addComputedStatsToJSONObj(userToJSON); + user.addComputedStatsToJSONObj(userToJSON.stats); return res.respond(200, userToJSON); }, }; @@ -921,76 +921,6 @@ api.userOpenMysteryItem = { }, }; -/** -* @api {post} /api/v3/user/webhook Create a new webhook - BETA -* @apiVersion 3.0.0 -* @apiName UserAddWebhook -* @apiGroup User -* -* @apiParam {String} url Body parameter - The webhook's URL -* @apiParam {Boolean} enabled Body parameter - If the webhook should be enabled -* -* @apiSuccess {Object} data The created webhook -*/ -api.addWebhook = { - method: 'POST', - middlewares: [authWithHeaders()], - url: '/user/webhook', - async handler (req, res) { - let user = res.locals.user; - let addWebhookRes = common.ops.addWebhook(user, req); - await user.save(); - res.respond(200, ...addWebhookRes); - }, -}; - -/** -* @api {put} /api/v3/user/webhook/:id Edit a webhook - BETA -* @apiVersion 3.0.0 -* @apiName UserUpdateWebhook -* @apiGroup User -* -* @apiParam {UUID} id The id of the webhook to update -* @apiParam {String} url Body parameter - The webhook's URL -* @apiParam {Boolean} enabled Body parameter - If the webhook should be enabled -* -* @apiSuccess {Object} data The updated webhook -*/ -api.updateWebhook = { - method: 'PUT', - middlewares: [authWithHeaders()], - url: '/user/webhook/:id', - async handler (req, res) { - let user = res.locals.user; - let updateWebhookRes = common.ops.updateWebhook(user, req); - await user.save(); - res.respond(200, ...updateWebhookRes); - }, -}; - -/** -* @api {delete} /api/v3/user/webhook/:id Delete a webhook - BETA -* @apiVersion 3.0.0 -* @apiName UserDeleteWebhook -* @apiGroup User -* -* @apiParam {UUID} id The id of the webhook to delete -* -* @apiSuccess {Object} data The user webhooks -*/ -api.deleteWebhook = { - method: 'DELETE', - middlewares: [authWithHeaders()], - url: '/user/webhook/:id', - async handler (req, res) { - let user = res.locals.user; - let deleteWebhookRes = common.ops.deleteWebhook(user, req); - await user.save(); - res.respond(200, ...deleteWebhookRes); - }, -}; - - /* @api {post} /api/v3/user/release-pets Release pets * @apiVersion 3.0.0 * @apiName UserReleasePets diff --git a/website/server/controllers/api-v3/webhook.js b/website/server/controllers/api-v3/webhook.js new file mode 100644 index 0000000000..4c6baaf0eb --- /dev/null +++ b/website/server/controllers/api-v3/webhook.js @@ -0,0 +1,193 @@ +import { authWithHeaders } from '../../middlewares/auth'; +import { model as Webhook } from '../../models/webhook'; +import { removeFromArray } from '../../libs/collectionManipulators'; +import { NotFound } from '../../libs/errors'; + +let api = {}; + +/** +* @api {post} /api/v3/user/webhook Create a new webhook - BETA +* @apiName AddWebhook +* @apiGroup Webhook +* +* @apiParam {UUID} [id="Randomly Generated UUID"] Body parameter - The webhook's id +* @apiParam {String} url Body parameter - The webhook's URL +* @apiParam {String} [label] Body parameter - A label to remind you what this webhook does +* @apiParam {Boolean} [enabled=true] Body parameter - If the webhook should be enabled +* @apiParam {Sring="taskActivity","groupChatReceived"} [type="taskActivity"] Body parameter - The webhook's type. +* @apiParam {Object} [options] Body parameter - The webhook's options. Wil differ depending on type. Required for `groupChatReceived` type. If a webhook supports options, the default values are displayed in the examples below +* @apiParamExample {json} Task Activity Example +* { +* "enabled": true, // default +* "url": "http://some-webhook-url.com", +* "label": "My Webhook", +* "type": "taskActivity", // default +* "options": { +* "created": false, // default +* "updated": false, // default +* "deleted": false, // default +* "scored": true // default +* } +* } +* @apiParamExample {json} Group Chat Received Example +* { +* "enabled": true, +* "url": "http://some-webhook-url.com", +* "label": "My Chat Webhook", +* "type": "groupChatReceived", +* "options": { +* "groupId": "required-uuid-of-group" +* } +* } +* @apiParamExample {json} Minimal Example +* { +* "url": "http://some-webhook-url.com" +* } +* +* @apiSuccess {Object} data The created webhook +* @apiSuccess {UUID} data.id The uuid of the webhook +* @apiSuccess {String} data.url The url of the webhook +* @apiSuccess {String} data.label A label for you to keep track of what this webhooks is for +* @apiSuccess {Boolean} data.enabled Whether the webhook should be sent +* @apiSuccess {String} data.type The type of the webhook +* @apiSuccess {Object} data.options The options for the webhook (See examples) +* +* @apiError InvalidUUID The `id` was not a valid `UUID` +* @apiError InvalidEnable The `enable` param was not a `Boolean` value +* @apiError InvalidUrl The `url` param was not valid url +* @apiError InvalidWebhookType The `type` param was not a supported Webhook type +* @apiError GroupIdIsNotUUID The `options.groupId` param is not a valid UUID for `groupChatReceived` webhook type +* @apiError TaskActivityOptionNotBoolean The `options` provided for the `taskActivity` webhook were not of `Boolean` value +*/ +api.addWebhook = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/user/webhook', + async handler (req, res) { + let user = res.locals.user; + let webhook = new Webhook(req.body); + + webhook.formatOptions(res); + + user.webhooks.push(webhook); + + await user.save(); + + res.respond(201, webhook); + }, +}; + +/** +* @api {put} /api/v3/user/webhook/:id Edit a webhook - BETA +* @apiName UserUpdateWebhook +* @apiGroup Webhook +* @apiDescription Can change `url`, `enabled`, `type`, and `options` properties. Cannot change `id`. +* +* @apiParam {UUID} id URL parameter - The id of the webhook to update +* @apiParam {String} [url] Body parameter - The webhook's URL +* @apiParam {String} [label] Body parameter - A label to remind you what this webhook does +* @apiParam {Boolean} [enabled] Body parameter - If the webhook should be enabled +* @apiParam {Sring="taskActivity","groupChatReceived"} [type] Body parameter - The webhook's type. +* @apiParam {Object} [options] Body parameter - The webhook's options. Wil differ depending on type. The options are enumerated in the [add webhook examples](#api-Webhook-UserAddWebhook). +* @apiParamExample {json} Update Enabled and Type Properties +* { +* "enabled": false, +* "type": "taskActivity" +* } +* @apiParamExample {json} Update Group Id for Group Chat Receieved Webhook +* { +* "options": { +* "groupId": "new-uuid-of-group" +* } +* } +* +* @apiSuccess {Object} data The updated webhook +* @apiSuccess {UUID} data.id The uuid of the webhook +* @apiSuccess {String} data.url The url of the webhook +* @apiSuccess {String} data.label A label for you to keep track of what this webhooks is for +* @apiSuccess {Boolean} data.enabled Whether the webhook should be sent +* @apiSuccess {String} data.type The type of the webhook +* @apiSuccess {Object} data.options The options for the webhook (See webhook add examples) +* +* @apiError WebhookDoesNotExist A webhook with that `id` does not exist +* @apiError InvalidEnable The `enable` param was not a `Boolean` value +* @apiError InvalidUrl The `url` param was not valid url +* @apiError InvalidWebhookType The `type` param was not a supported Webhook type +* @apiError GroupIdIsNotUUID The `options.groupId` param is not a valid UUID for `groupChatReceived` webhook type +* @apiError TaskActivityOptionNotBoolean The `options` provided for the `taskActivity` webhook were not of `Boolean` value +* +*/ +api.updateWebhook = { + method: 'PUT', + middlewares: [authWithHeaders()], + url: '/user/webhook/:id', + async handler (req, res) { + let user = res.locals.user; + let id = req.params.id; + let webhook = user.webhooks.find(hook => hook.id === id); + let { url, label, type, enabled, options } = req.body; + + if (!webhook) { + throw new NotFound(res.t('noWebhookWithId', {id})); + } + + if (url) { + webhook.url = url; + } + + if (label) { + webhook.label = label; + } + + if (type) { + webhook.type = type; + } + + if (enabled !== undefined) { + webhook.enabled = enabled; + } + + if (options) { + webhook.options = Object.assign(webhook.options, options); + } + + webhook.formatOptions(res); + + await user.save(); + res.respond(200, webhook); + }, +}; + +/** +* @api {delete} /api/v3/user/webhook/:id Delete a webhook - BETA +* @apiName UserDeleteWebhook +* @apiGroup Webhook +* +* @apiParam {UUID} id The id of the webhook to delete +* +* @apiSuccess {Array} data The remaining webhooks for the user +* @apiError WebhookDoesNotExist A webhook with that `id` does not exist +*/ +api.deleteWebhook = { + method: 'DELETE', + middlewares: [authWithHeaders()], + url: '/user/webhook/:id', + async handler (req, res) { + let user = res.locals.user; + let id = req.params.id; + + let webhook = user.webhooks.find(hook => hook.id === id); + + if (!webhook) { + throw new NotFound(res.t('noWebhookWithId', {id})); + } + + removeFromArray(user.webhooks, webhook); + + await user.save(); + + res.respond(200, user.webhooks); + }, +}; + +module.exports = api; diff --git a/website/server/libs/webhook.js b/website/server/libs/webhook.js index 8757a706ce..bbe2ba8f45 100644 --- a/website/server/libs/webhook.js +++ b/website/server/libs/webhook.js @@ -1,9 +1,8 @@ -import { each } from 'lodash'; import { post } from 'request'; import { isURL } from 'validator'; import logger from './logger'; -let _sendWebhook = (url, body) => { +function sendWebhook (url, body) { post({ url, body, @@ -13,23 +12,100 @@ let _sendWebhook = (url, body) => { logger.error(err); } }); -}; +} -let _isInvalidWebhook = (hook) => { - return !hook.enabled || !isURL(hook.url); -}; +function isValidWebhook (hook) { + return hook.enabled && isURL(hook.url); +} -export function sendTaskWebhook (webhooks, data) { - each(webhooks, (hook) => { - if (_isInvalidWebhook(hook)) return; +export class WebhookSender { + constructor (options = {}) { + this.type = options.type; + this.transformData = options.transformData || WebhookSender.defaultTransformData; + this.webhookFilter = options.webhookFilter || WebhookSender.defaultWebhookFilter; + } - let body = { - direction: data.task.direction, - task: data.task.details, - delta: data.task.delta, - user: data.user, + static defaultTransformData (data) { + return data; + } + + static defaultWebhookFilter () { + return true; + } + + send (webhooks, data) { + let hooks = webhooks.filter((hook) => { + return isValidWebhook(hook) && + this.type === hook.type && + this.webhookFilter(hook, data); + }); + + if (hooks.length < 1) { + return; // prevents running the body creation code if there are no webhooks to send + } + + let body = this.transformData(data); + + hooks.forEach((hook) => { + sendWebhook(hook.url, body); + }); + } +} + +export let taskScoredWebhook = new WebhookSender({ + type: 'taskActivity', + webhookFilter (hook) { + let scored = hook.options && hook.options.scored; + + return scored; + }, + transformData (data) { + let { user, task, direction, delta } = data; + + let extendedStats = user.addComputedStatsToJSONObj(user.stats.toJSON()); + + let userData = { + _id: user._id, + _tmp: user._tmp, + stats: extendedStats, }; - _sendWebhook(hook.url, body); - }); -} + let dataToSend = { + type: 'scored', + direction, + delta, + task, + user: userData, + }; + + return dataToSend; + }, +}); + +export let taskActivityWebhook = new WebhookSender({ + type: 'taskActivity', + webhookFilter (hook, data) { + let { type } = data; + return hook.options[type]; + }, +}); + +export let groupChatReceivedWebhook = new WebhookSender({ + type: 'groupChatReceived', + webhookFilter (hook, data) { + return hook.options.groupId === data.group.id; + }, + transformData (data) { + let { group, chat } = data; + + let dataToSend = { + group: { + id: group.id, + name: group.name, + }, + chat, + }; + + return dataToSend; + }, +}); diff --git a/website/server/models/group.js b/website/server/models/group.js index 8af087f7d5..6d504801e2 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -9,6 +9,7 @@ import { model as Challenge} from './challenge'; import * as Tasks from './task'; import validator from 'validator'; import { removeFromArray } from '../libs/collectionManipulators'; +import { groupChatReceivedWebhook } from '../libs/webhook'; import { InternalServerError, BadRequest, @@ -522,6 +523,33 @@ schema.methods.startQuest = async function startQuest (user) { }); }; +schema.methods.sendGroupChatReceivedWebhooks = function sendGroupChatReceivedWebhooks (chat) { + let query = { + webhooks: { + $elemMatch: { + type: 'groupChatReceived', + 'options.groupId': this._id, + }, + }, + }; + + if (this.type === 'party') { + query['party._id'] = this._id; + } else { + query.guilds = this._id; + } + + User.find(query).select({webhooks: 1}).lean().then((users) => { + users.forEach((user) => { + let { webhooks } = user; + groupChatReceivedWebhook.send(webhooks, { + group: this, + chat, + }); + }); + }); +}; + schema.statics.cleanQuestProgress = _cleanQuestProgress; // returns a clean object for group.quest diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 0fe9777118..85778fdd43 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -43,12 +43,14 @@ schema.methods.addNotification = function addUserNotification (type, data = {}) }; // Add stats.toNextLevel, stats.maxMP and stats.maxHealth -// to a JSONified User object -schema.methods.addComputedStatsToJSONObj = function addComputedStatsToUserJSONObj (obj) { +// to a JSONified User stats object +schema.methods.addComputedStatsToJSONObj = function addComputedStatsToUserJSONObj (statsObject) { // NOTE: if an item is manually added to user.stats then // common/fns/predictableRandom must be tweaked so the new item is not considered. // Otherwise the client will have it while the server won't and the results will be different. - obj.stats.toNextLevel = common.tnl(this.stats.lvl); - obj.stats.maxHealth = common.maxHealth; - obj.stats.maxMP = common.statsComputed(this).maxMP; + statsObject.toNextLevel = common.tnl(this.stats.lvl); + statsObject.maxHealth = common.maxHealth; + statsObject.maxMP = common.statsComputed(this).maxMP; + + return statsObject; }; diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js index 00767a93c5..5c7d45901e 100644 --- a/website/server/models/user/schema.js +++ b/website/server/models/user/schema.js @@ -4,6 +4,7 @@ import _ from 'lodash'; import validator from 'validator'; import { schema as TagSchema } from '../tag'; import { schema as PushDeviceSchema } from '../pushDevice'; +import { schema as WebhookSchema } from '../webhook'; import { schema as UserNotificationSchema, } from '../userNotification'; @@ -539,6 +540,7 @@ let schema = new Schema({ }}, pushDevices: [PushDeviceSchema], _ABtest: {type: String}, + webhooks: [WebhookSchema], }, { strict: true, minimize: false, // So empty objects are returned diff --git a/website/server/models/webhook.js b/website/server/models/webhook.js new file mode 100644 index 0000000000..bced102619 --- /dev/null +++ b/website/server/models/webhook.js @@ -0,0 +1,80 @@ +import mongoose from 'mongoose'; +import validator from 'validator'; +import baseModel from '../libs/baseModel'; +import shared from '../../common'; +import {v4 as uuid} from 'uuid'; +import _ from 'lodash'; +import { BadRequest } from '../libs/errors'; + +const Schema = mongoose.Schema; + +const TASK_ACTIVITY_DEFAULT_OPTIONS = Object.freeze({ + created: false, + updated: false, + deleted: false, + scored: true, +}); + +export let schema = new Schema({ + id: { + type: String, + required: true, + validate: [validator.isUUID, shared.i18n.t('invalidWebhookId')], + default: uuid, + }, + type: { + type: String, + required: true, + enum: ['taskActivity', 'groupChatReceived'], + default: 'taskActivity', + }, + label: { + type: String, + required: false, + default: '', + }, + url: { + type: String, + required: true, + validate: [validator.isURL, shared.i18n.t('invalidUrl')], + }, + enabled: { type: Boolean, required: true, default: true }, + options: { + type: Schema.Types.Mixed, + required: true, + default () { + return {}; + }, + }, +}, { + strict: true, + minimize: false, // So empty objects are returned + _id: false, +}); + +schema.plugin(baseModel, { + noSet: ['_id'], + timestamps: true, + _id: false, +}); + +schema.methods.formatOptions = function formatOptions (res) { + if (this.type === 'taskActivity') { + this.options = _(this.options).defaults(TASK_ACTIVITY_DEFAULT_OPTIONS).pick('created', 'updated', 'deleted', 'scored').value(); + + let invalidOption = Object.keys(this.options) + .find(option => typeof this.options[option] !== 'boolean'); + + if (invalidOption) { + throw new BadRequest(res.t('webhookBooleanOption', { option: invalidOption })); + } + } else if (this.type === 'groupChatReceived') { + this.options = _(this.options).pick('groupId').value(); + + if (!validator.isUUID(this.options.groupId)) { + throw new BadRequest(res.t('groupIdRequired')); + } + } +}; + +export let model = mongoose.model('Webhook', schema); diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index a04918f8f4..f7ba4b2394 100644 --- a/website/views/options/settings.jade +++ b/website/views/options/settings.jade @@ -258,20 +258,20 @@ script(type='text/ng-template', id='partials/options.settings.api.html') h2=env.t('webhooks') table.table.table-striped - thead(ng-if='hasWebhooks') + thead(ng-if='user.webhooks.length') tr th=env.t('enabled') th=env.t('webhookURL') th tbody - tr(ng-repeat="webhook in user.preferences.webhooks | toArray:true | orderBy:'sort'") + tr(ng-repeat="webhook in user.webhooks track by $index") td - input(type='checkbox', ng-model='webhook.enabled', ng-change='saveWebhook(webhook.$key,webhook)') + input(type='checkbox', ng-model='webhook.enabled', ng-change='saveWebhook(webhook, $index)') td - input.form-control(type='url', ng-model='webhook.url', ng-change='webhook._editing=true', ui-keyup="{13:'saveWebhook(webhook.$key,webhook)'}") + input.form-control(type='url', ng-model='webhook.url', ng-change='webhook._editing=true', ui-keyup="{13:'saveWebhook(webhook, $index)'}") td span.pull-left(ng-show='webhook._editing') * - a.checklist-icons(ng-click='deleteWebhook(webhook.$key)') + a.checklist-icons(ng-click='deleteWebhook(webhook, $index)') span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) tr td(colspan=2)