diff --git a/core/server/data/validation/index.js b/core/server/data/validation/index.js index bbbae182d7..82d914d424 100644 --- a/core/server/data/validation/index.js +++ b/core/server/data/validation/index.js @@ -1,343 +1,8 @@ -const schema = require('../schema').tables; -const _ = require('lodash'); -const validator = require('validator'); -const moment = require('moment-timezone'); -const assert = require('assert'); -const Promise = require('bluebird'); -const i18n = require('../../../shared/i18n'); -const errors = require('@tryghost/errors'); -const settingsCache = require('../../services/settings/cache'); -const urlUtils = require('../../../shared/url-utils'); - -function assertString(input) { - assert(typeof input === 'string', 'Validator js validates strings only'); -} - -/** - * Counts repeated characters in a string. When 50% or more characters are the same, - * we return false and therefore invalidate the string. - * @param {String} stringToTest The password string to check. - * @return {Boolean} - */ -function characterOccurance(stringToTest) { - const chars = {}; - let allowedOccurancy; - let valid = true; - - stringToTest = _.toString(stringToTest); - allowedOccurancy = stringToTest.length / 2; - - // Loop through string and accumulate character counts - _.each(stringToTest, function (char) { - if (!chars[char]) { - chars[char] = 1; - } else { - chars[char] += 1; - } - }); - - // check if any of the accumulated chars exceed the allowed occurancy - // of 50% of the words' length. - _.forIn(chars, function (charCount) { - if (charCount >= allowedOccurancy) { - valid = false; - } - }); - - return valid; -} - -// extends has been removed in validator >= 5.0.0, need to monkey-patch it back in -// @TODO: We modify the global validator dependency here! https://github.com/chriso/validator.js/issues/525#issuecomment-213149570 -validator.extend = function (name, fn) { - validator[name] = function () { - const args = Array.prototype.slice.call(arguments); - assertString(args[0]); - return fn.apply(validator, args); - }; -}; - -// Provide a few custom validators -validator.extend('empty', function empty(str) { - return _.isEmpty(str); -}); - -validator.extend('notContains', function notContains(str, badString) { - return !_.includes(str, badString); -}); - -validator.extend('isTimezone', function isTimezone(str) { - return moment.tz.zone(str) ? true : false; -}); - -validator.extend('isEmptyOrURL', function isEmptyOrURL(str) { - return (_.isEmpty(str) || validator.isURL(str, {require_protocol: false})); -}); - -validator.extend('isSlug', function isSlug(str) { - return validator.matches(str, /^[a-z0-9\-_]+$/); -}); - -/** - * Validation against simple password rules - * Returns false when validation fails and true for a valid password - * @param {String} password The password string to check. - * @param {String} email The users email address to validate agains password. - * @param {String} blogTitle Optional blogTitle value, when blog title is not set yet, e. g. in setup process. - * @return {Object} example for returned validation Object: - * invalid password: `validationResult: {isValid: false, message: 'Sorry, you cannot use an insecure password.'}` - * valid password: `validationResult: {isValid: true}` - */ -function validatePassword(password, email, blogTitle) { - const validationResult = {isValid: true}; - const disallowedPasswords = ['password', 'ghost', 'passw0rd']; - let blogUrl = urlUtils.urlFor('home', true); - - const badPasswords = [ - '1234567890', - 'qwertyuiop', - 'qwertzuiop', - 'asdfghjkl;', - 'abcdefghij', - '0987654321', - '1q2w3e4r5t', - '12345asdfg' - ]; - - blogTitle = blogTitle ? blogTitle : settingsCache.get('title'); - blogUrl = blogUrl.replace(/^http(s?):\/\//, ''); - - // password must be longer than 10 characters - if (!validator.isLength(password, 10)) { - validationResult.isValid = false; - validationResult.message = i18n.t('errors.models.user.passwordDoesNotComplyLength', {minLength: 10}); - - return validationResult; - } - - // dissallow password from badPasswords list (e. g. '1234567890') - _.each(badPasswords, function (badPassword) { - if (badPassword === password) { - validationResult.isValid = false; - } - }); - - // password must not match with users' email - if (email && email.toLowerCase() === password.toLowerCase()) { - validationResult.isValid = false; - } - - // password must not contain the words 'ghost', 'password', or 'passw0rd' - _.each(disallowedPasswords, function (disallowedPassword) { - if (password.toLowerCase().indexOf(disallowedPassword) >= 0) { - validationResult.isValid = false; - } - }); - - // password must not match with blog title - if (blogTitle && blogTitle.toLowerCase() === password.toLowerCase()) { - validationResult.isValid = false; - } - - // password must not match with blog URL (without protocol, with or without trailing slash) - if (blogUrl && (blogUrl.toLowerCase() === password.toLowerCase() || blogUrl.toLowerCase().replace(/\/$/, '') === password.toLowerCase())) { - validationResult.isValid = false; - } - - // dissallow passwords where 50% or more of characters are the same - if (!characterOccurance(password)) { - validationResult.isValid = false; - } - - // Generic error message for the rules where no dedicated error massage is set - if (!validationResult.isValid && !validationResult.message) { - validationResult.message = i18n.t('errors.models.user.passwordDoesNotComplySecurity'); - } - - return validationResult; -} - -/** - * Validate model against schema. - * - * ## on model update - * - only validate changed fields - * - otherwise we could throw errors which the user is out of control - * - e.g. - * - we add a new field without proper validation, release goes out - * - we add proper validation for a single field - * - if you call `user.save()` the default fallback in bookshelf is `options.method=update`. - * - we set `options.method` explicit for adding resources (because otherwise bookshelf uses `update`) - * - * ## on model add - * - validate everything to catch required fields - */ -function validateSchema(tableName, model, options) { - options = options || {}; - - const columns = _.keys(schema[tableName]); - let validationErrors = []; - - _.each(columns, function each(columnKey) { - let message = ''; // KEEP: Validator.js only validates strings. - const strVal = _.toString(model.get(columnKey)); - - if (options.method !== 'insert' && !_.has(model.changed, columnKey)) { - return; - } - - // check nullable - if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'nullable') && - schema[tableName][columnKey].nullable !== true && - !Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'defaultTo') - ) { - if (validator.empty(strVal)) { - message = i18n.t('notices.data.validation.index.valueCannotBeBlank', { - tableName: tableName, - columnKey: columnKey - }); - validationErrors.push(new errors.ValidationError({ - message: message, - context: tableName + '.' + columnKey - })); - } - } - - // validate boolean columns - if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type') - && schema[tableName][columnKey].type === 'bool') { - if (!(validator.isBoolean(strVal) || validator.empty(strVal))) { - message = i18n.t('notices.data.validation.index.valueMustBeBoolean', { - tableName: tableName, - columnKey: columnKey - }); - validationErrors.push(new errors.ValidationError({ - message: message, - context: tableName + '.' + columnKey - })); - } - - // CASE: ensure we transform 0|1 to false|true - if (!validator.empty(strVal)) { - model.set(columnKey, !!model.get(columnKey)); - } - } - - // TODO: check if mandatory values should be enforced - if (model.get(columnKey) !== null && model.get(columnKey) !== undefined) { - // check length - if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'maxlength')) { - if (!validator.isLength(strVal, 0, schema[tableName][columnKey].maxlength)) { - message = i18n.t('notices.data.validation.index.valueExceedsMaxLength', - { - tableName: tableName, - columnKey: columnKey, - maxlength: schema[tableName][columnKey].maxlength - }); - validationErrors.push(new errors.ValidationError({ - message: message, - context: tableName + '.' + columnKey - })); - } - } - - // check validations objects - if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'validations')) { - validationErrors = validationErrors.concat(validate(strVal, columnKey, schema[tableName][columnKey].validations, tableName)); - } - - // check type - if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type')) { - if (schema[tableName][columnKey].type === 'integer' && !validator.isInt(strVal)) { - message = i18n.t('notices.data.validation.index.valueIsNotInteger', { - tableName: tableName, - columnKey: columnKey - }); - validationErrors.push(new errors.ValidationError({ - message: message, - context: tableName + '.' + columnKey - })); - } - } - } - }); - - if (validationErrors.length !== 0) { - return Promise.reject(validationErrors); - } - - return Promise.resolve(); -} - -/** - * Validate keys using the validator module. - * Each validation's key is a method name and its value is an array of options - * eg: - * validations: { isURL: true, isLength: [20, 40] } - * will validate that a values's length is a URL between 20 and 40 chars. - * - * If you pass a boolean as the value, it will specify the "good" result. By default - * the "good" result is assumed to be true. - * eg: - * validations: { isNull: false } // means the "good" result would - * // fail the `isNull` check, so - * // not null. - * - * available validators: https://github.com/chriso/validator.js#validators - * @param {String} value the value to validate. - * @param {String} key the db column key of the value to validate. - * @param {Object} validations the validations object as described above. - * @param {String} tableName (optional) the db table of the value to validate, used for error message. - * @return {Array} returns an Array including the found validation errors (empty if none found); - */ -function validate(value, key, validations, tableName) { - const validationErrors = []; - let translation; - value = _.toString(value); - - _.each(validations, function each(validationOptions, validationName) { - let goodResult = true; - - if (_.isBoolean(validationOptions)) { - goodResult = validationOptions; - validationOptions = []; - } else if (!_.isArray(validationOptions)) { - validationOptions = [validationOptions]; - } - - validationOptions.unshift(value); - - // equivalent of validator.isSomething(option1, option2) - if (validator[validationName].apply(validator, validationOptions) !== goodResult) { - // CASE: You can define specific translations for validators e.g. isLength - if (i18n.doesTranslationKeyExist('notices.data.validation.index.validationFailedTypes.' + validationName)) { - translation = i18n.t('notices.data.validation.index.validationFailedTypes.' + validationName, _.merge({ - validationName: validationName, - key: key, - tableName: tableName - }, validationOptions[1])); - } else { - translation = i18n.t('notices.data.validation.index.validationFailed', { - validationName: validationName, - key: key - }); - } - - validationErrors.push(new errors.ValidationError({ - message: translation, - context: `${tableName}.${key}` - })); - } - - validationOptions.shift(); - }, this); - - return validationErrors; -} - module.exports = { - validate, - validator, - validatePassword, - validateSchema + validate: require('./validate'), + validator: require('./validator'), + + // These two things are dependent on validator, not related + validatePassword: require('./password'), + validateSchema: require('./schema') }; diff --git a/core/server/data/validation/password.js b/core/server/data/validation/password.js new file mode 100644 index 0000000000..a12d5af5fb --- /dev/null +++ b/core/server/data/validation/password.js @@ -0,0 +1,122 @@ +const _ = require('lodash'); + +const validator = require('./validator'); + +const i18n = require('../../../shared/i18n'); +const settingsCache = require('../../services/settings/cache'); +const urlUtils = require('../../../shared/url-utils'); + +/** + * Counts repeated characters in a string. When 50% or more characters are the same, + * we return false and therefore invalidate the string. + * @param {String} stringToTest The password string to check. + * @return {Boolean} + */ +function characterOccurance(stringToTest) { + const chars = {}; + let allowedOccurancy; + let valid = true; + + stringToTest = _.toString(stringToTest); + allowedOccurancy = stringToTest.length / 2; + + // Loop through string and accumulate character counts + _.each(stringToTest, function (char) { + if (!chars[char]) { + chars[char] = 1; + } else { + chars[char] += 1; + } + }); + + // check if any of the accumulated chars exceed the allowed occurancy + // of 50% of the words' length. + _.forIn(chars, function (charCount) { + if (charCount >= allowedOccurancy) { + valid = false; + } + }); + + return valid; +} + +/** + * Validation against simple password rules + * Returns false when validation fails and true for a valid password + * @param {String} password The password string to check. + * @param {String} email The users email address to validate agains password. + * @param {String} blogTitle Optional blogTitle value, when blog title is not set yet, e. g. in setup process. + * @return {Object} example for returned validation Object: + * invalid password: `validationResult: {isValid: false, message: 'Sorry, you cannot use an insecure password.'}` + * valid password: `validationResult: {isValid: true}` + */ +function validatePassword(password, email, blogTitle) { + const validationResult = {isValid: true}; + const disallowedPasswords = ['password', 'ghost', 'passw0rd']; + let blogUrl = urlUtils.urlFor('home', true); + + const badPasswords = [ + '1234567890', + 'qwertyuiop', + 'qwertzuiop', + 'asdfghjkl;', + 'abcdefghij', + '0987654321', + '1q2w3e4r5t', + '12345asdfg' + ]; + + blogTitle = blogTitle ? blogTitle : settingsCache.get('title'); + blogUrl = blogUrl.replace(/^http(s?):\/\//, ''); + + // password must be longer than 10 characters + if (!validator.isLength(password, 10)) { + validationResult.isValid = false; + validationResult.message = i18n.t('errors.models.user.passwordDoesNotComplyLength', {minLength: 10}); + + return validationResult; + } + + // dissallow password from badPasswords list (e. g. '1234567890') + _.each(badPasswords, function (badPassword) { + if (badPassword === password) { + validationResult.isValid = false; + } + }); + + // password must not match with users' email + if (email && email.toLowerCase() === password.toLowerCase()) { + validationResult.isValid = false; + } + + // password must not contain the words 'ghost', 'password', or 'passw0rd' + _.each(disallowedPasswords, function (disallowedPassword) { + if (password.toLowerCase().indexOf(disallowedPassword) >= 0) { + validationResult.isValid = false; + } + }); + + // password must not match with blog title + if (blogTitle && blogTitle.toLowerCase() === password.toLowerCase()) { + validationResult.isValid = false; + } + + // password must not match with blog URL (without protocol, with or without trailing slash) + if (blogUrl && (blogUrl.toLowerCase() === password.toLowerCase() || blogUrl.toLowerCase().replace(/\/$/, '') === password.toLowerCase())) { + validationResult.isValid = false; + } + + // dissallow passwords where 50% or more of characters are the same + if (!characterOccurance(password)) { + validationResult.isValid = false; + } + + // Generic error message for the rules where no dedicated error massage is set + if (!validationResult.isValid && !validationResult.message) { + validationResult.message = i18n.t('errors.models.user.passwordDoesNotComplySecurity'); + } + + return validationResult; +} + +module.exports = validatePassword; diff --git a/core/server/data/validation/schema.js b/core/server/data/validation/schema.js new file mode 100644 index 0000000000..c5546f86c7 --- /dev/null +++ b/core/server/data/validation/schema.js @@ -0,0 +1,122 @@ +const _ = require('lodash'); +const Promise = require('bluebird'); + +const i18n = require('../../../shared/i18n'); +const errors = require('@tryghost/errors'); + +const schema = require('../schema').tables; +const validator = require('./validator'); +const validate = require('./validate'); + +/** + * Validate model against schema. + * + * ## on model update + * - only validate changed fields + * - otherwise we could throw errors which the user is out of control + * - e.g. + * - we add a new field without proper validation, release goes out + * - we add proper validation for a single field + * - if you call `user.save()` the default fallback in bookshelf is `options.method=update`. + * - we set `options.method` explicit for adding resources (because otherwise bookshelf uses `update`) + * + * ## on model add + * - validate everything to catch required fields + */ +function validateSchema(tableName, model, options) { + options = options || {}; + + const columns = _.keys(schema[tableName]); + let validationErrors = []; + + _.each(columns, function each(columnKey) { + let message = ''; // KEEP: Validator.js only validates strings. + const strVal = _.toString(model.get(columnKey)); + + if (options.method !== 'insert' && !_.has(model.changed, columnKey)) { + return; + } + + // check nullable + if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'nullable') && + schema[tableName][columnKey].nullable !== true && + !Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'defaultTo') + ) { + if (validator.empty(strVal)) { + message = i18n.t('notices.data.validation.index.valueCannotBeBlank', { + tableName: tableName, + columnKey: columnKey + }); + validationErrors.push(new errors.ValidationError({ + message: message, + context: tableName + '.' + columnKey + })); + } + } + + // validate boolean columns + if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type') + && schema[tableName][columnKey].type === 'bool') { + if (!(validator.isBoolean(strVal) || validator.empty(strVal))) { + message = i18n.t('notices.data.validation.index.valueMustBeBoolean', { + tableName: tableName, + columnKey: columnKey + }); + validationErrors.push(new errors.ValidationError({ + message: message, + context: tableName + '.' + columnKey + })); + } + + // CASE: ensure we transform 0|1 to false|true + if (!validator.empty(strVal)) { + model.set(columnKey, !!model.get(columnKey)); + } + } + + // TODO: check if mandatory values should be enforced + if (model.get(columnKey) !== null && model.get(columnKey) !== undefined) { + // check length + if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'maxlength')) { + if (!validator.isLength(strVal, 0, schema[tableName][columnKey].maxlength)) { + message = i18n.t('notices.data.validation.index.valueExceedsMaxLength', + { + tableName: tableName, + columnKey: columnKey, + maxlength: schema[tableName][columnKey].maxlength + }); + validationErrors.push(new errors.ValidationError({ + message: message, + context: tableName + '.' + columnKey + })); + } + } + + // check validations objects + if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'validations')) { + validationErrors = validationErrors.concat(validate(strVal, columnKey, schema[tableName][columnKey].validations, tableName)); + } + + // check type + if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type')) { + if (schema[tableName][columnKey].type === 'integer' && !validator.isInt(strVal)) { + message = i18n.t('notices.data.validation.index.valueIsNotInteger', { + tableName: tableName, + columnKey: columnKey + }); + validationErrors.push(new errors.ValidationError({ + message: message, + context: tableName + '.' + columnKey + })); + } + } + } + }); + + if (validationErrors.length !== 0) { + return Promise.reject(validationErrors); + } + + return Promise.resolve(); +} +module.exports = validateSchema; diff --git a/core/server/data/validation/validate.js b/core/server/data/validation/validate.js new file mode 100644 index 0000000000..7740ef3d54 --- /dev/null +++ b/core/server/data/validation/validate.js @@ -0,0 +1,72 @@ +const _ = require('lodash'); +const validator = require('./validator'); +const i18n = require('../../../shared/i18n'); +const errors = require('@tryghost/errors'); + +/** + * Validate keys using the validator module. + * Each validation's key is a method name and its value is an array of options + * eg: + * validations: { isURL: true, isLength: [20, 40] } + * will validate that a values's length is a URL between 20 and 40 chars. + * + * If you pass a boolean as the value, it will specify the "good" result. By default + * the "good" result is assumed to be true. + * eg: + * validations: { isNull: false } // means the "good" result would + * // fail the `isNull` check, so + * // not null. + * + * available validators: https://github.com/chriso/validator.js#validators + * @param {String} value the value to validate. + * @param {String} key the db column key of the value to validate. + * @param {Object} validations the validations object as described above. + * @param {String} tableName (optional) the db table of the value to validate, used for error message. + * @return {Array} returns an Array including the found validation errors (empty if none found); + */ +function validate(value, key, validations, tableName) { + const validationErrors = []; + let translation; + value = _.toString(value); + + _.each(validations, function each(validationOptions, validationName) { + let goodResult = true; + + if (_.isBoolean(validationOptions)) { + goodResult = validationOptions; + validationOptions = []; + } else if (!_.isArray(validationOptions)) { + validationOptions = [validationOptions]; + } + + validationOptions.unshift(value); + + // equivalent of validator.isSomething(option1, option2) + if (validator[validationName].apply(validator, validationOptions) !== goodResult) { + // CASE: You can define specific translations for validators e.g. isLength + if (i18n.doesTranslationKeyExist('notices.data.validation.index.validationFailedTypes.' + validationName)) { + translation = i18n.t('notices.data.validation.index.validationFailedTypes.' + validationName, _.merge({ + validationName: validationName, + key: key, + tableName: tableName + }, validationOptions[1])); + } else { + translation = i18n.t('notices.data.validation.index.validationFailed', { + validationName: validationName, + key: key + }); + } + + validationErrors.push(new errors.ValidationError({ + message: translation, + context: `${tableName}.${key}` + })); + } + + validationOptions.shift(); + }, this); + + return validationErrors; +} + +module.exports = validate; diff --git a/core/server/data/validation/validator.js b/core/server/data/validation/validator.js new file mode 100644 index 0000000000..8a65c88289 --- /dev/null +++ b/core/server/data/validation/validator.js @@ -0,0 +1,42 @@ +const _ = require('lodash'); + +const validator = require('validator'); +const moment = require('moment-timezone'); +const assert = require('assert'); + +function assertString(input) { + assert(typeof input === 'string', 'Validator js validates strings only'); +} + +// extends has been removed in validator >= 5.0.0, need to monkey-patch it back in +// @TODO: We modify the global validator dependency here! https://github.com/chriso/validator.js/issues/525#issuecomment-213149570 +validator.extend = function (name, fn) { + validator[name] = function () { + const args = Array.prototype.slice.call(arguments); + assertString(args[0]); + return fn.apply(validator, args); + }; +}; + +// Provide a few custom validators +validator.extend('empty', function empty(str) { + return _.isEmpty(str); +}); + +validator.extend('notContains', function notContains(str, badString) { + return !_.includes(str, badString); +}); + +validator.extend('isTimezone', function isTimezone(str) { + return moment.tz.zone(str) ? true : false; +}); + +validator.extend('isEmptyOrURL', function isEmptyOrURL(str) { + return (_.isEmpty(str) || validator.isURL(str, {require_protocol: false})); +}); + +validator.extend('isSlug', function isSlug(str) { + return validator.matches(str, /^[a-z0-9\-_]+$/); +}); + +module.exports = validator; diff --git a/test/unit/data/validation/index_spec.js b/test/unit/data/validation/index_spec.js index 46248bfb01..1e1b52cce1 100644 --- a/test/unit/data/validation/index_spec.js +++ b/test/unit/data/validation/index_spec.js @@ -1,21 +1,14 @@ const should = require('should'); -const _ = require('lodash'); -const ObjectId = require('bson-objectid'); -const testUtils = require('../../../utils'); -const models = require('../../../../core/server/models'); + const validation = require('../../../../core/server/data/validation'); // Validate our customizations describe('Validation', function () { - before(function () { - models.init(); - }); - it('should export our required functions', function () { should.exist(validation); validation.should.have.properties( - ['validate', 'validator', 'validateSchema'] + ['validate', 'validator', 'validateSchema', 'validatePassword'] ); validation.validate.should.be.a.Function(); @@ -24,160 +17,4 @@ describe('Validation', function () { validation.validator.should.have.properties(['empty', 'notContains', 'isTimezone', 'isEmptyOrURL', 'isSlug']); }); - - describe('Validate Schema', function () { - describe('models.add', function () { - it('blank model', function () { - // NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level. - return validation.validateSchema('posts', models.Post.forge(), {method: 'insert'}) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err) { - if (!_.isArray(err)) { - throw err; - } - - err.length.should.eql(7); - - const errorMessages = _.map(err, function (object) { - return object.message; - }).join(','); - - // NOTE: Some of these fields are auto-filled in the model layer (e.g. author_id, created_at etc.) - ['id', 'uuid', 'slug', 'title', 'author_id', 'created_at', 'created_by'].forEach(function (attr) { - errorMessages.should.match(new RegExp('posts.' + attr)); - }); - }); - }); - - it('blank id', function () { - const postModel = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({ - id: null, - slug: 'test' - })); - - return validation.validateSchema('posts', postModel, {method: 'insert'}) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err) { - if (!_.isArray(err)) { - throw err; - } - - err.length.should.eql(1); - err[0].message.should.match(/posts\.id/); - }); - }); - - it('should pass', function () { - return validation.validateSchema( - 'posts', - models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'title'})), - {method: 'insert'} - ); - }); - - it('transforms 0 and 1', function () { - const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'test', featured: 0})); - post.get('featured').should.eql(0); - - return validation.validateSchema('posts', post, {method: 'insert'}) - .then(function () { - post.get('featured').should.eql(false); - }); - }); - - it('keeps true or false', function () { - const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'test', featured: true})); - post.get('featured').should.eql(true); - - return validation.validateSchema('posts', post, {method: 'insert'}) - .then(function () { - post.get('featured').should.eql(true); - }); - }); - }); - - describe('webhooks.add', function () { - it('event name is not lowercase', function () { - const webhook = models.Webhook.forge(testUtils.DataGenerator.forKnex.createWebhook({ - event: 'Test', - integration_id: testUtils.DataGenerator.Content.integrations[0].id - })); - - // NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level. - return validation.validateSchema('webhooks', webhook, {method: 'insert'}) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err) { - if (!_.isArray(err)) { - throw err; - } - - err.length.should.eql(1); - err[0].errorType.should.eql('ValidationError'); - err[0].message.should.match(/isLowercase/); - }); - }); - }); - - describe('models.edit', function () { - it('uuid is invalid', function () { - const postModel = models.Post.forge({id: ObjectId().toHexString(), uuid: '1234'}); - - postModel.changed = {uuid: postModel.get('uuid')}; - - return validation.validateSchema('posts', postModel) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err) { - if (!_.isArray(err)) { - throw err; - } - - err.length.should.eql(1); - err[0].message.should.match(/isUUID/); - }); - }); - - it('date is null', function () { - const postModel = models.Post.forge({id: ObjectId().toHexString(), created_at: null}); - - postModel.changed = {created_at: postModel.get('updated_at')}; - - return validation.validateSchema('posts', postModel) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err) { - if (!_.isArray(err)) { - throw err; - } - - err.length.should.eql(1); - err[0].message.should.match(/posts\.created_at/); - }); - }); - }); - }); - - describe('Assert the Validator dependency', function () { - const validator = validation.validator; - - it('isEmptyOrUrl filters javascript urls', function () { - validator.isEmptyOrURL('javascript:alert(0)').should.be.false(); - validator.isEmptyOrURL('http://example.com/lol//').should.be.false(); - validator.isEmptyOrURL('http://example.com/lol?somequery=').should.be.false(); - validator.isEmptyOrURL('').should.be.true(); - validator.isEmptyOrURL('http://localhost:2368').should.be.true(); - validator.isEmptyOrURL('http://example.com/test/').should.be.true(); - validator.isEmptyOrURL('http://www.example.com/test/').should.be.true(); - validator.isEmptyOrURL('http://example.com/foo?somequery=bar').should.be.true(); - validator.isEmptyOrURL('example.com/test/').should.be.true(); - }); - }); }); diff --git a/test/unit/data/validation/schema_spec.js b/test/unit/data/validation/schema_spec.js new file mode 100644 index 0000000000..b377f2561f --- /dev/null +++ b/test/unit/data/validation/schema_spec.js @@ -0,0 +1,150 @@ +const should = require('should'); +const _ = require('lodash'); +const ObjectId = require('bson-objectid'); +const testUtils = require('../../../utils'); +const models = require('../../../../core/server/models'); +const validation = require('../../../../core/server/data/validation'); + +describe('Validate Schema', function () { + before(function () { + models.init(); + }); + + describe('models.add', function () { + it('blank model', function () { + // NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level. + return validation.validateSchema('posts', models.Post.forge(), {method: 'insert'}) + .then(function () { + throw new Error('Expected ValidationError.'); + }) + .catch(function (err) { + if (!_.isArray(err)) { + throw err; + } + + err.length.should.eql(7); + + const errorMessages = _.map(err, function (object) { + return object.message; + }).join(','); + + // NOTE: Some of these fields are auto-filled in the model layer (e.g. author_id, created_at etc.) + ['id', 'uuid', 'slug', 'title', 'author_id', 'created_at', 'created_by'].forEach(function (attr) { + errorMessages.should.match(new RegExp('posts.' + attr)); + }); + }); + }); + + it('blank id', function () { + const postModel = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({ + id: null, + slug: 'test' + })); + + return validation.validateSchema('posts', postModel, {method: 'insert'}) + .then(function () { + throw new Error('Expected ValidationError.'); + }) + .catch(function (err) { + if (!_.isArray(err)) { + throw err; + } + + err.length.should.eql(1); + err[0].message.should.match(/posts\.id/); + }); + }); + + it('should pass', function () { + return validation.validateSchema( + 'posts', + models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'title'})), + {method: 'insert'} + ); + }); + + it('transforms 0 and 1', function () { + const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'test', featured: 0})); + post.get('featured').should.eql(0); + + return validation.validateSchema('posts', post, {method: 'insert'}) + .then(function () { + post.get('featured').should.eql(false); + }); + }); + + it('keeps true or false', function () { + const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({slug: 'test', featured: true})); + post.get('featured').should.eql(true); + + return validation.validateSchema('posts', post, {method: 'insert'}) + .then(function () { + post.get('featured').should.eql(true); + }); + }); + }); + + describe('webhooks.add', function () { + it('event name is not lowercase', function () { + const webhook = models.Webhook.forge(testUtils.DataGenerator.forKnex.createWebhook({ + event: 'Test', + integration_id: testUtils.DataGenerator.Content.integrations[0].id + })); + + // NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level. + return validation.validateSchema('webhooks', webhook, {method: 'insert'}) + .then(function () { + throw new Error('Expected ValidationError.'); + }) + .catch(function (err) { + if (!_.isArray(err)) { + throw err; + } + + err.length.should.eql(1); + err[0].errorType.should.eql('ValidationError'); + err[0].message.should.match(/isLowercase/); + }); + }); + }); + + describe('models.edit', function () { + it('uuid is invalid', function () { + const postModel = models.Post.forge({id: ObjectId().toHexString(), uuid: '1234'}); + + postModel.changed = {uuid: postModel.get('uuid')}; + + return validation.validateSchema('posts', postModel) + .then(function () { + throw new Error('Expected ValidationError.'); + }) + .catch(function (err) { + if (!_.isArray(err)) { + throw err; + } + + err.length.should.eql(1); + err[0].message.should.match(/isUUID/); + }); + }); + + it('date is null', function () { + const postModel = models.Post.forge({id: ObjectId().toHexString(), created_at: null}); + + postModel.changed = {created_at: postModel.get('updated_at')}; + + return validation.validateSchema('posts', postModel) + .then(function () { + throw new Error('Expected ValidationError.'); + }) + .catch(function (err) { + if (!_.isArray(err)) { + throw err; + } + + err.length.should.eql(1); + err[0].message.should.match(/posts\.created_at/); + }); + }); + }); +}); diff --git a/test/unit/data/validation/validator_spec.js b/test/unit/data/validation/validator_spec.js new file mode 100644 index 0000000000..7637ab5a94 --- /dev/null +++ b/test/unit/data/validation/validator_spec.js @@ -0,0 +1,19 @@ +const should = require('should'); + +const validation = require('../../../../core/server/data/validation'); + +describe('Validator dependency', function () { + const validator = validation.validator; + + it('isEmptyOrUrl filters javascript urls', function () { + validator.isEmptyOrURL('javascript:alert(0)').should.be.false(); + validator.isEmptyOrURL('http://example.com/lol//').should.be.false(); + validator.isEmptyOrURL('http://example.com/lol?somequery=').should.be.false(); + validator.isEmptyOrURL('').should.be.true(); + validator.isEmptyOrURL('http://localhost:2368').should.be.true(); + validator.isEmptyOrURL('http://example.com/test/').should.be.true(); + validator.isEmptyOrURL('http://www.example.com/test/').should.be.true(); + validator.isEmptyOrURL('http://example.com/foo?somequery=bar').should.be.true(); + validator.isEmptyOrURL('example.com/test/').should.be.true(); + }); +});