From a520cdad0b3aa36a5a2e0a2dcf69a3e2d9e0bcdc Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Fri, 17 Jul 2020 17:37:14 +1200 Subject: [PATCH] Added JSON Schema validations to Webhooks Admin API v3 closes #12033 - Added webhooks schemas and definitions. - Added validation checking if integration_id is present when using session auth. This is needed to prevent orphan webhooks. - Integrated webhook schemas into frame's validation layer. - Added isLowerCase ajv keyword support. This is needed to be able to do isLowerCase validation using JSON Schema for webhooks. --- .../canary/utils/validators/input/index.js | 4 + .../input/schemas/webhooks-add.json | 21 ++ .../input/schemas/webhooks-edit.json | 17 ++ .../validators/input/schemas/webhooks.json | 68 +++++ .../canary/utils/validators/input/webhooks.js | 28 ++ .../validators/utils/is-lowercase-keyword.js | 15 ++ .../utils/validators/utils/json-schema.js | 2 + core/server/api/canary/webhooks.js | 10 - core/server/translations/en.json | 3 + test/api-acceptance/admin/webhooks_spec.js | 8 +- .../api/canary/admin/webhooks_spec.js | 51 ++++ .../utils/validators/input/webhooks_spec.js | 255 ++++++++++++++++++ .../utils/validators/input/webhooks_spec.js | 255 ++++++++++++++++++ 13 files changed, 724 insertions(+), 13 deletions(-) create mode 100644 core/server/api/canary/utils/validators/input/schemas/webhooks-add.json create mode 100644 core/server/api/canary/utils/validators/input/schemas/webhooks-edit.json create mode 100644 core/server/api/canary/utils/validators/input/schemas/webhooks.json create mode 100644 core/server/api/canary/utils/validators/input/webhooks.js create mode 100644 core/server/api/canary/utils/validators/utils/is-lowercase-keyword.js create mode 100644 test/unit/api/canary/utils/validators/input/webhooks_spec.js create mode 100644 test/unit/api/v3/utils/validators/input/webhooks_spec.js diff --git a/core/server/api/canary/utils/validators/input/index.js b/core/server/api/canary/utils/validators/input/index.js index 8c9a42123d..7a4cd511f2 100644 --- a/core/server/api/canary/utils/validators/input/index.js +++ b/core/server/api/canary/utils/validators/input/index.js @@ -49,5 +49,9 @@ module.exports = { get oembed() { return require('./oembed'); + }, + + get webhooks() { + return require('./webhooks'); } }; diff --git a/core/server/api/canary/utils/validators/input/schemas/webhooks-add.json b/core/server/api/canary/utils/validators/input/schemas/webhooks-add.json new file mode 100644 index 0000000000..e1f3c82bc9 --- /dev/null +++ b/core/server/api/canary/utils/validators/input/schemas/webhooks-add.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "webhooks.add", + "title": "webhooks.add", + "description": "Schema for webhooks.add", + "type": "object", + "additionalProperties": false, + "properties": { + "webhooks": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "allOf": [{"$ref": "webhooks#/definitions/webhook"}], + "required": ["event", "target_url"] + } + } + }, + "required": ["webhooks"] +} diff --git a/core/server/api/canary/utils/validators/input/schemas/webhooks-edit.json b/core/server/api/canary/utils/validators/input/schemas/webhooks-edit.json new file mode 100644 index 0000000000..83bfc58aa3 --- /dev/null +++ b/core/server/api/canary/utils/validators/input/schemas/webhooks-edit.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "webhooks.edit", + "title": "webhooks.edit", + "description": "Schema for webhooks.edit", + "type": "object", + "additionalProperties": false, + "properties": { + "webhooks": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": {"$ref": "webhooks#/definitions/webhook"} + } + }, + "required": ["webhooks"] +} diff --git a/core/server/api/canary/utils/validators/input/schemas/webhooks.json b/core/server/api/canary/utils/validators/input/schemas/webhooks.json new file mode 100644 index 0000000000..a30af2001e --- /dev/null +++ b/core/server/api/canary/utils/validators/input/schemas/webhooks.json @@ -0,0 +1,68 @@ + +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "webhooks", + "title": "webhooks", + "description": "Base webhooks definitions", + "definitions": { + "webhook": { + "type": "object", + "additionalProperties": false, + "properties": { + "event": { + "type": "string", + "maxLength": 50, + "isLowercase": true + }, + "target_url": { + "type": "string", + "format": "uri-reference", + "maxLength": 2000 + }, + "name": { + "type": ["string", "null"], + "maxLength": 191 + }, + "secret": { + "type": ["string", "null"], + "maxLength": 191 + }, + "api_version": { + "type": ["string", "null"], + "maxLength": 50 + }, + "integration_id": { + "type": ["string", "null"], + "maxLength": 24 + }, + "id": { + "strip": true + }, + "status": { + "strip": true + }, + "last_triggered_at": { + "strip": true + }, + "last_triggered_status": { + "strip": true + }, + "last_triggered_error": { + "strip": true + }, + "created_at": { + "strip": true + }, + "created_by": { + "strip": true + }, + "updated_at": { + "strip": true + }, + "updated_by": { + "strip": true + } + } + } + } +} diff --git a/core/server/api/canary/utils/validators/input/webhooks.js b/core/server/api/canary/utils/validators/input/webhooks.js new file mode 100644 index 0000000000..d5f7c4b74b --- /dev/null +++ b/core/server/api/canary/utils/validators/input/webhooks.js @@ -0,0 +1,28 @@ +const _ = require('lodash'); +const errors = require('@tryghost/errors'); +const {i18n} = require('../../../../../lib/common'); +const jsonSchema = require('../utils/json-schema'); + +module.exports = { + add(apiConfig, frame) { + if (!_.get(frame, 'options.context.api_key.id') && !_.get(frame.data, 'webhooks[0].integration_id')) { + return Promise.reject(new errors.ValidationError({ + message: i18n.t('notices.data.validation.index.schemaValidationFailed', { + key: 'integration_id' + }), + context: i18n.t('errors.api.webhooks.noIntegrationIdProvided.context'), + property: 'integration_id' + })); + } + + const schema = require('./schemas/webhooks-add'); + const definitions = require('./schemas/webhooks'); + return jsonSchema.validate(schema, definitions, frame.data); + }, + + edit(apiConfig, frame) { + const schema = require('./schemas/webhooks-edit'); + const definitions = require('./schemas/webhooks'); + return jsonSchema.validate(schema, definitions, frame.data); + } +}; diff --git a/core/server/api/canary/utils/validators/utils/is-lowercase-keyword.js b/core/server/api/canary/utils/validators/utils/is-lowercase-keyword.js new file mode 100644 index 0000000000..032a1310e4 --- /dev/null +++ b/core/server/api/canary/utils/validators/utils/is-lowercase-keyword.js @@ -0,0 +1,15 @@ +module.exports = function defFunc(ajv) { + defFunc.definition = { + errors: false, + validate: function (schema, data) { + if (data) { + return data === data.toLowerCase(); + } + + return true; + } + }; + + ajv.addKeyword('isLowercase', defFunc.definition); + return ajv; +}; diff --git a/core/server/api/canary/utils/validators/utils/json-schema.js b/core/server/api/canary/utils/validators/utils/json-schema.js index ad0065c785..90729a3562 100644 --- a/core/server/api/canary/utils/validators/utils/json-schema.js +++ b/core/server/api/canary/utils/validators/utils/json-schema.js @@ -1,6 +1,7 @@ const _ = require('lodash'); const Ajv = require('ajv'); const stripKeyword = require('./strip-keyword'); +const isLowercaseKeyword = require('./is-lowercase-keyword'); const {i18n} = require('../../../../../lib/common'); const errors = require('@tryghost/errors'); @@ -20,6 +21,7 @@ const ajv = new Ajv({ }); stripKeyword(ajv); +isLowercaseKeyword(ajv); const getValidation = (schema, def) => { if (!ajv.getSchema(def.$id)) { diff --git a/core/server/api/canary/webhooks.js b/core/server/api/canary/webhooks.js index f8c8bd80be..b0b49fef95 100644 --- a/core/server/api/canary/webhooks.js +++ b/core/server/api/canary/webhooks.js @@ -10,16 +10,6 @@ module.exports = { headers: {}, options: [], data: [], - validation: { - data: { - event: { - required: true - }, - target_url: { - required: true - } - } - }, permissions: true, query(frame) { return models.Webhook.getByEventAndTarget( diff --git a/core/server/translations/en.json b/core/server/translations/en.json index a13726d94b..a5bd606d23 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -452,6 +452,9 @@ "message": "You do not have permission to {method} this webhook.", "context": "You may only {method} webhooks that belong to the authenticated integration. Check the supplied Admin API Key." }, + "noIntegrationIdProvided": { + "context": "You may only create webhooks with 'integration_id' when using session authentication." + }, "webhookAlreadyExists": "Target URL has already been used for this event." }, "oembed": { diff --git a/test/api-acceptance/admin/webhooks_spec.js b/test/api-acceptance/admin/webhooks_spec.js index b91c602b08..b7ccd87f30 100644 --- a/test/api-acceptance/admin/webhooks_spec.js +++ b/test/api-acceptance/admin/webhooks_spec.js @@ -27,7 +27,8 @@ describe('Webhooks API', function () { target_url: 'http://example.com/webhooks/test/extra/1', name: 'test', secret: 'thisissecret', - api_version: 'v2' + api_version: 'v2', + integration_id: '12345' }; return request.post(localUtils.API.getApiQuery('webhooks/')) @@ -48,6 +49,7 @@ describe('Webhooks API', function () { jsonResponse.webhooks[0].secret.should.equal(webhookData.secret); jsonResponse.webhooks[0].name.should.equal(webhookData.name); jsonResponse.webhooks[0].api_version.should.equal(webhookData.api_version); + jsonResponse.webhooks[0].integration_id.should.equal(webhookData.integration_id); }); }); @@ -109,7 +111,8 @@ describe('Webhooks API', function () { const newWebhook = { event: 'test.create', // a different target_url from above is needed to avoid an "already exists" error - target_url: 'http://example.com/webhooks/test/2' + target_url: 'http://example.com/webhooks/test/2', + integration_id: '123423' }; // create the webhook that is to be deleted @@ -120,7 +123,6 @@ describe('Webhooks API', function () { .expect('Cache-Control', testUtils.cacheRules.private) .expect(201) .then((res) => { - const location = res.headers.location; const jsonResponse = res.body; should.exist(jsonResponse.webhooks); diff --git a/test/regression/api/canary/admin/webhooks_spec.js b/test/regression/api/canary/admin/webhooks_spec.js index c6c180ed25..cdca0ce8dd 100644 --- a/test/regression/api/canary/admin/webhooks_spec.js +++ b/test/regression/api/canary/admin/webhooks_spec.js @@ -47,11 +47,62 @@ describe('Webhooks API (canary)', function () { jsonResponse.webhooks[0].event.should.eql('test.create'); jsonResponse.webhooks[0].target_url.should.eql('http://example.com/webhooks/test/extra/canary'); jsonResponse.webhooks[0].integration_id.should.eql(testUtils.DataGenerator.Content.api_keys[0].id); + jsonResponse.webhooks[0].name.should.eql('test'); + jsonResponse.webhooks[0].secret.should.eql('thisissecret'); + jsonResponse.webhooks[0].api_version.should.eql('v3'); localUtils.API.checkResponse(jsonResponse.webhooks[0], 'webhook'); }); }); + it('Fails validation for when integration_id is missing', function () { + let webhookData = { + event: 'test.create', + target_url: 'http://example.com/webhooks/test/extra/1', + name: 'test', + secret: 'thisissecret', + api_version: 'v2' + }; + + return request.post(localUtils.API.getApiQuery('webhooks/')) + .set('Origin', config.get('url')) + .send({webhooks: [webhookData]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Fails validation for non-lowercase event name', function () { + let webhookData = { + event: 'tEst.evenT', + target_url: 'http://example.com/webhooks/test/extra/1', + name: 'test', + secret: 'thisissecret', + api_version: 'v2' + }; + + return request.post(localUtils.API.getApiQuery('webhooks/')) + .set('Origin', config.get('url')) + .send({webhooks: [webhookData]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Fails validation when required fields are not present', function () { + let webhookData = { + api_version: 'v2', + integration_id: 'dummy' + }; + + return request.post(localUtils.API.getApiQuery('webhooks/')) + .set('Origin', config.get('url')) + .send({webhooks: [webhookData]}) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + it('Integration cannot edit or delete other integration\'s webhook', function () { let createdIntegration; let createdWebhook; diff --git a/test/unit/api/canary/utils/validators/input/webhooks_spec.js b/test/unit/api/canary/utils/validators/input/webhooks_spec.js new file mode 100644 index 0000000000..6de6c412e2 --- /dev/null +++ b/test/unit/api/canary/utils/validators/input/webhooks_spec.js @@ -0,0 +1,255 @@ +const errors = require('@tryghost/errors'); +const _ = require('lodash'); +const should = require('should'); +const sinon = require('sinon'); +const Promise = require('bluebird'); +const validators = require('../../../../../../../core/server/api/canary/utils/validators'); + +describe('Unit: canary/utils/validators/input/webhooks', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('add', function () { + const apiConfig = { + docName: 'webhooks' + }; + + describe('required fields', function () { + it('should fail with no data', function () { + const frame = { + options: {}, + data: {} + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with no webhooks', function () { + const frame = { + options: {}, + data: { + posts: [] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with no webhooks in array', function () { + const frame = { + options: {}, + data: { + webhooks: [] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with more than webhooks', function () { + const frame = { + options: {}, + data: { + webhooks: [], + posts: [] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail without required fields', function () { + const frame = { + options: {}, + data: { + webhooks: [{ + what: 'a fail' + }] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should pass with required fields', function () { + const frame = { + options: {}, + data: { + webhooks: [{ + integration_id: '123', + event: 'post.edited', + target_url: 'https://example.com' + }] + } + }; + + return validators.input.webhooks.add(apiConfig, frame); + }); + + it('should remove `strip`able fields and leave regular fields', function () { + const frame = { + options: {}, + data: { + webhooks: [{ + name: 'pass', + target_url: 'https://example.com/target/1', + event: 'post.published', + integration_id: '1234', + id: 'strip me', + status: 'strip me', + last_triggered_at: 'strip me', + last_triggered_status: 'strip me', + last_triggered_error: 'strip me', + created_at: 'strip me', + created_by: 'strip me', + updated_at: 'strip me', + updated_by: 'strip me' + }] + } + }; + + let result = validators.input.webhooks.add(apiConfig, frame); + + frame.data.webhooks[0].name.should.equal('pass'); + frame.data.webhooks[0].target_url.should.equal('https://example.com/target/1'); + frame.data.webhooks[0].event.should.equal('post.published'); + frame.data.webhooks[0].integration_id.should.equal('1234'); + should.not.exist(frame.data.webhooks[0].status); + should.not.exist(frame.data.webhooks[0].last_triggered_at); + should.not.exist(frame.data.webhooks[0].last_triggered_status); + should.not.exist(frame.data.webhooks[0].last_triggered_error); + should.not.exist(frame.data.webhooks[0].created_at); + should.not.exist(frame.data.webhooks[0].created_by); + should.not.exist(frame.data.webhooks[0].updated_at); + should.not.exist(frame.data.webhooks[0].updated_by); + }); + }); + + describe('field formats', function () { + const fieldMap = { + name: [123, new Date(), '', _.repeat('a', 192), null], + secret: [123, new Date(), _.repeat('a', 192)], + api_version: [123, new Date(), _.repeat('a', 51)], + integration_id: [123, new Date(), 'not uri'] + }; + + Object.keys(fieldMap).forEach((key) => { + it(`should fail for bad ${key}`, function () { + const badValues = fieldMap[key]; + + const checks = badValues.map((value) => { + const webhook = {}; + webhook[key] = value; + + if (key !== 'name') { + webhook.name = 'abc'; + } + + const frame = { + options: {}, + data: { + webhooks: [webhook] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + return Promise.all(checks); + }); + }); + }); + }); + + describe('edit', function () { + const apiConfig = { + docName: 'webhooks' + }; + + describe('required fields', function () { + it('should fail with no data', function () { + const frame = { + options: {}, + data: {} + }; + + return validators.input.webhooks.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with no webhooks', function () { + const frame = { + options: {}, + data: { + posts: [] + } + }; + + return validators.input.webhooks.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with more than webhooks', function () { + const frame = { + options: {}, + data: { + webhooks: [], + posts: [] + } + }; + + return validators.input.webhooks.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should pass with required fields', function () { + const frame = { + options: {}, + data: { + webhooks: [{ + target_url: 'https://example.com/target/1', + event: 'post.published', + integration_id: '1234' + }] + } + }; + + return validators.input.webhooks.edit(apiConfig, frame); + }); + }); + }); +}); diff --git a/test/unit/api/v3/utils/validators/input/webhooks_spec.js b/test/unit/api/v3/utils/validators/input/webhooks_spec.js new file mode 100644 index 0000000000..8d2701cb80 --- /dev/null +++ b/test/unit/api/v3/utils/validators/input/webhooks_spec.js @@ -0,0 +1,255 @@ +const errors = require('@tryghost/errors'); +const _ = require('lodash'); +const should = require('should'); +const sinon = require('sinon'); +const Promise = require('bluebird'); +const validators = require('../../../../../../../core/server/api/canary/utils/validators'); + +describe('Unit: v3/utils/validators/input/webhooks', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('add', function () { + const apiConfig = { + docName: 'webhooks' + }; + + describe('required fields', function () { + it('should fail with no data', function () { + const frame = { + options: {}, + data: {} + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with no webhooks', function () { + const frame = { + options: {}, + data: { + posts: [] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with no webhooks in array', function () { + const frame = { + options: {}, + data: { + webhooks: [] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with more than webhooks', function () { + const frame = { + options: {}, + data: { + webhooks: [], + posts: [] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail without required fields', function () { + const frame = { + options: {}, + data: { + webhooks: [{ + what: 'a fail' + }] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should pass with required fields', function () { + const frame = { + options: {}, + data: { + webhooks: [{ + integration_id: '123', + event: 'post.edited', + target_url: 'https://example.com' + }] + } + }; + + return validators.input.webhooks.add(apiConfig, frame); + }); + + it('should remove `strip`able fields and leave regular fields', function () { + const frame = { + options: {}, + data: { + webhooks: [{ + name: 'pass', + target_url: 'https://example.com/target/1', + event: 'post.published', + integration_id: '1234', + id: 'strip me', + status: 'strip me', + last_triggered_at: 'strip me', + last_triggered_status: 'strip me', + last_triggered_error: 'strip me', + created_at: 'strip me', + created_by: 'strip me', + updated_at: 'strip me', + updated_by: 'strip me' + }] + } + }; + + let result = validators.input.webhooks.add(apiConfig, frame); + + frame.data.webhooks[0].name.should.equal('pass'); + frame.data.webhooks[0].target_url.should.equal('https://example.com/target/1'); + frame.data.webhooks[0].event.should.equal('post.published'); + frame.data.webhooks[0].integration_id.should.equal('1234'); + should.not.exist(frame.data.webhooks[0].status); + should.not.exist(frame.data.webhooks[0].last_triggered_at); + should.not.exist(frame.data.webhooks[0].last_triggered_status); + should.not.exist(frame.data.webhooks[0].last_triggered_error); + should.not.exist(frame.data.webhooks[0].created_at); + should.not.exist(frame.data.webhooks[0].created_by); + should.not.exist(frame.data.webhooks[0].updated_at); + should.not.exist(frame.data.webhooks[0].updated_by); + }); + }); + + describe('field formats', function () { + const fieldMap = { + name: [123, new Date(), '', _.repeat('a', 192), null], + secret: [123, new Date(), _.repeat('a', 192)], + api_version: [123, new Date(), _.repeat('a', 51)], + integration_id: [123, new Date(), 'not uri'] + }; + + Object.keys(fieldMap).forEach((key) => { + it(`should fail for bad ${key}`, function () { + const badValues = fieldMap[key]; + + const checks = badValues.map((value) => { + const webhook = {}; + webhook[key] = value; + + if (key !== 'name') { + webhook.name = 'abc'; + } + + const frame = { + options: {}, + data: { + webhooks: [webhook] + } + }; + + return validators.input.webhooks.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + return Promise.all(checks); + }); + }); + }); + }); + + describe('edit', function () { + const apiConfig = { + docName: 'webhooks' + }; + + describe('required fields', function () { + it('should fail with no data', function () { + const frame = { + options: {}, + data: {} + }; + + return validators.input.webhooks.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with no webhooks', function () { + const frame = { + options: {}, + data: { + posts: [] + } + }; + + return validators.input.webhooks.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with more than webhooks', function () { + const frame = { + options: {}, + data: { + webhooks: [], + posts: [] + } + }; + + return validators.input.webhooks.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof errors.ValidationError).should.be.true(); + }); + }); + + it('should pass with required fields', function () { + const frame = { + options: {}, + data: { + webhooks: [{ + target_url: 'https://example.com/target/1', + event: 'post.published', + integration_id: '1234' + }] + } + }; + + return validators.input.webhooks.edit(apiConfig, frame); + }); + }); + }); +});