0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-24 23:48:13 -05:00
ghost/core/test/unit/api/utils_spec.js
Katharina Irrgang 40d0a745df Multiple authors (#9426)
no issue

This PR adds the server side logic for multiple authors. This adds the ability to add multiple authors per post. We keep and support single authors (maybe till the next major - this is still in discussion)

### key notes

- `authors` are not fetched by default, only if we need them
- the migration script iterates over all posts and figures out if an author_id is valid and exists (in master we can add invalid author_id's) and then adds the relation (falls back to owner if invalid)
- ~~i had to push a fork of bookshelf to npm because we currently can't bump bookshelf + the two bugs i discovered are anyway not yet merged (https://github.com/kirrg001/bookshelf/commits/master)~~ replaced by new bookshelf release
- the implementation of single & multiple authors lives in a single place (introduction of a new concept: model relation)
- if you destroy an author, we keep the behaviour for now -> remove all posts where the primary author id matches. furthermore, remove all relations in posts_authors (e.g. secondary author)
- we make re-use of the `excludeAttrs` concept which was invented in the contributors PR (to protect editing authors as author/contributor role) -> i've added a clear todo that we need a logic to make a diff of the target relation -> both for tags and authors
- `authors` helper available (same as `tags` helper)
- `primary_author` computed field available
- `primary_author` functionality available (same as `primary_tag` e.g. permalinks, prev/next helper etc)
2018-03-27 15:16:15 +01:00

755 lines
30 KiB
JavaScript

var should = require('should'),
sinon = require('sinon'),
_ = require('lodash'),
Promise = require('bluebird'),
ObjectId = require('bson-objectid'),
permissions = require('../../../server/services/permissions'),
common = require('../../../server/lib/common'),
apiUtils = require('../../../server/api/utils'),
sandbox = sinon.sandbox.create();
describe('API Utils', function () {
afterEach(function () {
sandbox.restore();
});
it('exports', function () {
// @TODO reduce the number of methods that are here!
_.keys(apiUtils).should.eql([
'globalDefaultOptions',
'dataDefaultOptions',
'browseDefaultOptions',
'idDefaultOptions',
'validate',
'validateOptions',
'detectPublicContext',
'applyPublicPermissions',
'handlePublicPermissions',
'handlePermissions',
'trimAndLowerCase',
'prepareInclude',
'prepareFields',
'prepareFormats',
'convertOptions',
'checkObject'
]);
});
describe('Default Options', function () {
it('should provide a set of default options', function () {
apiUtils.globalDefaultOptions.should.eql(['context', 'include']);
apiUtils.browseDefaultOptions.should.eql(['page', 'limit', 'fields', 'filter', 'order', 'debug']);
apiUtils.dataDefaultOptions.should.eql(['data']);
apiUtils.idDefaultOptions.should.eql(['id']);
});
});
describe('validate', function () {
it('should create options when passed no args', function (done) {
apiUtils.validate()().then(function (options) {
options.should.eql({});
done();
}).catch(done);
});
it('should pick data attrs when passed them', function (done) {
apiUtils.validate('test', {attrs: ['id']})(
{id: 'test', status: 'all', uuid: 'other-test'}
).then(function (options) {
options.should.have.ownProperty('data');
options.data.should.have.ownProperty('id');
options.should.not.have.ownProperty('id');
options.data.id.should.eql('test');
options.data.should.not.have.ownProperty('status');
options.should.not.have.ownProperty('status');
options.should.not.have.ownProperty('uuid');
done();
}).catch(done);
});
it('should pick data attrs & leave options if passed', function (done) {
apiUtils.validate('test', {attrs: ['id'], opts: ['status', 'uuid']})(
{id: 'test', status: 'all', uuid: 'ffecea44-393c-4273-b784-e1928975ecfb'}
).then(function (options) {
options.should.have.ownProperty('data');
options.data.should.have.ownProperty('id');
options.should.not.have.ownProperty('id');
options.data.id.should.eql('test');
options.data.should.not.have.ownProperty('status');
options.should.have.ownProperty('status');
options.status.should.eql('all');
options.should.have.ownProperty('uuid');
options.uuid.should.eql('ffecea44-393c-4273-b784-e1928975ecfb');
done();
}).catch(done);
});
it('should check data if an object is passed', function (done) {
var object = {test: [{id: 1}]},
checkObjectStub = sandbox.stub(apiUtils, 'checkObject').returns(Promise.resolve(object));
apiUtils.validate('test')(object, {}).then(function (options) {
checkObjectStub.calledOnce.should.be.true();
checkObjectStub.calledWith(object, 'test').should.be.true();
options.should.have.ownProperty('data');
options.data.should.have.ownProperty('test');
done();
}).catch(done);
});
it('should handle options being undefined', function (done) {
apiUtils.validate()(undefined).then(function (options) {
options.should.eql({});
done();
}).catch(done);
});
it('should handle options being undefined when provided with object', function (done) {
var object = {test: [{id: 1}]},
checkObjectStub = sandbox.stub(apiUtils, 'checkObject').returns(Promise.resolve(object));
apiUtils.validate('test')(object, undefined).then(function (options) {
checkObjectStub.calledOnce.should.be.true();
checkObjectStub.calledWith(object, 'test').should.be.true();
options.should.have.ownProperty('data');
options.data.should.have.ownProperty('test');
done();
}).catch(done);
});
it('should remove unknown options', function (done) {
apiUtils.validate('test')({magic: 'stuff', rubbish: 'stuff'}).then(function (options) {
options.should.not.have.ownProperty('data');
options.should.not.have.ownProperty('rubbish');
options.should.not.have.ownProperty('magic');
done();
}).catch(done);
});
it('should always allow context & include options', function (done) {
apiUtils.validate('test')({context: 'stuff', include: 'stuff'}).then(function (options) {
options.should.not.have.ownProperty('data');
options.should.have.ownProperty('context');
options.context.should.eql('stuff');
options.should.have.ownProperty('include');
options.include.should.eql('stuff');
done();
}).catch(done);
});
it('should allow page & limit options when browseDefaultOptions passed', function (done) {
apiUtils.validate('test', {opts: apiUtils.browseDefaultOptions})(
{context: 'stuff', include: 'stuff', page: 1, limit: 5}
).then(function (options) {
options.should.not.have.ownProperty('data');
options.should.have.ownProperty('context');
options.context.should.eql('stuff');
options.should.have.ownProperty('include');
options.include.should.eql('stuff');
options.should.have.ownProperty('page');
options.page.should.eql(1);
options.should.have.ownProperty('limit');
options.limit.should.eql(5);
done();
}).catch(done);
});
it('should allow idDefaultOptions when passed', function (done) {
var id = ObjectId.generate();
apiUtils.validate('test', {opts: apiUtils.idDefaultOptions})(
{id: id, context: 'stuff'}
).then(function (options) {
options.should.not.have.ownProperty('data');
options.should.not.have.ownProperty('include');
options.should.not.have.ownProperty('page');
options.should.not.have.ownProperty('limit');
options.should.have.ownProperty('context');
options.context.should.eql('stuff');
options.should.have.ownProperty('id');
options.id.should.eql(id);
done();
}).catch(done);
});
it('should reject if limit is invalid', function (done) {
apiUtils.validate('test', {opts: apiUtils.browseDefaultOptions})(
{limit: 'none'}
).then(function () {
done(new Error('Should have thrown a validation error'));
}).catch(function (err) {
err.should.have.property('errorType', 'ValidationError');
done();
});
});
it('should reject if from is invalid', function (done) {
apiUtils.validate('test', {opts: ['from']})(
{from: true}
).then(function () {
done(new Error('Should have thrown a validation error'));
}).catch(function (err) {
err.should.have.property('errorType', 'ValidationError');
done();
});
});
});
describe('validateOptions', function () {
var valid, invalid;
function check(key, valid, invalid) {
_.each(valid, function (value) {
var options = {};
options[key] = value;
apiUtils.validateOptions(options).should.eql([]);
});
_.each(invalid, function (value) {
var options = {}, errors;
options[key] = value;
errors = apiUtils.validateOptions(options);
errors.should.be.an.Array().and.have.lengthOf(1);
errors[0].errorType.should.eql('ValidationError');
});
}
it('can validate `id`', function () {
valid = [ObjectId.generate(), '1', 1];
invalid = ['test', 'de305d54', 300, '304'];
check('id', valid, invalid);
});
it('can validate `uuid`', function () {
valid = ['de305d54-75b4-431b-adb2-eb6b9e546014'];
invalid = ['de305d54-75b4-431b-adb2'];
check('uuid', valid, invalid);
});
it('can validate `page`', function () {
valid = [1, '1', 304, '304'];
invalid = ['me', 'test', 'de305d54', -1, '-1'];
check('page', valid, invalid);
});
it('can validate `limit`', function () {
valid = [1, '1', 304, '304', 'all'];
invalid = ['me', 'test', 'de305d54', -1, '-1'];
check('limit', valid, invalid);
});
it('can validate `slug` or `status` or `author` etc as a-z, 0-9, - and _', function () {
valid = ['hello-world', 'hello', '1-2-3', 1, '-1', -1, 'hello_world'];
invalid = ['hello~world', '!things', '?other-things', 'thing"', '`ticks`'];
check('slug', valid, invalid);
check('status', valid, invalid);
check('author', valid, invalid);
});
it('gives no errors for `context`, `include` and `data`', function () {
apiUtils.validateOptions({
context: {user: 1},
include: '"super,@random!,string?and',
data: {object: 'thing'}
}).should.eql([]);
});
});
describe('prepareInclude', function () {
it('should handle empty items', function () {
apiUtils.prepareInclude('', []).should.eql([]);
});
it('should be empty if there are no allowed includes', function () {
apiUtils.prepareInclude('a,b,c', []).should.eql([]);
});
it('should return correct includes', function () {
apiUtils.prepareInclude('a,b,c', ['a']).should.eql(['a']);
apiUtils.prepareInclude('a,b,c', ['a', 'c']).should.eql(['a', 'c']);
apiUtils.prepareInclude('a,b,c', ['a', 'd']).should.eql(['a']);
apiUtils.prepareInclude('a,b,c', ['d']).should.eql([]);
});
});
describe('convertOptions', function () {
it('should not call prepareInclude if there is no include option', function () {
var prepareIncludeStub = sandbox.stub(apiUtils, 'prepareInclude');
apiUtils.convertOptions(['a', 'b', 'c'])({}).should.eql({});
prepareIncludeStub.called.should.be.false();
});
it('should pass options.include to prepareInclude if provided', function () {
var expectedResult = ['a', 'b'],
prepareIncludeStub = sandbox.stub(apiUtils, 'prepareInclude').returns(expectedResult),
allowed = ['a', 'b', 'c'],
options = {include: 'a,b'},
actualResult;
actualResult = apiUtils.convertOptions(allowed)(_.clone(options));
prepareIncludeStub.calledOnce.should.be.true();
prepareIncludeStub.calledWith(options.include, allowed).should.be.true();
actualResult.should.have.hasOwnProperty('withRelated');
actualResult.withRelated.should.be.an.Array();
actualResult.withRelated.should.eql(expectedResult);
});
});
describe('checkObject', function () {
it('throws an error if the object is empty', function (done) {
apiUtils.checkObject({}, 'test').then(function () {
done('This should have thrown an error');
}).catch(function (error) {
should.exist(error);
error.errorType.should.eql('BadRequestError');
done();
});
});
it('throws an error if the object key is empty', function (done) {
apiUtils.checkObject({test: []}, 'test').then(function () {
done('This should have thrown an error');
}).catch(function (error) {
should.exist(error);
error.errorType.should.eql('BadRequestError');
done();
});
});
it('throws an error if the object key is array with empty object', function (done) {
apiUtils.checkObject({test: [{}]}, 'test').then(function () {
done('This should have thrown an error');
}).catch(function (error) {
should.exist(error);
error.errorType.should.eql('BadRequestError');
done();
});
});
it('passed through a simple, correct object', function (done) {
var object = {test: [{id: 1}]};
apiUtils.checkObject(_.cloneDeep(object), 'test').then(function (data) {
should.exist(data);
data.should.have.ownProperty('test');
object.should.eql(data);
done();
}).catch(done);
});
it('[DEPRECATED] should do author_id to author conversion for posts', function (done) {
var object = {posts: [{id: 1, author: 4}]};
apiUtils.checkObject(_.cloneDeep(object), 'posts').then(function (data) {
should.exist(data);
data.should.have.ownProperty('posts');
data.should.not.eql(object);
data.posts.should.be.an.Array();
data.posts[0].should.have.ownProperty('author_id');
data.posts[0].should.not.have.ownProperty('author');
done();
}).catch(done);
});
it('[DEPRECATED] should not do author_id to author conversion for posts if not needed', function (done) {
var object = {posts: [{id: 1, author_id: 4}]};
apiUtils.checkObject(_.cloneDeep(object), 'posts').then(function (data) {
should.exist(data);
data.should.have.ownProperty('posts');
data.should.eql(object);
data.posts.should.be.an.Array();
data.posts[0].should.have.ownProperty('author_id');
data.posts[0].should.not.have.ownProperty('author');
done();
}).catch(done);
});
it('should throw error if invalid editId if provided', function (done) {
var object = {test: [{id: 1}]};
apiUtils.checkObject(_.cloneDeep(object), 'test', 3).then(function () {
done('This should have thrown an error');
}).catch(function (error) {
should.exist(error);
error.errorType.should.eql('BadRequestError');
done();
});
});
it('should ignore undefined editId', function (done) {
var object = {test: [{id: 1}]};
apiUtils.checkObject(_.cloneDeep(object), 'test', undefined).then(function (data) {
should.exist(data);
data.should.eql(object);
done();
}).catch(done);
});
it('should ignore editId if object has no id', function (done) {
var object = {test: [{uuid: 1}]};
apiUtils.checkObject(_.cloneDeep(object), 'test', 3).then(function (data) {
should.exist(data);
data.should.eql(object);
done();
}).catch(done);
});
it('will delete null values from object', function (done) {
var object = {test: [{id: 1, key: null}]};
apiUtils.checkObject(_.cloneDeep(object), 'test').then(function (data) {
should.not.exist(data.test[0].key);
should.exist(data.test[0].id);
done();
}).catch(done);
});
it('will not break if the expected object is a string', function (done) {
var object = {test: ['something']};
apiUtils.checkObject(_.cloneDeep(object), 'test').then(function (data) {
data.test[0].should.eql('something');
done();
}).catch(done);
});
describe('invalid post.authors structure', function () {
it('post.authors is not present', function (done) {
var object = {posts: [{id: 1}]};
apiUtils.checkObject(_.cloneDeep(object), 'posts')
.then(function () {
done();
})
.catch(done);
});
it('post.authors is no array', function (done) {
var object = {posts: [{id: 1, authors: null}]};
apiUtils.checkObject(_.cloneDeep(object), 'posts')
.then(function () {
"Test should fail".should.eql(false);
})
.catch(function (err) {
(err instanceof common.errors.BadRequestError).should.be.true;
err.message.should.eql('No valid object structure provided for: posts[*].authors');
done();
});
});
it('post.authors is empty', function (done) {
var object = {posts: [{id: 1, authors: []}]};
apiUtils.checkObject(_.cloneDeep(object), 'posts')
.then(function () {
done();
})
.catch(done);
});
it('post.authors contains id property', function (done) {
var object = {
posts: [{
id: 1,
authors: [{id: 'objectid', name: 'Kate'}, {id: 'objectid', name: 'Steffen'}]
}]
};
apiUtils.checkObject(_.cloneDeep(object), 'posts')
.then(function () {
done();
})
.catch(done);
});
it('post.authors does not contain id property', function (done) {
var object = {posts: [{id: 1, authors: [{id: 'objectid', name: 'Kate'}, {name: 'Steffen'}]}]};
apiUtils.checkObject(_.cloneDeep(object), 'posts')
.then(function () {
"Test should fail".should.eql(false);
})
.catch(function (err) {
(err instanceof common.errors.BadRequestError).should.be.true;
err.message.should.eql('No valid object structure provided for: posts[*].authors');
done();
});
});
});
});
describe('isPublicContext', function () {
it('should call out to permissions', function () {
var permsStub = sandbox.stub(permissions, 'parseContext').returns({public: true});
apiUtils.detectPublicContext({context: 'test'}).should.be.true();
permsStub.called.should.be.true();
permsStub.calledWith('test').should.be.true();
});
});
describe('applyPublicPermissions', function () {
it('should call out to permissions', function () {
var permsStub = sandbox.stub(permissions, 'applyPublicRules');
apiUtils.applyPublicPermissions('test', {});
permsStub.called.should.be.true();
permsStub.calledWith('test', {}).should.be.true();
});
});
describe('handlePublicPermissions', function () {
it('should return empty options if passed empty options', function (done) {
apiUtils.handlePublicPermissions('tests', 'test')({}).then(function (options) {
options.should.eql({context: {app: null, external: false, internal: false, public: true, user: null}});
done();
}).catch(done);
});
it('should treat no context as public', function (done) {
var aPPStub = sandbox.stub(apiUtils, 'applyPublicPermissions').returns(Promise.resolve({}));
apiUtils.handlePublicPermissions('tests', 'test')({}).then(function (options) {
aPPStub.calledOnce.should.eql(true);
options.should.eql({context: {app: null, external: false, internal: false, public: true, user: null}});
done();
}).catch(done);
});
it('should treat user context as NOT public', function (done) {
var cTMethodStub = {
test: {
test: sandbox.stub().returns(Promise.resolve())
}
},
cTStub = sandbox.stub(permissions, 'canThis').returns(cTMethodStub);
apiUtils.handlePublicPermissions('tests', 'test')({context: {user: 1}}).then(function (options) {
cTStub.calledOnce.should.eql(true);
cTMethodStub.test.test.calledOnce.should.eql(true);
options.should.eql({context: {app: null, external: false, internal: false, public: false, user: 1}});
done();
}).catch(done);
});
it('should throw a permissions error if permission is not granted', function (done) {
var cTMethodStub = {
test: {
test: sandbox.stub().returns(Promise.reject(new common.errors.NoPermissionError()))
}
},
cTStub = sandbox.stub(permissions, 'canThis').returns(cTMethodStub);
apiUtils.handlePublicPermissions('tests', 'test')({context: {user: 1}}).then(function () {
done(new Error('should throw error when no permissions'));
}).catch(function (err) {
cTStub.calledOnce.should.eql(true);
cTMethodStub.test.test.calledOnce.should.eql(true);
err.errorType.should.eql('NoPermissionError');
done();
});
});
});
describe('handlePermissions', function () {
it('should require a docName', function () {
apiUtils.handlePermissions.should.throwError();
});
it('should return a function', function () {
apiUtils.handlePermissions('test').should.be.a.Function();
});
it('should handle an unknown rejection', function (done) {
var testStub = sandbox.stub().returns(new Promise.reject(new Error('not found'))),
permsStub = sandbox.stub(permissions, 'canThis').callsFake(function () {
return {
testing: {
test: testStub
}
};
}),
permsFunc = apiUtils.handlePermissions('tests', 'testing');
permsFunc({})
.then(function () {
done(new Error('Should have thrown an error'));
})
.catch(function (err) {
permsStub.callCount.should.eql(1);
testStub.callCount.should.eql(1);
err.errorType.should.eql('InternalServerError');
done();
});
});
it('should handle a NoPermissions rejection', function (done) {
var testStub = sandbox.stub().returns(Promise.reject(new common.errors.NoPermissionError())),
permsStub = sandbox.stub(permissions, 'canThis').callsFake(function () {
return {
testing: {
test: testStub
}
};
}),
permsFunc = apiUtils.handlePermissions('tests', 'testing');
permsFunc({})
.then(function () {
done(new Error('Should have thrown an error'));
})
.catch(function (err) {
permsStub.callCount.should.eql(1);
testStub.callCount.should.eql(1);
err.errorType.should.eql('NoPermissionError');
err.message.should.match(/testing/);
err.message.should.match(/tests/);
done();
});
});
it('should handle success', function (done) {
var testStub = sandbox.stub().returns(new Promise.resolve()),
permsStub = sandbox.stub(permissions, 'canThis').callsFake(function () {
return {
testing: {
test: testStub
}
};
}),
permsFunc = apiUtils.handlePermissions('tests', 'testing'),
testObj = {foo: 'bar', id: 5};
permsFunc(testObj)
.then(function (res) {
permsStub.callCount.should.eql(1);
testStub.callCount.should.eql(1);
testStub.firstCall.args.length.should.eql(2);
testStub.firstCall.args[0].should.eql(5);
testStub.firstCall.args[1].should.eql({});
res.should.eql(testObj);
done();
})
.catch(done);
});
it('should ignore unsafe attrs if none are provided', function (done) {
var testStub = sandbox.stub().returns(new Promise.resolve()),
permsStub = sandbox.stub(permissions, 'canThis').callsFake(function () {
return {
testing: {
test: testStub
}
};
}),
permsFunc = apiUtils.handlePermissions('tests', 'testing', ['foo']),
testObj = {data: {tests: [{}]}, id: 5};
permsFunc(testObj)
.then(function (res) {
permsStub.callCount.should.eql(1);
testStub.callCount.should.eql(1);
testStub.firstCall.args.length.should.eql(2);
testStub.firstCall.args[0].should.eql(5);
testStub.firstCall.args[1].should.eql({});
res.should.eql(testObj);
done();
})
.catch(done);
});
it('should ignore unsafe attrs if they are provided but not present', function (done) {
var testStub = sandbox.stub().returns(new Promise.resolve()),
permsStub = sandbox.stub(permissions, 'canThis').callsFake(function () {
return {
testing: {
test: testStub
}
};
}),
permsFunc = apiUtils.handlePermissions('tests', 'testing', ['foo']),
testObj = {foo: 'bar', id: 5};
permsFunc(testObj)
.then(function (res) {
permsStub.callCount.should.eql(1);
testStub.callCount.should.eql(1);
testStub.firstCall.args.length.should.eql(2);
testStub.firstCall.args[0].should.eql(5);
testStub.firstCall.args[1].should.eql({});
res.should.eql(testObj);
done();
})
.catch(done);
});
it('should pass through unsafe attrs if they DO exist', function (done) {
var testStub = sandbox.stub().returns(new Promise.resolve()),
permsStub = sandbox.stub(permissions, 'canThis').callsFake(function () {
return {
testing: {
test: testStub
}
};
}),
permsFunc = apiUtils.handlePermissions('tests', 'testing', ['foo']),
testObj = {data: {tests: [{foo: 'bar'}]}, id: 5};
permsFunc(testObj)
.then(function (res) {
permsStub.callCount.should.eql(1);
testStub.callCount.should.eql(1);
testStub.firstCall.args.length.should.eql(2);
testStub.firstCall.args[0].should.eql(5);
testStub.firstCall.args[1].should.eql({foo: 'bar'});
res.should.eql(testObj);
done();
})
.catch(done);
});
it('should strip excludedAttrs from data if permissions function returns them', function () {
var testStub = sandbox.stub().resolves({excludedAttrs: ['foo']}),
permsStub = sandbox.stub(permissions, 'canThis').returns({
testing: {
test: testStub
}
}),
permsFunc = apiUtils.handlePermissions('tests', 'testing'),
testObj = {data: {tests: [{id: 5, name: 'testing', foo: 'bar'}]}, id: 5};
return permsFunc(testObj).then(function (res) {
permsStub.calledOnce.should.be.true();
testStub.calledOnce.should.be.true();
testStub.calledWithExactly(5, {}).should.be.true();
should(res).deepEqual({
data: {
tests: [{id: 5, name: 'testing'}]
},
id: 5
});
});
});
});
});