mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-10-28 19:59:24 +01:00
* Update webhook.js Add `questOwner: group.quest.leader,` to webhook.js * Update POST-groups_groupId_quests_invite.test.js Test if questOwner contains the correct data * Update POST-groups_groupId_quests_invite.test.js * Update webhooks.test.js
758 lines
19 KiB
JavaScript
758 lines
19 KiB
JavaScript
import got from 'got';
|
|
import moment from 'moment';
|
|
import {
|
|
WebhookSender,
|
|
taskScoredWebhook,
|
|
groupChatReceivedWebhook,
|
|
taskActivityWebhook,
|
|
questActivityWebhook,
|
|
userActivityWebhook,
|
|
} from '../../../../website/server/libs/webhook';
|
|
import {
|
|
model as User,
|
|
} from '../../../../website/server/models/user';
|
|
import {
|
|
generateUser,
|
|
defer,
|
|
sleep,
|
|
} from '../../../helpers/api-unit.helper';
|
|
import logger from '../../../../website/server/libs/logger';
|
|
|
|
describe('webhooks', () => {
|
|
let webhooks; let
|
|
user;
|
|
|
|
beforeEach(() => {
|
|
sandbox.stub(got, 'post').returns(defer().promise);
|
|
|
|
webhooks = [{
|
|
id: 'taskActivity',
|
|
url: 'http://task-scored.com',
|
|
enabled: true,
|
|
type: 'taskActivity',
|
|
options: {
|
|
created: true,
|
|
updated: true,
|
|
deleted: true,
|
|
scored: true,
|
|
checklistScored: true,
|
|
},
|
|
}, {
|
|
id: 'questActivity',
|
|
url: 'http://quest-activity.com',
|
|
enabled: true,
|
|
type: 'questActivity',
|
|
options: {
|
|
questStarted: true,
|
|
questFinised: true,
|
|
questInvited: true,
|
|
},
|
|
}, {
|
|
id: 'userActivity',
|
|
url: 'http://user-activity.com',
|
|
enabled: true,
|
|
type: 'userActivity',
|
|
options: {
|
|
petHatched: true,
|
|
mountRaised: true,
|
|
leveledUp: true,
|
|
},
|
|
}, {
|
|
id: 'groupChatReceived',
|
|
url: 'http://group-chat-received.com',
|
|
enabled: true,
|
|
type: 'groupChatReceived',
|
|
options: {
|
|
groupId: 'group-id',
|
|
},
|
|
}];
|
|
|
|
user = generateUser();
|
|
user.webhooks = webhooks;
|
|
});
|
|
|
|
afterEach(() => {
|
|
sandbox.restore();
|
|
});
|
|
|
|
describe('WebhookSender', () => {
|
|
it('creates a new WebhookSender object', () => {
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
});
|
|
|
|
expect(sendWebhook.type).to.equal('custom');
|
|
expect(sendWebhook).to.respondTo('send');
|
|
});
|
|
|
|
it('provides default function for data transformation', () => {
|
|
sandbox.spy(WebhookSender, 'defaultTransformData');
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [{
|
|
id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
|
|
}];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(WebhookSender.defaultTransformData).to.be.calledOnce;
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
|
json: body,
|
|
});
|
|
});
|
|
|
|
it('adds default data (user and webhookType) to the body', () => {
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
});
|
|
sandbox.spy(sendWebhook, 'attachDefaultData');
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [{
|
|
id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
|
|
}];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(sendWebhook.attachDefaultData).to.be.calledOnce;
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
|
json: body,
|
|
});
|
|
|
|
expect(body).to.eql({
|
|
foo: 'bar',
|
|
user: { _id: user._id },
|
|
webhookType: 'custom',
|
|
});
|
|
});
|
|
|
|
it('can pass in a data transformation function', () => {
|
|
sandbox.spy(WebhookSender, 'defaultTransformData');
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
transformData (data) {
|
|
const dataToSend = { baz: 'biz', ...data };
|
|
|
|
return dataToSend;
|
|
},
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [{
|
|
id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
|
|
}];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(WebhookSender.defaultTransformData).to.not.be.called;
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
|
json: {
|
|
foo: 'bar',
|
|
baz: 'biz',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('provides a default filter function', () => {
|
|
sandbox.spy(WebhookSender, 'defaultWebhookFilter');
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [{
|
|
id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
|
|
}];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(WebhookSender.defaultWebhookFilter).to.be.calledOnce;
|
|
});
|
|
|
|
it('can pass in a webhook filter function', () => {
|
|
sandbox.spy(WebhookSender, 'defaultWebhookFilter');
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
webhookFilter (hook) {
|
|
return hook.url !== 'http://custom-url.com';
|
|
},
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [{
|
|
id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
|
|
}];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(WebhookSender.defaultWebhookFilter).to.not.be.called;
|
|
expect(got.post).to.not.be.called;
|
|
});
|
|
|
|
it('can pass in a webhook filter function that filters on data', () => {
|
|
sandbox.spy(WebhookSender, 'defaultWebhookFilter');
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
webhookFilter (hook, data) {
|
|
return hook.options.foo === data.foo;
|
|
},
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [
|
|
{
|
|
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' },
|
|
},
|
|
];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com');
|
|
});
|
|
|
|
it('ignores disabled webhooks', () => {
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [{
|
|
id: 'custom-webhook', url: 'http://custom-url.com', enabled: false, type: 'custom',
|
|
}];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(got.post).to.not.be.called;
|
|
});
|
|
|
|
it('ignores webhooks with invalid urls', () => {
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [{
|
|
id: 'custom-webhook', url: 'httxp://custom-url!!!', enabled: true, type: 'custom',
|
|
}];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(got.post).to.not.be.called;
|
|
});
|
|
|
|
it('ignores webhooks of other types', () => {
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [
|
|
{
|
|
id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
|
|
},
|
|
{
|
|
id: 'other-webhook', url: 'http://other-url.com', enabled: true, type: 'other',
|
|
},
|
|
];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
|
json: body,
|
|
});
|
|
});
|
|
|
|
it('sends every type of activity to global webhooks', () => {
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [
|
|
{
|
|
id: 'global-webhook', url: 'http://custom-url.com', enabled: true, type: 'globalActivity',
|
|
},
|
|
];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
|
json: body,
|
|
});
|
|
});
|
|
|
|
it('sends multiple webhooks of the same type', () => {
|
|
const sendWebhook = new WebhookSender({
|
|
type: 'custom',
|
|
});
|
|
|
|
const body = { foo: 'bar' };
|
|
|
|
user.webhooks = [
|
|
{
|
|
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',
|
|
},
|
|
];
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(got.post).to.be.calledTwice;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
|
json: body,
|
|
});
|
|
expect(got.post).to.be.calledWithMatch('http://other-url.com', {
|
|
json: body,
|
|
});
|
|
});
|
|
|
|
describe('failures', () => {
|
|
let sendWebhook;
|
|
|
|
beforeEach(async () => {
|
|
sandbox.restore();
|
|
sandbox.stub(got, 'post').returns(Promise.reject());
|
|
|
|
sendWebhook = new WebhookSender({ type: 'taskActivity' });
|
|
user.webhooks = [{
|
|
url: 'http://custom-url.com', enabled: true, type: 'taskActivity',
|
|
}];
|
|
await user.save();
|
|
|
|
expect(user.webhooks[0].failures).to.equal(0);
|
|
expect(user.webhooks[0].lastFailureAt).to.equal(undefined);
|
|
});
|
|
|
|
it('does not increase failures counter if request is successfull', async () => {
|
|
sandbox.restore();
|
|
sandbox.stub(got, 'post').returns(Promise.resolve());
|
|
|
|
const body = {};
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
|
json: body,
|
|
});
|
|
|
|
await sleep(0.1);
|
|
user = await User.findById(user._id).exec();
|
|
|
|
expect(user.webhooks[0].failures).to.equal(0);
|
|
expect(user.webhooks[0].lastFailureAt).to.equal(undefined);
|
|
});
|
|
|
|
it('records failures', async () => {
|
|
sinon.stub(logger, 'error');
|
|
const body = {};
|
|
sendWebhook.send(user, body);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
|
json: body,
|
|
});
|
|
|
|
await sleep(0.1);
|
|
user = await User.findById(user._id).exec();
|
|
|
|
expect(user.webhooks[0].failures).to.equal(1);
|
|
expect((Date.now() - user.webhooks[0].lastFailureAt.getTime()) < 10000).to.be.true;
|
|
|
|
expect(logger.error).to.be.calledOnce;
|
|
logger.error.restore();
|
|
});
|
|
|
|
it('disables a webhook after 10 failures', async () => {
|
|
const times = 10;
|
|
for (let i = 0; i < times; i += 1) {
|
|
sendWebhook.send(user, {});
|
|
await sleep(0.1); // eslint-disable-line no-await-in-loop
|
|
user = await User.findById(user._id).exec(); // eslint-disable-line no-await-in-loop
|
|
}
|
|
|
|
expect(got.post).to.be.callCount(10);
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com');
|
|
|
|
await sleep(0.1);
|
|
user = await User.findById(user._id).exec();
|
|
|
|
expect(user.webhooks[0].enabled).to.equal(false);
|
|
expect(user.webhooks[0].failures).to.equal(0);
|
|
});
|
|
|
|
it('resets failures after a month ', async () => {
|
|
const oneMonthAgo = moment().subtract(1, 'months').subtract(1, 'days').toDate();
|
|
user.webhooks[0].lastFailureAt = oneMonthAgo;
|
|
user.webhooks[0].failures = 9;
|
|
|
|
await user.save();
|
|
|
|
sendWebhook.send(user, []);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://custom-url.com');
|
|
|
|
await sleep(0.1);
|
|
user = await User.findById(user._id).exec();
|
|
|
|
expect(user.webhooks[0].failures).to.equal(1);
|
|
// Check that the stored date is whitin 10s from now
|
|
expect((Date.now() - user.webhooks[0].lastFailureAt.getTime()) < 10000).to.be.true;
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('taskScoredWebhook', () => {
|
|
let data;
|
|
|
|
beforeEach(() => {
|
|
data = {
|
|
user: {
|
|
_tmp: { foo: 'bar' },
|
|
stats: {
|
|
lvl: 5,
|
|
int: 10,
|
|
str: 5,
|
|
exp: 423,
|
|
toJSON () {
|
|
return this;
|
|
},
|
|
},
|
|
},
|
|
task: {
|
|
text: 'text',
|
|
},
|
|
direction: 'up',
|
|
delta: 176,
|
|
};
|
|
|
|
const mockStats = {
|
|
maxHealth: 50,
|
|
maxMP: 103,
|
|
toNextLevel: 40,
|
|
...data.user.stats,
|
|
};
|
|
delete mockStats.toJSON;
|
|
|
|
sandbox.stub(User, 'addComputedStatsToJSONObj').returns(mockStats);
|
|
});
|
|
|
|
it('sends task and stats data', () => {
|
|
taskScoredWebhook.send(user, data);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
|
|
json: {
|
|
type: 'scored',
|
|
webhookType: 'taskActivity',
|
|
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('sends task and stats data to globalActivity webhookd', () => {
|
|
user.webhooks = [{
|
|
id: 'globalActivity',
|
|
url: 'http://global-activity.com',
|
|
enabled: true,
|
|
type: 'globalActivity',
|
|
}];
|
|
|
|
taskScoredWebhook.send(user, data);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch('http://global-activity.com', {
|
|
json: {
|
|
type: 'scored',
|
|
webhookType: 'taskActivity',
|
|
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(user, data);
|
|
|
|
expect(got.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(user, data);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
|
|
json: {
|
|
type,
|
|
webhookType: 'taskActivity',
|
|
user: {
|
|
_id: user._id,
|
|
},
|
|
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(user, data);
|
|
|
|
expect(got.post).to.not.be.called;
|
|
});
|
|
});
|
|
|
|
describe('checklistScored', () => {
|
|
beforeEach(() => {
|
|
data = {
|
|
task: {
|
|
text: 'text',
|
|
},
|
|
item: {
|
|
text: 'item-text',
|
|
},
|
|
};
|
|
});
|
|
|
|
it('sends \'checklistScored\' tasks', () => {
|
|
data.type = 'checklistScored';
|
|
|
|
taskActivityWebhook.send(user, data);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
|
|
json: {
|
|
webhookType: 'taskActivity',
|
|
user: {
|
|
_id: user._id,
|
|
},
|
|
type: data.type,
|
|
task: data.task,
|
|
item: data.item,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('does not send task \'checklistScored\' data if \'checklistScored\' option is not true', () => {
|
|
data.type = 'checklistScored';
|
|
webhooks[0].options.checklistScored = false;
|
|
|
|
taskActivityWebhook.send(user, data);
|
|
|
|
expect(got.post).to.not.be.called;
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('userActivityWebhook', () => {
|
|
let data;
|
|
|
|
beforeEach(() => {
|
|
data = {
|
|
something: true,
|
|
};
|
|
});
|
|
|
|
['petHatched', 'mountRaised', 'leveledUp'].forEach(type => {
|
|
it(`sends ${type} webhooks`, () => {
|
|
data.type = type;
|
|
|
|
userActivityWebhook.send(user, data);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch(webhooks[2].url, {
|
|
json: {
|
|
type,
|
|
webhookType: 'userActivity',
|
|
user: {
|
|
_id: user._id,
|
|
},
|
|
something: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it(`does not send webhook ${type} data if ${type} option is not true`, () => {
|
|
data.type = type;
|
|
webhooks[2].options[type] = false;
|
|
|
|
userActivityWebhook.send(user, data);
|
|
|
|
expect(got.post).to.not.be.called;
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('questActivityWebhook', () => {
|
|
let data;
|
|
|
|
beforeEach(() => {
|
|
data = {
|
|
group: {
|
|
id: 'group-id',
|
|
name: 'some group',
|
|
otherData: 'foo',
|
|
quest: {},
|
|
},
|
|
quest: {
|
|
key: 'some-key',
|
|
questOwner: 'user-id',
|
|
},
|
|
};
|
|
});
|
|
|
|
['questStarted', 'questFinised', 'questInvited'].forEach(type => {
|
|
it(`sends ${type} webhooks`, () => {
|
|
data.type = type;
|
|
|
|
questActivityWebhook.send(user, data);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch(webhooks[1].url, {
|
|
json: {
|
|
type,
|
|
webhookType: 'questActivity',
|
|
user: {
|
|
_id: user._id,
|
|
},
|
|
group: {
|
|
id: 'group-id',
|
|
name: 'some group',
|
|
},
|
|
quest: {
|
|
key: 'some-key',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it(`does not send webhook ${type} data if ${type} option is not true`, () => {
|
|
data.type = type;
|
|
webhooks[1].options[type] = false;
|
|
|
|
userActivityWebhook.send(user, data);
|
|
|
|
expect(got.post).to.not.be.called;
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('groupChatReceivedWebhook', () => {
|
|
it('sends chat data', () => {
|
|
const data = {
|
|
group: {
|
|
id: 'group-id',
|
|
name: 'some group',
|
|
otherData: 'foo',
|
|
},
|
|
chat: {
|
|
id: 'some-id',
|
|
text: 'message',
|
|
},
|
|
};
|
|
|
|
groupChatReceivedWebhook.send(user, data);
|
|
|
|
expect(got.post).to.be.calledOnce;
|
|
expect(got.post).to.be.calledWithMatch(webhooks[webhooks.length - 1].url, {
|
|
json: {
|
|
webhookType: 'groupChatReceived',
|
|
user: {
|
|
_id: user._id,
|
|
},
|
|
group: {
|
|
id: 'group-id',
|
|
name: 'some group',
|
|
},
|
|
chat: {
|
|
id: 'some-id',
|
|
text: 'message',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('does not send chat data for group if not selected', () => {
|
|
const data = {
|
|
group: {
|
|
id: 'not-group-id',
|
|
name: 'some group',
|
|
otherData: 'foo',
|
|
},
|
|
chat: {
|
|
id: 'some-id',
|
|
text: 'message',
|
|
},
|
|
};
|
|
|
|
groupChatReceivedWebhook.send(user, data);
|
|
|
|
expect(got.post).to.not.be.called;
|
|
});
|
|
});
|
|
});
|