Split validation index into separate files

- The data/validation module is made up of several loosely related things with lots of dependencies
- Separating out the various components makes it possible to see what's what, and importantly what has complex dependencies
- validator + validate probably go togetheri in an external module, the other two files should probably have their own homes in related areas of ghost e.g. schema -> data/schema/validate.js
This commit is contained in:
Hannah Wolfe 2021-06-14 17:53:24 +01:00
parent 846e9f1bbc
commit c37de311ea
No known key found for this signature in database
GPG Key ID: 9F8C7532D0A6BA55
8 changed files with 535 additions and 506 deletions

View File

@ -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 = { module.exports = {
validate, validate: require('./validate'),
validator, validator: require('./validator'),
validatePassword,
validateSchema // These two things are dependent on validator, not related
validatePassword: require('./password'),
validateSchema: require('./schema')
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,14 @@
const should = require('should'); 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'); const validation = require('../../../../core/server/data/validation');
// Validate our customizations // Validate our customizations
describe('Validation', function () { describe('Validation', function () {
before(function () {
models.init();
});
it('should export our required functions', function () { it('should export our required functions', function () {
should.exist(validation); should.exist(validation);
validation.should.have.properties( validation.should.have.properties(
['validate', 'validator', 'validateSchema'] ['validate', 'validator', 'validateSchema', 'validatePassword']
); );
validation.validate.should.be.a.Function(); validation.validate.should.be.a.Function();
@ -24,160 +17,4 @@ describe('Validation', function () {
validation.validator.should.have.properties(['empty', 'notContains', 'isTimezone', 'isEmptyOrURL', 'isSlug']); 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/<script>lalala</script>/').should.be.false();
validator.isEmptyOrURL('http://example.com/lol?somequery=<script>lalala</script>').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();
});
});
}); });

View File

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

View File

@ -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/<script>lalala</script>/').should.be.false();
validator.isEmptyOrURL('http://example.com/lol?somequery=<script>lalala</script>').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();
});
});