From 494de59789a64f75ab97acf36d5d0fa767e046fe Mon Sep 17 00:00:00 2001 From: Makinde Adeagbo Date: Tue, 7 Aug 2018 14:48:17 -0700 Subject: [PATCH] Creating a new testing structure (#13) * Creating a new testing structure This diff does a couple things: - It creates test file for every Model and Document method that we'll need to handle and test. These can be gradually filled in. - It breaks helpers.js functions into separate files. Each one of those now has a corresponding test file. In upcoming diffs, I'll move the existing tests into this new structure. * fixing lint issue --- __tests__/authIsDisabled.test.js | 3 + __tests__/cleanAuthLevels.test.js | 16 ++ __tests__/embedPermissions.test.js | 3 + __tests__/getAuthorizedFields.test.js | 3 + __tests__/getUpdatePaths.test.js | 3 + __tests__/hasPermission.test.js | 3 + __tests__/methods/Document.save.test.js | 3 + __tests__/methods/Document.update.test.js | 3 + __tests__/methods/Model.aggreate.test.js | 3 + __tests__/methods/Model.bulkWrite.test.js | 3 + __tests__/methods/Model.count.test.js | 3 + __tests__/methods/Model.create.test.js | 30 ++++ __tests__/methods/Model.deleteMany.test.js | 3 + __tests__/methods/Model.deleteOne.test.js | 3 + __tests__/methods/Model.distinct.test.js | 3 + __tests__/methods/Model.find.test.js | 3 + __tests__/methods/Model.findById.test.js | 3 + .../methods/Model.findByIdAndDelete.test.js | 3 + .../methods/Model.findByIdAndRemove.test.js | 3 + .../methods/Model.findByIdAndUpdate.test.js | 3 + __tests__/methods/Model.findOne.test.js | 3 + .../methods/Model.findOneAndDelete.test.js | 3 + .../methods/Model.findOneAndRemove.test.js | 3 + .../methods/Model.findOneAndUpdate.test.js | 3 + __tests__/methods/Model.geoSearch.test.js | 3 + __tests__/methods/Model.increment.test.js | 3 + __tests__/methods/Model.isertMany.test.js | 3 + __tests__/methods/Model.mapReduce.test.js | 3 + __tests__/methods/Model.remove.test.js | 30 ++++ __tests__/methods/Model.replaceOne.test.js | 3 + __tests__/methods/Model.update.test.js | 3 + __tests__/methods/Model.updateMany.test.js | 3 + __tests__/methods/Model.updateOne.test.js | 3 + __tests__/resolveAuthLevel.test.js | 3 + __tests__/restrictedMethods.test.js | 27 --- __tests__/sanitizeDocumentList.test.js | 3 + index.js | 19 +- lib/helpers.js | 170 ------------------ {lib => src}/IncompatibleMethodError.js | 0 {lib => src}/PermissionDeniedError.js | 0 src/authIsDisabled.js | 5 + src/cleanAuthLevels.js | 11 ++ src/embedPermissions.js | 15 ++ src/getAuthorizedFields.js | 14 ++ src/getUpdatePaths.js | 16 ++ src/hasPermission.js | 12 ++ src/resolveAuthLevel.js | 30 ++++ src/sanitizeDocumentList.js | 82 +++++++++ test/helpers.test.js | 10 +- test/index.test.js | 2 +- 50 files changed, 370 insertions(+), 215 deletions(-) create mode 100644 __tests__/authIsDisabled.test.js create mode 100644 __tests__/cleanAuthLevels.test.js create mode 100644 __tests__/embedPermissions.test.js create mode 100644 __tests__/getAuthorizedFields.test.js create mode 100644 __tests__/getUpdatePaths.test.js create mode 100644 __tests__/hasPermission.test.js create mode 100644 __tests__/methods/Document.save.test.js create mode 100644 __tests__/methods/Document.update.test.js create mode 100644 __tests__/methods/Model.aggreate.test.js create mode 100644 __tests__/methods/Model.bulkWrite.test.js create mode 100644 __tests__/methods/Model.count.test.js create mode 100644 __tests__/methods/Model.create.test.js create mode 100644 __tests__/methods/Model.deleteMany.test.js create mode 100644 __tests__/methods/Model.deleteOne.test.js create mode 100644 __tests__/methods/Model.distinct.test.js create mode 100644 __tests__/methods/Model.find.test.js create mode 100644 __tests__/methods/Model.findById.test.js create mode 100644 __tests__/methods/Model.findByIdAndDelete.test.js create mode 100644 __tests__/methods/Model.findByIdAndRemove.test.js create mode 100644 __tests__/methods/Model.findByIdAndUpdate.test.js create mode 100644 __tests__/methods/Model.findOne.test.js create mode 100644 __tests__/methods/Model.findOneAndDelete.test.js create mode 100644 __tests__/methods/Model.findOneAndRemove.test.js create mode 100644 __tests__/methods/Model.findOneAndUpdate.test.js create mode 100644 __tests__/methods/Model.geoSearch.test.js create mode 100644 __tests__/methods/Model.increment.test.js create mode 100644 __tests__/methods/Model.isertMany.test.js create mode 100644 __tests__/methods/Model.mapReduce.test.js create mode 100644 __tests__/methods/Model.remove.test.js create mode 100644 __tests__/methods/Model.replaceOne.test.js create mode 100644 __tests__/methods/Model.update.test.js create mode 100644 __tests__/methods/Model.updateMany.test.js create mode 100644 __tests__/methods/Model.updateOne.test.js create mode 100644 __tests__/resolveAuthLevel.test.js delete mode 100644 __tests__/restrictedMethods.test.js create mode 100644 __tests__/sanitizeDocumentList.test.js delete mode 100644 lib/helpers.js rename {lib => src}/IncompatibleMethodError.js (100%) rename {lib => src}/PermissionDeniedError.js (100%) create mode 100644 src/authIsDisabled.js create mode 100644 src/cleanAuthLevels.js create mode 100644 src/embedPermissions.js create mode 100644 src/getAuthorizedFields.js create mode 100644 src/getUpdatePaths.js create mode 100644 src/hasPermission.js create mode 100644 src/resolveAuthLevel.js create mode 100644 src/sanitizeDocumentList.js diff --git a/__tests__/authIsDisabled.test.js b/__tests__/authIsDisabled.test.js new file mode 100644 index 0000000..9895517 --- /dev/null +++ b/__tests__/authIsDisabled.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Create tests for authIsDisabled'); diff --git a/__tests__/cleanAuthLevels.test.js b/__tests__/cleanAuthLevels.test.js new file mode 100644 index 0000000..ea26175 --- /dev/null +++ b/__tests__/cleanAuthLevels.test.js @@ -0,0 +1,16 @@ +const test = require('ava'); +const mongoose = require('mongoose'); +// const cleanAuthLevels = require('../src/cleanAuthLevels'); + +test.before((t) => { + t.context.schema = new mongoose.Schema({ friend: String }); +}); + +test.todo('Schema passed in is not valid'); +test.todo('Falsey authLevel value'); +test.todo('Empty array authLevel value'); +test.todo('Remove duplicate entries'); +test.todo('Remove false entries'); +test.todo('Remove entries that are not in the permissions object'); +test.todo('authLevel with no issues'); + diff --git a/__tests__/embedPermissions.test.js b/__tests__/embedPermissions.test.js new file mode 100644 index 0000000..b35a49f --- /dev/null +++ b/__tests__/embedPermissions.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Migrate tests for embedPermissions'); diff --git a/__tests__/getAuthorizedFields.test.js b/__tests__/getAuthorizedFields.test.js new file mode 100644 index 0000000..811007e --- /dev/null +++ b/__tests__/getAuthorizedFields.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Migrate tests for getAuthorizedFields'); diff --git a/__tests__/getUpdatePaths.test.js b/__tests__/getUpdatePaths.test.js new file mode 100644 index 0000000..dfc52f0 --- /dev/null +++ b/__tests__/getUpdatePaths.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Migrate getUpdatePaths tests to this file'); diff --git a/__tests__/hasPermission.test.js b/__tests__/hasPermission.test.js new file mode 100644 index 0000000..3aeda0a --- /dev/null +++ b/__tests__/hasPermission.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Migrate hasPermission tests here'); diff --git a/__tests__/methods/Document.save.test.js b/__tests__/methods/Document.save.test.js new file mode 100644 index 0000000..1a06830 --- /dev/null +++ b/__tests__/methods/Document.save.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Document.save'); diff --git a/__tests__/methods/Document.update.test.js b/__tests__/methods/Document.update.test.js new file mode 100644 index 0000000..29ff0e9 --- /dev/null +++ b/__tests__/methods/Document.update.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Document.update'); diff --git a/__tests__/methods/Model.aggreate.test.js b/__tests__/methods/Model.aggreate.test.js new file mode 100644 index 0000000..7a0b121 --- /dev/null +++ b/__tests__/methods/Model.aggreate.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.aggregate. It should be a disabled method'); diff --git a/__tests__/methods/Model.bulkWrite.test.js b/__tests__/methods/Model.bulkWrite.test.js new file mode 100644 index 0000000..255ff13 --- /dev/null +++ b/__tests__/methods/Model.bulkWrite.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.bulkWrite. It should be disabled, does not support middleware'); diff --git a/__tests__/methods/Model.count.test.js b/__tests__/methods/Model.count.test.js new file mode 100644 index 0000000..a6ae46e --- /dev/null +++ b/__tests__/methods/Model.count.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.count'); diff --git a/__tests__/methods/Model.create.test.js b/__tests__/methods/Model.create.test.js new file mode 100644 index 0000000..874c8b5 --- /dev/null +++ b/__tests__/methods/Model.create.test.js @@ -0,0 +1,30 @@ +const test = require('ava'); +const mongoose = require('mongoose'); +const authz = require('../../'); +const IncompatibleMethodError = require('../../src/IncompatibleMethodError'); + +test.before(async () => { + await mongoose.connect('mongodb://localhost:27017/ModelCreateTests'); +}); + +test('Model.create should not be callable with plugin installed', (t) => { + const schema = new mongoose.Schema({ friend: String }); + schema.plugin(authz); + const MyModel = mongoose.model('ModelCreatePluggedIn', schema); + + t.throws( + () => MyModel.create({ friend: 'bar' }), + IncompatibleMethodError, + ); +}); + +test('Model.create should be callable without the plugin installed', async (t) => { + const schema = new mongoose.Schema({ friend: String }); + const MyModel = mongoose.model('ModelCreateWithoutPluggin', schema); + + await t.notThrows(MyModel.create({ friend: 'bar' })); +}); + +test.after.always(async () => { + await mongoose.disconnect(); +}); diff --git a/__tests__/methods/Model.deleteMany.test.js b/__tests__/methods/Model.deleteMany.test.js new file mode 100644 index 0000000..7855b78 --- /dev/null +++ b/__tests__/methods/Model.deleteMany.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.deleteMany'); diff --git a/__tests__/methods/Model.deleteOne.test.js b/__tests__/methods/Model.deleteOne.test.js new file mode 100644 index 0000000..6da4883 --- /dev/null +++ b/__tests__/methods/Model.deleteOne.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.deleteOne'); diff --git a/__tests__/methods/Model.distinct.test.js b/__tests__/methods/Model.distinct.test.js new file mode 100644 index 0000000..9367cc4 --- /dev/null +++ b/__tests__/methods/Model.distinct.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.distinct'); diff --git a/__tests__/methods/Model.find.test.js b/__tests__/methods/Model.find.test.js new file mode 100644 index 0000000..c4d09f5 --- /dev/null +++ b/__tests__/methods/Model.find.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.find'); diff --git a/__tests__/methods/Model.findById.test.js b/__tests__/methods/Model.findById.test.js new file mode 100644 index 0000000..504410c --- /dev/null +++ b/__tests__/methods/Model.findById.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.findById'); diff --git a/__tests__/methods/Model.findByIdAndDelete.test.js b/__tests__/methods/Model.findByIdAndDelete.test.js new file mode 100644 index 0000000..a68b283 --- /dev/null +++ b/__tests__/methods/Model.findByIdAndDelete.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.findByIdAndDelete'); diff --git a/__tests__/methods/Model.findByIdAndRemove.test.js b/__tests__/methods/Model.findByIdAndRemove.test.js new file mode 100644 index 0000000..9dc4a61 --- /dev/null +++ b/__tests__/methods/Model.findByIdAndRemove.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.findByIdAndRemove'); diff --git a/__tests__/methods/Model.findByIdAndUpdate.test.js b/__tests__/methods/Model.findByIdAndUpdate.test.js new file mode 100644 index 0000000..fe110b1 --- /dev/null +++ b/__tests__/methods/Model.findByIdAndUpdate.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.findByIdAndUpdate'); diff --git a/__tests__/methods/Model.findOne.test.js b/__tests__/methods/Model.findOne.test.js new file mode 100644 index 0000000..67c64dc --- /dev/null +++ b/__tests__/methods/Model.findOne.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.findOne'); diff --git a/__tests__/methods/Model.findOneAndDelete.test.js b/__tests__/methods/Model.findOneAndDelete.test.js new file mode 100644 index 0000000..dc66ad2 --- /dev/null +++ b/__tests__/methods/Model.findOneAndDelete.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.findOneAndDelete'); diff --git a/__tests__/methods/Model.findOneAndRemove.test.js b/__tests__/methods/Model.findOneAndRemove.test.js new file mode 100644 index 0000000..739c58f --- /dev/null +++ b/__tests__/methods/Model.findOneAndRemove.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.findOneAndRemove'); diff --git a/__tests__/methods/Model.findOneAndUpdate.test.js b/__tests__/methods/Model.findOneAndUpdate.test.js new file mode 100644 index 0000000..621c7c8 --- /dev/null +++ b/__tests__/methods/Model.findOneAndUpdate.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.findOneAndUpdate'); diff --git a/__tests__/methods/Model.geoSearch.test.js b/__tests__/methods/Model.geoSearch.test.js new file mode 100644 index 0000000..3282004 --- /dev/null +++ b/__tests__/methods/Model.geoSearch.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.geoSearch (probably disable)'); diff --git a/__tests__/methods/Model.increment.test.js b/__tests__/methods/Model.increment.test.js new file mode 100644 index 0000000..6812a5c --- /dev/null +++ b/__tests__/methods/Model.increment.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.increment'); diff --git a/__tests__/methods/Model.isertMany.test.js b/__tests__/methods/Model.isertMany.test.js new file mode 100644 index 0000000..9947c8d --- /dev/null +++ b/__tests__/methods/Model.isertMany.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.insertMany - disallow'); diff --git a/__tests__/methods/Model.mapReduce.test.js b/__tests__/methods/Model.mapReduce.test.js new file mode 100644 index 0000000..558ecd7 --- /dev/null +++ b/__tests__/methods/Model.mapReduce.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.mapReduce - disable'); diff --git a/__tests__/methods/Model.remove.test.js b/__tests__/methods/Model.remove.test.js new file mode 100644 index 0000000..5859c6d --- /dev/null +++ b/__tests__/methods/Model.remove.test.js @@ -0,0 +1,30 @@ +const test = require('ava'); +const mongoose = require('mongoose'); +const authz = require('../../'); +const IncompatibleMethodError = require('../../src/IncompatibleMethodError'); + +test.before(async () => { + await mongoose.connect('mongodb://localhost:27017/ModelRemoveTests'); +}); + +test('Model.remove should not be callable with plugin installed', (t) => { + const schema = new mongoose.Schema({ friend: String }); + schema.plugin(authz); + const MyModel = mongoose.model('ModelRemovePluggedIn', schema); + + t.throws( + () => MyModel.remove({ friend: 'bar' }).exec(), + IncompatibleMethodError, + ); +}); + +test('Model.remove should be callable without the plugin installed', async (t) => { + const schema = new mongoose.Schema({ friend: String }); + const MyModel = mongoose.model('ModelRemoveWithoutPlugin', schema); + + await t.notThrows(MyModel.remove({}).exec()); +}); + +test.after.always(async () => { + await mongoose.disconnect(); +}); diff --git a/__tests__/methods/Model.replaceOne.test.js b/__tests__/methods/Model.replaceOne.test.js new file mode 100644 index 0000000..9c288f3 --- /dev/null +++ b/__tests__/methods/Model.replaceOne.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.replaceOne'); diff --git a/__tests__/methods/Model.update.test.js b/__tests__/methods/Model.update.test.js new file mode 100644 index 0000000..d21e61d --- /dev/null +++ b/__tests__/methods/Model.update.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.update'); diff --git a/__tests__/methods/Model.updateMany.test.js b/__tests__/methods/Model.updateMany.test.js new file mode 100644 index 0000000..0724a0a --- /dev/null +++ b/__tests__/methods/Model.updateMany.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.updateMany'); diff --git a/__tests__/methods/Model.updateOne.test.js b/__tests__/methods/Model.updateOne.test.js new file mode 100644 index 0000000..3393828 --- /dev/null +++ b/__tests__/methods/Model.updateOne.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Write tests for Model.updateOne'); diff --git a/__tests__/resolveAuthLevel.test.js b/__tests__/resolveAuthLevel.test.js new file mode 100644 index 0000000..6bf8412 --- /dev/null +++ b/__tests__/resolveAuthLevel.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Migrate resolveAuthLevel tests to here'); diff --git a/__tests__/restrictedMethods.test.js b/__tests__/restrictedMethods.test.js deleted file mode 100644 index 96e2ace..0000000 --- a/__tests__/restrictedMethods.test.js +++ /dev/null @@ -1,27 +0,0 @@ -const test = require('ava'); -const mongoose = require('mongoose'); -const authz = require('../'); -const IncompatibleMethodError = require('../lib/IncompatibleMethodError'); - -test.before((t) => { - const schema = new mongoose.Schema({ friend: String }); - schema.plugin(authz); - t.context.MyModel = mongoose.model('MyModel', schema); -}); - -test('Model.create should not be callable', (t) => { - const { MyModel } = t.context; - t.throws( - () => MyModel.create({ friend: 'bar' }), - IncompatibleMethodError, - ); -}); - -test('Model.remove should not be callable', (t) => { - const { MyModel } = t.context; - t.throws( - () => MyModel.remove({}), - IncompatibleMethodError, - ); -}); - diff --git a/__tests__/sanitizeDocumentList.test.js b/__tests__/sanitizeDocumentList.test.js new file mode 100644 index 0000000..34ecefb --- /dev/null +++ b/__tests__/sanitizeDocumentList.test.js @@ -0,0 +1,3 @@ +const test = require('ava'); + +test.todo('Migrate SanitizeDocumentList tests to this file'); diff --git a/index.js b/index.js index 0db6bd3..ed0368a 100644 --- a/index.js +++ b/index.js @@ -1,15 +1,12 @@ const _ = require('lodash'); -const { - resolveAuthLevel, - getAuthorizedFields, - hasPermission, - authIsDisabled, - sanitizeDocumentList, - getUpdatePaths, -} = require('./lib/helpers'); - -const PermissionDeniedError = require('./lib/PermissionDeniedError'); -const IncompatibleMethodError = require('./lib/IncompatibleMethodError'); +const getAuthorizedFields = require('./src/getAuthorizedFields'); +const hasPermission = require('./src/hasPermission'); +const authIsDisabled = require('./src/authIsDisabled'); +const sanitizeDocumentList = require('./src/sanitizeDocumentList'); +const getUpdatePaths = require('./src/getUpdatePaths'); +const resolveAuthLevel = require('./src/resolveAuthLevel'); +const PermissionDeniedError = require('./src/PermissionDeniedError'); +const IncompatibleMethodError = require('./src/IncompatibleMethodError'); module.exports = (schema) => { async function save(doc, options) { diff --git a/lib/helpers.js b/lib/helpers.js deleted file mode 100644 index 436c094..0000000 --- a/lib/helpers.js +++ /dev/null @@ -1,170 +0,0 @@ -const _ = require('lodash'); - -// TODO add tests -function cleanAuthLevels(schema, authLevels) { - const perms = schema.permissions || {}; - - return _.chain(authLevels) - .castArray() - .filter(level => !!perms[level]) // make sure the level in the permissions dict - .uniq() // get rid of fields mentioned in multiple levels - .value(); -} - -async function resolveAuthLevel(schema, options, doc) { - // Look into options the options and try to find authLevels. Always prefer to take - // authLevels from the direct authLevel option as opposed to the computed - // ones from getAuthLevel in the schema object. - let authLevels = []; - if (options) { - if (options.authLevel) { - authLevels = _.castArray(options.authLevel); - } else if (typeof schema.getAuthLevel === 'function') { - if (!options.authPayload) { - throw new Error('An `authPayload` must exist with a `getAuthLevel` method.'); - } - if (!doc) { - throw new Error('This type of query is not compatible with using a getAuthLevel method.'); - } - authLevels = _.castArray(await schema.getAuthLevel(options.authPayload, doc)); - } - } - - // Add `defaults` to the list of levels since you should always be able to do what's specified - // in defaults. - authLevels.push('defaults'); - - return cleanAuthLevels(schema, authLevels); -} - -function getAuthorizedFields(schema, authLevels, action) { - const cleanedLevels = cleanAuthLevels(schema, authLevels); - - return _.chain(cleanedLevels) - .flatMap(level => schema.permissions[level][action]) - .filter(path => schema.pathType(path) !== 'adhocOrUndefined') // ensure fields are in schema - .uniq() // dropping duplicates - .value(); -} - -function hasPermission(schema, authLevels, action) { - const perms = schema.permissions || {}; - - // look for any permissions setting for this action that is set to true (for these authLevels) - const cleanedLevels = cleanAuthLevels(schema, authLevels); - return _.some(cleanedLevels, level => perms[level][action]); -} - -function authIsDisabled(options) { - return options && options.authLevel === false; -} - -function embedPermissions(schema, options, authLevels, doc) { - if (!options || !options.permissions) { return; } - - const permsKey = options.permissions === true ? 'permissions' : options.permissions; - doc[permsKey] = { - read: getAuthorizedFields(schema, authLevels, 'read'), - write: getAuthorizedFields(schema, authLevels, 'write'), - remove: hasPermission(schema, authLevels, 'remove'), - }; -} - -async function sanitizeDocument(schema, options, doc) { - if (!doc) { return; } - - // If there are subschemas that need to be authorized, store a reference to the top - // level doc so they have context when doing their authorization checks - const optionAddition = {}; - if (!options.originalDoc && !_.isEmpty(schema.pathsWithPermissionedSchemas)) { - optionAddition.authPayload = { originalDoc: doc }; - } - const docOptions = _.merge({}, options, optionAddition); - - - const authLevels = await resolveAuthLevel(schema, docOptions, doc); - const authorizedFields = getAuthorizedFields(schema, authLevels, 'read'); - - // Check to see if group has the permission to see the fields that came back. - // We must edit the document in place to maintain the right reference - // Also, we use `_.pick` to make sure that we can handle paths that are deep - // reference to nested objects, like `nested.subpath`. - - // `doc._doc` contains the plain JS object with all the data we care about if `doc` is a - // Mongoose Document. - const innerDoc = doc._doc || doc; - const newDoc = _.pick(innerDoc, authorizedFields); - - // Empty out the object so we can put in other the paths that were `_.pick`ed - // Then copy back only the info the user is allowed to see - Object.keys(innerDoc).forEach((pathName) => { - delete innerDoc[pathName]; - }); - Object.assign(innerDoc, newDoc); - - // Special work. Wipe out the getter for the virtuals that have been set on the - // schema that are not authorized to come back - Object.keys(schema.virtuals).forEach((pathName) => { - if (!_.includes(authorizedFields, pathName)) { - // These virtuals are set with `Object.defineProperty`. You cannot overwrite them - // by directly setting the value to undefined, or by deleting the key in the - // document. This is potentially slow with lots of virtuals - Object.defineProperty(doc, pathName, { - value: undefined, - }); - } - }); - - // Check to see if we're going to be inserting the permissions info - embedPermissions(schema, docOptions, authLevels, doc); - - // Apply the rules down one level if there are any path specific permissions - const subDocSanitationPromises = _.map( - schema.pathsWithPermissionedSchemas, - async (path, subSchema) => { - if (innerDoc[path]) { - // eslint-disable-next-line no-use-before-define - innerDoc[path] = await sanitizeDocumentList(subSchema, docOptions, innerDoc[path]); - } - }, - ); - - await Promise.all(subDocSanitationPromises); -} - -async function sanitizeDocumentList(schema, options, docs) { - const multi = _.isArrayLike(docs); - - if (!multi) { - sanitizeDocument(schema, options, docs); - return; - } - - const sanitizationPromises = docs.map(doc => sanitizeDocument(schema, options, doc)); - await Promise.all(sanitizationPromises); - - _.remove(docs, doc => _.isEmpty(doc._doc || doc)); -} - -function getUpdatePaths(updates) { - // query._update is sometimes in the form of `{ $set: { foo: 1 } }`, where the top level - // is atomic operations. See: http://mongoosejs.com/docs/api.html#query_Query-update - // For findOneAndUpdate, the top level may be the fields that we want to examine. - return _.flatMap(updates, (val, key) => { - if (_.startsWith(key, '$')) { - return Object.keys(val); - } - - return key; - }); -} - -module.exports = { - resolveAuthLevel, - getAuthorizedFields, - hasPermission, - authIsDisabled, - embedPermissions, - sanitizeDocumentList, - getUpdatePaths, -}; diff --git a/lib/IncompatibleMethodError.js b/src/IncompatibleMethodError.js similarity index 100% rename from lib/IncompatibleMethodError.js rename to src/IncompatibleMethodError.js diff --git a/lib/PermissionDeniedError.js b/src/PermissionDeniedError.js similarity index 100% rename from lib/PermissionDeniedError.js rename to src/PermissionDeniedError.js diff --git a/src/authIsDisabled.js b/src/authIsDisabled.js new file mode 100644 index 0000000..39d1ba8 --- /dev/null +++ b/src/authIsDisabled.js @@ -0,0 +1,5 @@ +function authIsDisabled(options) { + return options && options.authLevel === false; +} + +module.exports = authIsDisabled; diff --git a/src/cleanAuthLevels.js b/src/cleanAuthLevels.js new file mode 100644 index 0000000..668c2c6 --- /dev/null +++ b/src/cleanAuthLevels.js @@ -0,0 +1,11 @@ +const _ = require('lodash'); + +module.exports = function cleanAuthLevels(schema, authLevels) { + const perms = (schema && schema.permissions) || {}; + + return _.chain(authLevels) + .castArray() + .filter(level => !!perms[level]) // make sure the level in the permissions dict + .uniq() // get rid of fields mentioned in multiple levels + .value(); +}; diff --git a/src/embedPermissions.js b/src/embedPermissions.js new file mode 100644 index 0000000..565e70c --- /dev/null +++ b/src/embedPermissions.js @@ -0,0 +1,15 @@ +const getAuthorizedFields = require('./getAuthorizedFields'); +const hasPermission = require('./hasPermission'); + +function embedPermissions(schema, options, authLevels, doc) { + if (!options || !options.permissions) { return; } + + const permsKey = options.permissions === true ? 'permissions' : options.permissions; + doc[permsKey] = { + read: getAuthorizedFields(schema, authLevels, 'read'), + write: getAuthorizedFields(schema, authLevels, 'write'), + remove: hasPermission(schema, authLevels, 'remove'), + }; +} + +module.exports = embedPermissions; diff --git a/src/getAuthorizedFields.js b/src/getAuthorizedFields.js new file mode 100644 index 0000000..2312fc1 --- /dev/null +++ b/src/getAuthorizedFields.js @@ -0,0 +1,14 @@ +const _ = require('lodash'); +const cleanAuthLevels = require('./cleanAuthLevels'); + +function getAuthorizedFields(schema, authLevels, action) { + const cleanedLevels = cleanAuthLevels(schema, authLevels); + + return _.chain(cleanedLevels) + .flatMap(level => schema.permissions[level][action]) + .filter(path => schema.pathType(path) !== 'adhocOrUndefined') // ensure fields are in schema + .uniq() // dropping duplicates + .value(); +} + +module.exports = getAuthorizedFields; diff --git a/src/getUpdatePaths.js b/src/getUpdatePaths.js new file mode 100644 index 0000000..63a225b --- /dev/null +++ b/src/getUpdatePaths.js @@ -0,0 +1,16 @@ +const _ = require('lodash'); + +function getUpdatePaths(updates) { + // query._update is sometimes in the form of `{ $set: { foo: 1 } }`, where the top level + // is atomic operations. See: http://mongoosejs.com/docs/api.html#query_Query-update + // For findOneAndUpdate, the top level may be the fields that we want to examine. + return _.flatMap(updates, (val, key) => { + if (_.startsWith(key, '$')) { + return Object.keys(val); + } + + return key; + }); +} + +module.exports = getUpdatePaths; diff --git a/src/hasPermission.js b/src/hasPermission.js new file mode 100644 index 0000000..28c65af --- /dev/null +++ b/src/hasPermission.js @@ -0,0 +1,12 @@ +const _ = require('lodash'); +const cleanAuthLevels = require('./cleanAuthLevels'); + +function hasPermission(schema, authLevels, action) { + const perms = schema.permissions || {}; + + // look for any permissions setting for this action that is set to true (for these authLevels) + const cleanedLevels = cleanAuthLevels(schema, authLevels); + return _.some(cleanedLevels, level => perms[level][action]); +} + +module.exports = hasPermission; diff --git a/src/resolveAuthLevel.js b/src/resolveAuthLevel.js new file mode 100644 index 0000000..2ddb904 --- /dev/null +++ b/src/resolveAuthLevel.js @@ -0,0 +1,30 @@ +const _ = require('lodash'); +const cleanAuthLevels = require('./cleanAuthLevels'); + +async function resolveAuthLevel(schema, options, doc) { + // Look into options the options and try to find authLevels. Always prefer to take + // authLevels from the direct authLevel option as opposed to the computed + // ones from getAuthLevel in the schema object. + let authLevels = []; + if (options) { + if (options.authLevel) { + authLevels = _.castArray(options.authLevel); + } else if (typeof schema.getAuthLevel === 'function') { + if (!options.authPayload) { + throw new Error('An `authPayload` must exist with a `getAuthLevel` method.'); + } + if (!doc) { + throw new Error('This type of query is not compatible with using a getAuthLevel method.'); + } + authLevels = _.castArray(await schema.getAuthLevel(options.authPayload, doc)); + } + } + + // Add `defaults` to the list of levels since you should always be able to do what's specified + // in defaults. + authLevels.push('defaults'); + + return cleanAuthLevels(schema, authLevels); +} + +module.exports = resolveAuthLevel; diff --git a/src/sanitizeDocumentList.js b/src/sanitizeDocumentList.js new file mode 100644 index 0000000..809c1de --- /dev/null +++ b/src/sanitizeDocumentList.js @@ -0,0 +1,82 @@ +const _ = require('lodash'); +const resolveAuthLevel = require('./resolveAuthLevel'); +const getAuthorizedFields = require('./getAuthorizedFields'); +const embedPermissions = require('./embedPermissions'); + +async function sanitizeDocument(schema, options, doc) { + if (!doc) { return; } + + // If there are subschemas that need to be authorized, store a reference to the top + // level doc so they have context when doing their authorization checks + const optionAddition = {}; + if (!options.originalDoc && !_.isEmpty(schema.pathsWithPermissionedSchemas)) { + optionAddition.authPayload = { originalDoc: doc }; + } + const docOptions = _.merge({}, options, optionAddition); + + + const authLevels = await resolveAuthLevel(schema, docOptions, doc); + const authorizedFields = getAuthorizedFields(schema, authLevels, 'read'); + + // Check to see if group has the permission to see the fields that came back. + // We must edit the document in place to maintain the right reference + // Also, we use `_.pick` to make sure that we can handle paths that are deep + // reference to nested objects, like `nested.subpath`. + + // `doc._doc` contains the plain JS object with all the data we care about if `doc` is a + // Mongoose Document. + const innerDoc = doc._doc || doc; + const newDoc = _.pick(innerDoc, authorizedFields); + + // Empty out the object so we can put in other the paths that were `_.pick`ed + // Then copy back only the info the user is allowed to see + Object.keys(innerDoc).forEach((pathName) => { + delete innerDoc[pathName]; + }); + Object.assign(innerDoc, newDoc); + + // Special work. Wipe out the getter for the virtuals that have been set on the + // schema that are not authorized to come back + Object.keys(schema.virtuals).forEach((pathName) => { + if (!_.includes(authorizedFields, pathName)) { + // These virtuals are set with `Object.defineProperty`. You cannot overwrite them + // by directly setting the value to undefined, or by deleting the key in the + // document. This is potentially slow with lots of virtuals + Object.defineProperty(doc, pathName, { + value: undefined, + }); + } + }); + + // Check to see if we're going to be inserting the permissions info + embedPermissions(schema, docOptions, authLevels, doc); + + // Apply the rules down one level if there are any path specific permissions + const subDocSanitationPromises = _.map( + schema.pathsWithPermissionedSchemas, + async (path, subSchema) => { + if (innerDoc[path]) { + // eslint-disable-next-line no-use-before-define + innerDoc[path] = await sanitizeDocumentList(subSchema, docOptions, innerDoc[path]); + } + }, + ); + + await Promise.all(subDocSanitationPromises); +} + +async function sanitizeDocumentList(schema, options, docs) { + const multi = _.isArrayLike(docs); + + if (!multi) { + sanitizeDocument(schema, options, docs); + return; + } + + const sanitizationPromises = docs.map(doc => sanitizeDocument(schema, options, doc)); + await Promise.all(sanitizationPromises); + + _.remove(docs, doc => _.isEmpty(doc._doc || doc)); +} + +module.exports = sanitizeDocumentList; diff --git a/test/helpers.test.js b/test/helpers.test.js index 8c929d9..2cb698a 100644 --- a/test/helpers.test.js +++ b/test/helpers.test.js @@ -1,11 +1,9 @@ const mongoose = require('mongoose'); -const { - resolveAuthLevel, - getAuthorizedFields, - hasPermission, - getUpdatePaths, -} = require('../lib/helpers'); +const resolveAuthLevel = require('../src/resolveAuthLevel'); +const getAuthorizedFields = require('../src/getAuthorizedFields'); +const hasPermission = require('../src/hasPermission'); +const getUpdatePaths = require('../src/getUpdatePaths'); // Set up a bunch of schemas for testing. We're not going to connect to the database // since these tests only depend on the schema definitions. diff --git a/test/index.test.js b/test/index.test.js index 83bf73f..d1165de 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,7 +1,7 @@ const mongoose = require('mongoose'); const User = require('./user.schema'); const Car = require('./car.schema'); -const PermissionDeniedError = require('../lib/PermissionDeniedError'); +const PermissionDeniedError = require('../src/PermissionDeniedError'); const dbUri = 'mongodb://localhost:27017/mongooseAuthorization';