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.
This commit is contained in:
Nazar Gargol 2020-07-17 17:37:14 +12:00
parent 8f660c3259
commit a520cdad0b
13 changed files with 724 additions and 13 deletions

View File

@ -49,5 +49,9 @@ module.exports = {
get oembed() {
return require('./oembed');
},
get webhooks() {
return require('./webhooks');
}
};

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -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
}
}
}
}
}

View File

@ -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);
}
};

View File

@ -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;
};

View File

@ -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)) {

View File

@ -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(

View File

@ -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": {

View File

@ -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);

View File

@ -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;

View File

@ -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);
});
});
});
});

View File

@ -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);
});
});
});
});