Ghost/core/test/unit/api/utils_spec.js
Hannah Wolfe b468d6dbe2 Support for attribute-based permissions (#9025)
refs #8602

- Add the wiring to pass attributes around the permission system
- Allows us to get access to the important "unsafe" attributes that are changing
- E.g. status for posts
- This can then be used to determine whether a user has permission to perform an attribute-based action
- E.g. publish a post (change status)
2017-09-26 18:06:14 +02:00

703 lines
28 KiB
JavaScript

var should = require('should'),
sinon = require('sinon'),
_ = require('lodash'),
Promise = require('bluebird'),
ObjectId = require('bson-objectid'),
permissions = require('../../../server/permissions'),
errors = require('../../../server/errors'),
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',
'checkFileExists',
'checkFileIsValid'
]);
});
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.should.have.enumerable('0').with.property('errorType', '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('include');
actualResult.include.should.be.an.Array();
actualResult.include.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('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('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('checkFileExists', function () {
it('should return true if file exists in input', function () {
apiUtils.checkFileExists({mimetype: 'file', path: 'path'}).should.be.true();
});
it('should return false if file does not exist in input', function () {
apiUtils.checkFileExists({}).should.be.false();
});
it('should return false if file is incorrectly structured', function () {
apiUtils.checkFileExists({type: 'file'}).should.be.false();
});
});
describe('checkFileIsValid', function () {
it('returns true if file has valid extension and type', function () {
apiUtils.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['text'], ['.txt']).should.be.true();
apiUtils.checkFileIsValid({
name: 'test.jpg',
mimetype: 'jpeg'
}, ['text', 'jpeg'], ['.txt', '.jpg']).should.be.true();
});
it('returns false if file has invalid extension', function () {
apiUtils.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['text'], ['.tar']).should.be.false();
apiUtils.checkFileIsValid({name: 'test', mimetype: 'text'}, ['text'], ['.txt']).should.be.false();
});
it('returns false if file has invalid type', function () {
apiUtils.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['archive'], ['.txt']).should.be.false();
});
});
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 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()),
permsStub = sandbox.stub(permissions, 'canThis', 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(new Promise.reject(
new errors.NoPermissionError()
)),
permsStub = sandbox.stub(permissions, 'canThis', 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', 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', 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', 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', 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);
});
});
});