diff --git a/.gitignore b/.gitignore index ad46b30..a064656 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ typings/ # next.js build output .next + +#package-lock.json +package-lock.json \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6d79142 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ + +language: node_js +node_js: + - "10" + - "11" + - "12" +cache: + directories: + - node_modules diff --git a/README.md b/README.md index 8f29d0f..8d0f9b4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,145 @@ -# simple-express-authorization +# Simple Express Authorization - Scopes based authorization middleware. Ideal for app express or derivative such as express-gateway + +[![node](https://img.shields.io/badge/node-v10.16.2-red.svg?style=?style=flat-square&logo=node.js)](https://nodejs.org/) +[![npm](https://img.shields.io/badge/npm-v6.10.3-red.svg?style=flat-square&logo=npm)](https://nodejs.org/) +[![mocha](https://img.shields.io/badge/mocha-v6.2.0-brown.svg?style=flat-square&logo=mocha)](https://www.npmjs.com/package/mocha) +[![chai](https://img.shields.io/badge/chai-v4.2.0-orange.svg?style=flat-square&logo=chai)](https://www.npmjs.com/package/chai) +[![supertest](https://img.shields.io/badge/supertest-v4.2.0-green.svg?style=flat-square&logo=supertest)](https://www.npmjs.com/package/supertest) +[![nyc](https://img.shields.io/badge/nyc-v14.1.1-blue.svg?style=flat-square&logo=nyc)](https://www.npmjs.com/package/nyc) +[![Coverage Status](https://coveralls.io/repos/github/adalcinojunior/simple-express-authorization/badge.svg?branch=develop)](https://coveralls.io/github/adalcinojunior/simple-express-authorization?branch=develop) +[![Build Status](https://travis-ci.com/adalcinojunior/simple-express-authorization.svg?branch=develop)](https://travis-ci.com/adalcinojunior/simple-express-authorization.svg?branch=develop) + + +## Installation + + $ npm i simple-express-authorization + +## Usage the simple-express-authorization +### When there is a single setting +```javascript +const app = require('express') +const guard = require('simple-express-authorization') + +const settings = { + responseCaseError: { + code: 403, + message: "FORBIDDEN", + description: "Authorization failed due to insufficient permissions.", + redirect_link: "/auth" + }, + logicalStrategy: 'AND', + flowStrategy: "NEXTWITHERROR" +}; + +guard.config(settings) + +app.get('/users', guard.check(['users:read', 'users:readAll']), () => { + return []; +})) + +app.get('/users/:userId', guard.check(['users:read']), () => { + return {}; +})) +... +``` +### When there are local settings +```javascript +const app = require('express') +const guard = require('simple-express-authorization') + +const settingsGetAll = { + responseCaseError: { + code: 403, + message: "FORBIDDEN", + description: "Authorization failed due to insufficient permissions.", + redirect_link: "/auth" + }, + logicalStrategy: 'AND', + flowStrategy: "NEXTWITHERROR" +}; + +const settingsGet = { + responseCaseError: { + code: 403, + message: "FORBIDDEN", + description: "Authorization failed due to insufficient permissions.", + redirect_link: "/auth" + }, + logicalStrategy: 'AND', + flowStrategy: "RETURNRESPONSE" +}; + +guard.config(options) + +app.get('/users', guard.check(['users:read', 'users:readAll'],settingsGetAll), () => { + return []; +})) + +app.get('/users/:userId', guard.check(['users:read'],settingsGet), () => { + return {}; +})) +... +``` +### Possibles settings +```javascript +settings = { + /** Specific where we find user scopes + * By default we use -> req.user.scope + * Observation: + * - userScopesLocation is a string + * - req.user.scope is expected to be of type Array. + * + * When informed "a.b.c" we use -> req['a']['b']['c'] + */ + userScopesLocation: "DEFAULT", + + /** Specifies the logical strategy used to evaluate user scopes + * By default we use -> OR + * Observation: + * - logicalStrategy is a string + * - We currently only support "OR" and "AND". + */ + logicalStrategy: "OR", + + /** Specifies the return object if the user does not have the expected scopes. + * responseCaseError is the content returned in the response body when flowStrategy + * is not modified, or when it is set to the default value "RETURNRESPONSE" + */ + responseCaseError: { + code: 403, + message: "FORBIDDEN", + description: "Authorization failed due to insufficient permissions.", + redirect_link: "/auth" + }, + + /** Specifies the flow strategy used when the user does not have the expected scopes + * By default we use -> RETURNRESPONSE + * Observation: + * - flowStrategy is a string + * - "RETURNRESPONSE"-> When the user does not have the required scopes, + * the object responseCaseError is returned. + * - "NEXTWITHERROR"-> When the user does not have the required scopes, + * the next() function is called passing the responseCaseError object. + * - We currently only support "RETURNRESPONSE" and "NEXTWITHERROR". + */ + flowStrategy: "RETURNRESPONSE" +} +``` + +## Running tests + +### Unitary tests + +Run `npm run test:unit` to execute the unit tests. + +### Integration tests + +Run `npm run test:integration` to execute the integration tests. + +### Coverage tests + +Run `npm run test:coverage` to execute the coverage tests. + + diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..a1f0f81 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,21 @@ +declare var simpleAuthorization: simpleAuthorization.SimpleAuthorization +export = simpleAuthorization + +declare namespace simpleAuthorization { + + export interface SimpleAuthorization { + (options?: IOptions): any + + config(options?: IOptions): any + + check(expectedScopes: Array, options?: IOptions): any + + } + + export interface IOptions { + userScopesLocation?: string + logicalStrategy?: string + responseCaseError?: object + flowStrategy?: string + } +} \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..0281279 --- /dev/null +++ b/index.js @@ -0,0 +1,11 @@ +'use strict' + +const simpleAuthorization = require('./lib/simple.authorization') + +module.exports.config = function (configurations) { + return simpleAuthorization.config(configurations) +} + +module.exports.check = function (expectedScopes, localConfigurations) { + return simpleAuthorization.check(expectedScopes, localConfigurations) +} \ No newline at end of file diff --git a/lib/config/default.js b/lib/config/default.js new file mode 100644 index 0000000..cba5a9b --- /dev/null +++ b/lib/config/default.js @@ -0,0 +1,46 @@ +const USERSCOPESLOCATION = require('./enums').USERSCOPESLOCATION +const LOGICALSTRATEGY = require('./enums').LOGICALSTRATEGY +const FLOWSTRATEGY = require('./enums').FLOWSTRATEGY +/** + * File with default settings + */ +module.exports = { + /** + * By default we use -> req.user.scope + * Observation: + * - userScopesLocation is a string + * - req.user.scope is expected to be of type Array. + * + * When informed "a.b.c" we use -> req['a']['b']['c'] + */ + userScopesLocation: USERSCOPESLOCATION.DEFAULT, + + /** + * By default we use -> OR + * Observation: + * - logicalStrategy is a string + * - We currently only support "OR" and "AND". + */ + logicalStrategy: LOGICALSTRATEGY.OR, + + /** + * responseCaseError is the content returned in the response body when flowStrategy is not modified, + * or when it is set to the default value "RETURNRESPONSE" + */ + responseCaseError: { + code: 403, + message: "FORBIDDEN", + description: "Authorization failed due to insufficient permissions.", + redirect_link: "/auth" + }, + + /** + * By default we use -> RETURNRESPONSE + * Observation: + * - flowStrategy is a string + * - "RETURNRESPONSE"-> When the user does not have the required scopes, the object responseCaseError is returned. + * - "NEXTWITHERROR"-> When the user does not have the required scopes, the next() function is called passing the responseCaseError object. + * - We currently only support "RETURNRESPONSE" and "NEXTWITHERROR". + */ + flowStrategy: FLOWSTRATEGY.RETURNRESPONSE +} diff --git a/lib/config/enums.js b/lib/config/enums.js new file mode 100644 index 0000000..8f0acb3 --- /dev/null +++ b/lib/config/enums.js @@ -0,0 +1,13 @@ +module.exports.USERSCOPESLOCATION = { + DEFAULT: "DEFAULT" +} + +module.exports.LOGICALSTRATEGY = { + AND: "AND", + OR: "OR", +} + +module.exports.FLOWSTRATEGY = { + RETURNRESPONSE: "RETURNRESPONSE", + NEXTWITHERROR: "NEXTWITHERROR", +} \ No newline at end of file diff --git a/lib/property.extractor.js b/lib/property.extractor.js new file mode 100644 index 0000000..0b4427a --- /dev/null +++ b/lib/property.extractor.js @@ -0,0 +1,29 @@ +'use strict' + +/** + * Function to extract the value of a property of an object from a string with its properties. + * Example: + * - object={req:{user:{scope:['scope1', 'scope2']}}} + * - str='req.user.scope' + * The expected return when applying the function is ['scope1', 'scope2'] * + * @param {*} object + * @param {*} str + */ +module.exports = (object, str) => { + if (typeof str !== 'string') { + const msgError = `The userScopeLocation property must be of type string. Check the settings passed to the simple-express-jwt-authorization middleware.`; + throw Error(msgError); + } + const propeties = str.split('.') + let property = object; + propeties.forEach((key) => { + if (property === undefined || property === null) { + console.warn(new Date().toISOString() + ' warn [simple-express-jwt-authorization] The property set on userScopeLocation returned undefined!'); + return undefined; + } + if (!key) return property; + property = property[key]; + }); + if (property === undefined || property === null) console.warn(new Date().toISOString() + ' warn [simple-express-jwt-authorization] The property set on userScopeLocation returned undefined!'); + return property; +} diff --git a/lib/simple.authorization.js b/lib/simple.authorization.js new file mode 100644 index 0000000..dd1ff30 --- /dev/null +++ b/lib/simple.authorization.js @@ -0,0 +1,63 @@ +'use strict' + +const USERSCOPESLOCATION = require('./config/enums').USERSCOPESLOCATION +const LOGICALSTRATEGY = require('./config/enums').LOGICALSTRATEGY +const FLOWSTRATEGY = require('./config/enums').FLOWSTRATEGY +const configurationsDefault = require('./config/default') +const validator = require('./validator') +const propertyExtract = require('./property.extractor') + +let configurations = configurationsDefault + +const config = (localConfigurations) => { + configurations = validator(localConfigurations) +} + +const check = (expectedScopes, localConfigurations) => { + if (localConfigurations) { + configurations = validator(localConfigurations) + } + return (req, res, next) => { + if (!expectedScopes) { + return next() + } + if (Array.isArray(expectedScopes) && expectedScopes.length === 0) { + return next() + } + if (!Array.isArray(expectedScopes)) { + throw new Error('Expected scopes must be passed in the form of a array, verify the check() function!') + } + + let userScopes = [] + if (configurations.userScopesLocation === USERSCOPESLOCATION.DEFAULT) { + if (!req.user || !req.user.scope) { + throw new Error('You are using the default userScopeLocation, but req.user.scope is undefined.') + } + userScopes = req.user.scope + } + else { + userScopes = propertyExtract(req, configurations.userScopesLocation) + } + + let accepted = false; + + if (configurations.logicalStrategy === LOGICALSTRATEGY.AND) { + accepted = expectedScopes.every(scope => userScopes.includes(scope)) + } else { + accepted = expectedScopes.some(scope => userScopes.includes(scope)) + } + + if (!accepted) { + if (configurations.flowStrategy === FLOWSTRATEGY.NEXTWITHERROR) { + return next(configurations.responseCaseError) + } + return res.status(403).send(configurations.responseCaseError) + } + return next() + } +} + +module.exports = { + config, + check +} \ No newline at end of file diff --git a/lib/validator.js b/lib/validator.js new file mode 100644 index 0000000..bc0cb46 --- /dev/null +++ b/lib/validator.js @@ -0,0 +1,74 @@ +'use strict' + +const LOGICALSTRATEGY = require('./config/enums').LOGICALSTRATEGY +const FLOWSTRATEGY = require('./config/enums').FLOWSTRATEGY +const optionsDefault = require('./config/default') +/** + * Function to validate configured parameters + * @param {*} configOptions + */ +module.exports = (configOptions) => { + /** + * If no settings are entered, the default settings are used. + */ + if (!configOptions) return optionsDefault + + /** + * Passing strings to uppercase + */ + if (configOptions.logicalStrategy && typeof configOptions.logicalStrategy === 'string') { + configOptions.logicalStrategy = configOptions.logicalStrategy.toUpperCase() + } + + if (configOptions.flowStrategy && typeof configOptions.flowStrategy === 'string') { + configOptions.flowStrategy = configOptions.flowStrategy.toUpperCase() + } + + + const optionsReturn = Object.assign({}, configOptions) + + + /** + * If the userScopesLocation property has not been entered, the default userScopesLocation is used. + */ + if (!configOptions.userScopesLocation || typeof configOptions.userScopesLocation !== 'string') { + optionsReturn.userScopesLocation = optionsDefault.userScopesLocation + } + + /** + * If the logicalStrategy property has not been entered, the default logicalStrategy is used. + + if (!configOptions.logicalStrategy || typeof configOptions.logicalStrategy !== 'string') { + optionsReturn.logicalStrategy = optionsDefault.logicalStrategy + } + */ + + /** + * If the responseCaseError property has not been entered, the default responseCaseError is used. + */ + if (!configOptions.responseCaseError) optionsReturn.responseCaseError = optionsDefault.responseCaseError + + /** + * If the flowStrategy property has not been entered, the default flowStrategy is used. + + if (!configOptions.flowStrategy || typeof configOptions.flowStrategy !== 'string') { + optionsReturn.flowStrategy = optionsDefault.flowStrategy + } + */ + + /** + * If the logicalStrategy property is not AND or is not OR, the default logicalStrategy is used. + */ + if (!Object.keys(LOGICALSTRATEGY).includes(configOptions.logicalStrategy)) { + optionsReturn.logicalStrategy = optionsDefault.logicalStrategy + } + + /** + * If the flowStrategy property is not RETURNRESPONSE or is not NEXTWITHERROR, the default flowStrategy is used. + */ + if (!Object.keys(FLOWSTRATEGY).includes(configOptions.flowStrategy)) { + optionsReturn.flowStrategy = optionsDefault.flowStrategy + } + + return optionsReturn +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1ab0c1c --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "simple-express-authorization", + "version": "1.0.0", + "description": "Scopes based authorization middleware for express applications.", + "main": "index.js", + "scripts": { + "test": "mocha --opts test/mocha.opts test/**/*.spec.js", + "test:unit": "mocha --opts test/mocha.opts test/unit/*.spec.js", + "test:integration": "mocha --opts test/mocha.opts test/integration/*.spec.js", + "test:coverage": "nyc --clean --all --reporter=html --reporter=text mocha --opts test/mocha.opts test/**/*.spec.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/adalcinojunior/simple-express-authorization.git" + }, + "keywords": [ + "express", + "authorization" + ], + "author": "Adalcino Junior ", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0.0" + }, + "bugs": { + "url": "https://github.com/adalcinojunior/simple-express-authorization/issues" + }, + "homepage": "https://github.com/adalcinojunior/simple-express-authorization#readme", + "devDependencies": { + "chai": "^4.2.0", + "mocha": "^6.2.0", + "nyc": "^14.1.1", + "sinon": "^7.4.1", + "supertest": "^4.0.2" + } +} diff --git a/test/integration/index.spec.js b/test/integration/index.spec.js new file mode 100644 index 0000000..677e1b5 --- /dev/null +++ b/test/integration/index.spec.js @@ -0,0 +1,230 @@ +const assert = require('chai').assert +const sinon = require('sinon') + +const authorization = require('../../index') +const configurationsDefault = require('../../lib/config/default') + +describe('Simple Authorization:', () => { + describe('Integrity', () => { + const keys = Object.keys(authorization) + it('should return a object with two functions', () => { + assert.typeOf(authorization, 'object', '"simple.authorization" not return a object') + assert.equal(keys.length, 2, '"simple.authorization" not returned a object with two property') + assert.typeOf(authorization[keys[0]], 'function', '"simple.authorization" not returned two function') + assert.typeOf(authorization[keys[1]], 'function', '"simple.authorization" not returned two function') + }) + + it('must return an object with a function called "config"', () => { + assert.equal((keys[0] === 'config' || keys[1] === 'config'), true, '"simple.authorization" not returned an object with property called "config"') + assert.typeOf(authorization.config, 'function', '"simple.authorization" not return a function "config"') + }) + + it('must return an object with a function called "check"', () => { + assert.equal((keys[0] === 'check' || keys[1] === 'check'), true, '"simple.authorization" not returned an object with property called "check"') + assert.typeOf(authorization.check, 'function', '"simple.authorization" not return a function "check"') + }) + }) + + describe('Functionality', () => { + context('Testing the config Function.', () => { + it('The config function should not be returned', () => { + const configReturn = authorization.config() + assert.equal(configReturn, undefined, 'The config function returns some') + }) + }) + + describe('Testing the check Function.', () => { + it('Check function should return middleware', () => { + /** This struct define an middleware */ + const expected = "(req,res,next)=>"; + const middlewareReturn = authorization.check() + /** Removing whitespace */ + const strMiddleware = middlewareReturn.toString().replace(/[ ]/g, '') + /** Getting the function signature */ + const firstLine = strMiddleware.split('{')[0] + assert.typeOf(middlewareReturn, 'function', 'Check function not return an middleware') + assert.equal(firstLine, expected, 'Check function not return an middleware') + }) + + it('When the check function is called without passing expected scopes, middleware should call the next function', () => { + const req = {} + const res = {} + const next = sinon.spy() + + const middleware = authorization.check() + middleware(req, res, next) + sinon.assert.called(next) + }) + + it('When the check function is called by passing an empty scope array, middleware should call the next function', () => { + const req = {} + const res = {} + const next = sinon.spy() + + const middleware = authorization.check([]) + middleware(req, res, next) + sinon.assert.called(next) + }) + + it('When the check function is called by passing an argument of a different type of array, middleware should throw an error', () => { + const req = {} + const res = {} + const next = sinon.spy() + + const middleware = authorization.check({ scopes: ['scope1', 'scope2'] }) + try { + middleware(req, res, next) + assert.fail('Did not throw error') + } catch (e) { + const msgExpected = 'Expected scopes must be passed in the form of a array, verify the check() function!' + assert.equal(e.message, msgExpected, "Did not return expected error") + } + sinon.assert.notCalled(next) + }) + + context('When not informed, the default settings should be used.', () => { + it('should call the function next', () => { + const req = { + user: { + scope: ['scope1', 'scope2'] + } + } + const res = {} + const next = sinon.spy() + + const middleware = authorization.check(['scope1', 'scope2']) + middleware(req, res, next) + + sinon.assert.called(next) + }) + + it('should return status code 403', () => { + const responseExpected = configurationsDefault.responseCaseError + const req = { + user: { + scope: ['scope4', 'scope3'] + } + } + const res = { + send: sinon.stub(), + status: sinon.stub() + } + res.status.withArgs(403).returns(res); + res.send.withArgs(responseExpected).returns(); + const next = sinon.spy() + + const middleware = authorization.check(['scope1', 'scope2']) + middleware(req, res, next) + + sinon.assert.calledWith(res.status, 403) + sinon.assert.calledWith(res.send, responseExpected) + sinon.assert.notCalled(next) + }) + + it('You should throw an error because req.user.scope doesnt exist', () => { + const req = { + scopes: ['scope1', 'scope2'], + user: {} + } + const res = {} + const next = sinon.spy() + + const middleware = authorization.check(['scope1', 'scope2']) + try { + middleware(req, res, next) + assert.fail('Did not throw error') + } catch (e) { + const msgExpected = 'You are using the default userScopeLocation, but req.user.scope is undefined.' + assert.equal(e.message, msgExpected, "Did not return expected error") + } + sinon.assert.notCalled(next) + }) + + }) + + context('When informed, local settings should take precedence over default settings', () => { + it('should call the function next', () => { + const req = { + user: { + scope: ['scope1', 'scope2'] + } + } + const res = {} + const next = sinon.spy() + const options = { + logicalStrategy: "AND" + } + + const middleware = authorization.check(['scope1', 'scope2'], options) + middleware(req, res, next) + + sinon.assert.called(next) + }) + + it('must return status code 403 and should not call next function', () => { + const options = { + logicalStrategy: "AND", + responseCaseError: { + code: 403 + } + } + const req = { + user: { + scope: ['scope1', 'scope2'] + } + } + const res = { + send: sinon.stub(), + status: sinon.stub() + } + res.status.withArgs(403).returns(res) + res.send.withArgs(options.responseCaseError).returns() + const next = sinon.spy() + + + const middleware = authorization.check(['scope1', 'scope3'], options) + middleware(req, res, next) + + sinon.assert.calledWith(res.status, 403) + sinon.assert.calledWith(res.send, options.responseCaseError) + sinon.assert.notCalled(next) + }) + }) + + context('When informed, you must merge the valid settings entered with the default settings', () => { + it('must call the next function passing the error object', () => { + const options = { + userScopesLocation: "userLogged.scope", + logicalStrategy: "AND", + responseCaseError: { + code: 403 + }, + flowStrategy: "NEXTWITHERROR" + } + const req = { + userLogged: { + scope: ['scope1', 'scope2'] + } + } + const res = { + send: sinon.spy(), + status: sinon.spy() + } + const next = sinon.stub() + + + const middleware = authorization.check(['scope1', 'scope3'], options) + middleware(req, res, next) + + sinon.assert.notCalled(res.status) + sinon.assert.notCalled(res.send) + sinon.assert.calledWith(next, options.responseCaseError) + }) + + }) + }) + + + }) + +}) \ No newline at end of file diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..5017dab --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,4 @@ +--reporter spec +--slow 5000 +--timeout 5000 +--exit \ No newline at end of file diff --git a/test/unit/property.extractor.spec.js b/test/unit/property.extractor.spec.js new file mode 100644 index 0000000..5f7e8ba --- /dev/null +++ b/test/unit/property.extractor.spec.js @@ -0,0 +1,135 @@ +const assert = require('chai').assert +const propertExt = require('../../lib/property.extractor') + +describe('Module: property.extractor.js', () => { + + describe('Integrity', () => { + it('should return a function', () => { + assert.typeOf(propertExt, 'function', '"property.extractor" is not function type') + }) + }) + + describe('Functionality', () => { + context('When parameters are valid.', () => { + it('Object and property path valid, must return corresponding property.', () => { + const object = { + req: { + user: { + id: "507f1f77bcf86cd799439011", + type: "user_type", + scopes: ['scope1', 'scope2'] + }, + headers: [ + { "Content-Type": "application/json" }, + { "Content-Length": "10" } + ] + }, + res: { + status: 0, + body: "" + } + } + const str = "req.user.scopes"; + const propertyExpected = object.req.user.scopes + + const propertyReturn = propertExt(object, str) + assert.equal(propertyReturn, propertyExpected, "Did not return expected property") + assert.equal(typeof propertyReturn, typeof propertyExpected, "Did not return an expected property type") + }) + }) + + context('When any parameters are invalid.', () => { + it('Object valid and value of property path invalid, must return value undefined.', () => { + const object = { + req: { + user: { + id: "507f1f77bcf86cd799439011", + type: "user_type", + scopes: ['scope1', 'scope2'] + }, + headers: [ + { "Content-Type": "application/json" }, + { "Content-Length": "10" } + ] + }, + res: { + status: 0, + body: "" + } + } + const str = "req.scopes"; + const propertyExpected = undefined + + const propertyReturn = propertExt(object, str) + assert.equal(propertyReturn, propertyExpected, "Did not return expected property") + }) + + it('The valid object and property path value contains one. In addition, it must return the last valid value.', () => { + const object = { + req: { + user: { + id: "507f1f77bcf86cd799439011", + type: "user_type", + scopes: ['scope1', 'scope2'] + }, + headers: [ + { "Content-Type": "application/json" }, + { "Content-Length": "10" } + ] + }, + res: { + status: 0, + body: "" + } + } + const str = "req.user.scopes."; + const propertyExpected = object.req.user.scopes + + const propertyReturn = propertExt(object, str) + assert.equal(propertyReturn, propertyExpected, "Did not return expected property") + + }) + + it('Object valid and property path type of invalid, must throw a error.', () => { + const object = { + req: { + user: { + id: "507f1f77bcf86cd799439011", + type: "user_type", + scopes: ['scope1', 'scope2'] + }, + headers: [ + { "Content-Type": "application/json" }, + { "Content-Length": "10" } + ] + }, + res: { + status: 0, + body: "" + } + } + const str = ['req.user.scopes']; + + try { + const propertyReturn = propertExt(object, str) + assert.fail('Did not throw error') + } catch (e) { + const msgExpected = "The userScopeLocation property must be of type string. Check the settings passed to the simple-express-jwt-authorization middleware." + assert.equal(e.message, msgExpected, "Did not return expected error") + } + + }) + + it('Invalid object, regardless of property path value, must return undefined value.', () => { + const object = undefined + const str = "req.user.scopes"; + + const propertyReturn = propertExt(object, str) + assert.equal(propertyReturn, undefined, "Did not return expected property") + + }) + + }) + }) + +}) \ No newline at end of file diff --git a/test/unit/simple.authorization.spec.js b/test/unit/simple.authorization.spec.js new file mode 100644 index 0000000..9e04531 --- /dev/null +++ b/test/unit/simple.authorization.spec.js @@ -0,0 +1,230 @@ +const assert = require('chai').assert +const sinon = require('sinon') +const authorization = require('../../lib/simple.authorization') +const configurationsDefault = require('../../lib/config/default') + +describe('Module: simple.authorization.js', () => { + + describe('Integrity', () => { + const keys = Object.keys(authorization) + it('should return a object with two functions', () => { + assert.typeOf(authorization, 'object', '"simple.authorization" not return a object') + assert.equal(keys.length, 2, '"simple.authorization" not returned a object with two property') + assert.typeOf(authorization[keys[0]], 'function', '"simple.authorization" not returned two function') + assert.typeOf(authorization[keys[1]], 'function', '"simple.authorization" not returned two function') + }) + + it('must return an object with a function called "config"', () => { + assert.equal((keys[0] === 'config' || keys[1] === 'config'), true, '"simple.authorization" not returned an object with property called "config"') + assert.typeOf(authorization.config, 'function', '"simple.authorization" not return a function "config"') + }) + + it('must return an object with a function called "check"', () => { + assert.equal((keys[0] === 'check' || keys[1] === 'check'), true, '"simple.authorization" not returned an object with property called "check"') + assert.typeOf(authorization.check, 'function', '"simple.authorization" not return a function "check"') + }) + }) + + describe('Functionality', () => { + context('Testing the config Function.', () => { + it('The config function should not be returned', () => { + const configReturn = authorization.config() + assert.equal(configReturn, undefined, 'The config function returns some') + }) + }) + + describe('Testing the check Function.', () => { + it('Check function should return middleware', () => { + /** This struct define an middleware */ + const expected = "(req,res,next)=>"; + const middlewareReturn = authorization.check() + /** Removing whitespace */ + const strMiddleware = middlewareReturn.toString().replace(/[ ]/g, '') + /** Getting the function signature */ + const firstLine = strMiddleware.split('{')[0] + assert.typeOf(middlewareReturn, 'function', 'Check function not return an middleware') + assert.equal(firstLine, expected, 'Check function not return an middleware') + }) + + it('When the check function is called without passing expected scopes, middleware should call the next function', () => { + const req = {} + const res = {} + const next = sinon.spy() + + const middleware = authorization.check() + middleware(req, res, next) + sinon.assert.called(next) + }) + + it('When the check function is called by passing an empty scope array, middleware should call the next function', () => { + const req = {} + const res = {} + const next = sinon.spy() + + const middleware = authorization.check([]) + middleware(req, res, next) + sinon.assert.called(next) + }) + + it('When the check function is called by passing an argument of a different type of array, middleware should throw an error', () => { + const req = {} + const res = {} + const next = sinon.spy() + + const middleware = authorization.check({ scopes: ['scope1', 'scope2'] }) + try { + middleware(req, res, next) + assert.fail('Did not throw error') + } catch (e) { + const msgExpected = 'Expected scopes must be passed in the form of a array, verify the check() function!' + assert.equal(e.message, msgExpected, "Did not return expected error") + } + sinon.assert.notCalled(next) + }) + + context('When not informed, the default settings should be used.', () => { + it('should call the function next', () => { + const req = { + user: { + scope: ['scope1', 'scope2'] + } + } + const res = {} + const next = sinon.spy() + + const middleware = authorization.check(['scope1', 'scope2']) + middleware(req, res, next) + + sinon.assert.called(next) + }) + + it('should return status code 403', () => { + const responseExpected = configurationsDefault.responseCaseError + const req = { + user: { + scope: ['scope4', 'scope3'] + } + } + const res = { + send: sinon.stub(), + status: sinon.stub() + } + res.status.withArgs(403).returns(res); + res.send.withArgs(responseExpected).returns(); + const next = sinon.spy() + + const middleware = authorization.check(['scope1', 'scope2']) + middleware(req, res, next) + + sinon.assert.calledWith(res.status, 403) + sinon.assert.calledWith(res.send, responseExpected) + sinon.assert.notCalled(next) + }) + + it('You should throw an error because req.user.scope doesnt exist', () => { + const req = { + scopes: ['scope1', 'scope2'], + user: {} + } + const res = {} + const next = sinon.spy() + + const middleware = authorization.check(['scope1', 'scope2']) + try { + middleware(req, res, next) + assert.fail('Did not throw error') + } catch (e) { + const msgExpected = 'You are using the default userScopeLocation, but req.user.scope is undefined.' + assert.equal(e.message, msgExpected, "Did not return expected error") + } + sinon.assert.notCalled(next) + }) + + }) + + context('When informed, local settings should take precedence over default settings', () => { + it('should call the function next', () => { + const req = { + user: { + scope: ['scope1', 'scope2'] + } + } + const res = {} + const next = sinon.spy() + const options = { + logicalStrategy: "AND" + } + + const middleware = authorization.check(['scope1', 'scope2'], options) + middleware(req, res, next) + + sinon.assert.called(next) + }) + + it('must return status code 403 and should not call next function', () => { + const options = { + logicalStrategy: "AND", + responseCaseError: { + code: 403 + } + } + const req = { + user: { + scope: ['scope1', 'scope2'] + } + } + const res = { + send: sinon.stub(), + status: sinon.stub() + } + res.status.withArgs(403).returns(res) + res.send.withArgs(options.responseCaseError).returns() + const next = sinon.spy() + + + const middleware = authorization.check(['scope1', 'scope3'], options) + middleware(req, res, next) + + sinon.assert.calledWith(res.status, 403) + sinon.assert.calledWith(res.send, options.responseCaseError) + sinon.assert.notCalled(next) + }) + }) + + context('When informed, you must merge the valid settings entered with the default settings', () => { + it('must call the next function passing the error object', () => { + const options = { + userScopesLocation: "userLogged.scope", + logicalStrategy: "AND", + responseCaseError: { + code: 403 + }, + flowStrategy: "NEXTWITHERROR" + } + const req = { + userLogged: { + scope: ['scope1', 'scope2'] + } + } + const res = { + send: sinon.spy(), + status: sinon.spy() + } + const next = sinon.stub() + + + const middleware = authorization.check(['scope1', 'scope3'], options) + middleware(req, res, next) + + sinon.assert.notCalled(res.status) + sinon.assert.notCalled(res.send) + sinon.assert.calledWith(next, options.responseCaseError) + }) + + }) + }) + + + }) + +}) \ No newline at end of file diff --git a/test/unit/validator.spec.js b/test/unit/validator.spec.js new file mode 100644 index 0000000..3e6301b --- /dev/null +++ b/test/unit/validator.spec.js @@ -0,0 +1,166 @@ +const assert = require('chai').assert +const validator = require('../../lib/validator') +const configDefault = require('../../lib/config/default') + +describe('Module: validator.js', () => { + + describe('Integrity', () => { + it('should return a function', () => { + assert.typeOf(validator, 'function', '"validator" is not function type') + }) + }) + + describe('Functionality', () => { + context('When no setting is passed.', () => { + it('Must return an object with default settings', () => { + const configReturned = validator() + assert.equal(configReturned, configDefault, "Did not return default settings") + }) + }) + context('When any configuration is passed.', () => { + it('Passing a type valid userScopesLocation, must return an object with a merge between valid settings and default settings.', () => { + const configurations = { + userScopesLocation: "user.scopes" + }; + const configExpected = Object.assign({}, configDefault) + configExpected.userScopesLocation = configurations.userScopesLocation; + + const configReturned = validator(configurations) + assert.equal(configReturned.userScopesLocation, configExpected.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.logicalStrategy, configExpected.logicalStrategy, "Did not return expected logicalStrategy") + assert.equal(configReturned.responseCaseError, configExpected.responseCaseError, "Did not return expected responseCaseError") + assert.equal(configReturned.flowStrategy, configExpected.flowStrategy, "Did not return expected flowStrategy") + }) + + it('Passing a type invalid userScopesLocation, must return an object with a default userScopesLocation settings.', () => { + const configurations = { + userScopesLocation: { + req: { + user: { + scopes: "" + } + } + } + }; + const configExpected = Object.assign({}, configDefault) + + const configReturned = validator(configurations) + assert.notEqual(configReturned.userScopesLocation, configurations.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.userScopesLocation, configExpected.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.logicalStrategy, configExpected.logicalStrategy, "Did not return expected logicalStrategy") + assert.equal(configReturned.responseCaseError, configExpected.responseCaseError, "Did not return expected responseCaseError") + assert.equal(configReturned.flowStrategy, configExpected.flowStrategy, "Did not return expected flowStrategy") + }) + + + it('Passing valid logicalStrategy, must return an object with a merge between valid settings and default settings.', () => { + const configurations = { + logicalStrategy: "AND" + }; + const configExpected = Object.assign({}, configDefault) + configExpected.logicalStrategy = configurations.logicalStrategy; + + const configReturned = validator(configurations) + assert.equal(configReturned.userScopesLocation, configExpected.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.logicalStrategy, configExpected.logicalStrategy, "Did not return expected logicalStrategy") + assert.equal(configReturned.responseCaseError, configExpected.responseCaseError, "Did not return expected responseCaseError") + assert.equal(configReturned.flowStrategy, configExpected.flowStrategy, "Did not return expected flowStrategy") + }) + + it('Passing a value invalid logicalStrategy, must return an object with a default logicalStrategy settings.', () => { + const configurations = { + logicalStrategy: "XOR" + }; + const configExpected = Object.assign({}, configDefault) + + const configReturned = validator(configurations) + assert.equal(configReturned.userScopesLocation, configExpected.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.logicalStrategy, configExpected.logicalStrategy, "Did not return expected logicalStrategy") + assert.notEqual(configReturned.logicalStrategy, configurations.logicalStrategy, "Did not return expected logicalStrategy") + assert.equal(configReturned.responseCaseError, configExpected.responseCaseError, "Did not return expected responseCaseError") + assert.equal(configReturned.flowStrategy, configExpected.flowStrategy, "Did not return expected flowStrategy") + }) + + it('Passing a type invalid logicalStrategy, must return an object with a default logicalStrategy settings.', () => { + const configurations = { + logicalStrategy: { + value: "XOR" + } + }; + const configExpected = Object.assign({}, configDefault) + + const configReturned = validator(configurations) + assert.equal(configReturned.userScopesLocation, configExpected.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.logicalStrategy, configExpected.logicalStrategy, "Did not return expected logicalStrategy") + assert.notEqual(configReturned.logicalStrategy, configurations.logicalStrategy, "Did not return expected logicalStrategy") + assert.equal(configReturned.responseCaseError, configExpected.responseCaseError, "Did not return expected responseCaseError") + assert.equal(configReturned.flowStrategy, configExpected.flowStrategy, "Did not return expected flowStrategy") + }) + + it('Passing userScopesLocation, must return an object with a merge between valid settings and default settings.', () => { + const configurations = { + responseCaseError: { + code: 403, + message: "FORBIDDEN", + description: "Custom description by develop.", + redirect_link: "/authenticate" + } + }; + const configExpected = Object.assign({}, configDefault) + configExpected.responseCaseError = configurations.responseCaseError; + + const configReturned = validator(configurations) + assert.equal(configReturned.userScopesLocation, configExpected.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.logicalStrategy, configExpected.logicalStrategy, "Did not return expected logicalStrategy") + assert.equal(configReturned.responseCaseError, configExpected.responseCaseError, "Did not return expected responseCaseError") + assert.equal(configReturned.flowStrategy, configExpected.flowStrategy, "Did not return expected flowStrategy") + }) + + it('Passing valid flowStrategy, must return an object with a merge between valid settings and default settings.', () => { + const configurations = { + flowStrategy: "NEXTWITHERROR" + }; + const configExpected = Object.assign({}, configDefault) + configExpected.flowStrategy = configurations.flowStrategy; + + const configReturned = validator(configurations) + assert.equal(configReturned.userScopesLocation, configExpected.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.logicalStrategy, configExpected.logicalStrategy, "Did not return expected logicalStrategy") + assert.equal(configReturned.responseCaseError, configExpected.responseCaseError, "Did not return expected responseCaseError") + assert.equal(configReturned.flowStrategy, configExpected.flowStrategy, "Did not return expected flowStrategy") + }) + + it('Passing a value invalid in logicalStrategy, must return an object with a default flowStrategy settings.', () => { + const configurations = { + flowStrategy: "OutConfigurations" + }; + const configExpected = Object.assign({}, configDefault) + + const configReturned = validator(configurations) + assert.equal(configReturned.userScopesLocation, configExpected.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.logicalStrategy, configExpected.logicalStrategy, "Did not return expected logicalStrategy") + assert.equal(configReturned.responseCaseError, configExpected.responseCaseError, "Did not return expected responseCaseError") + assert.equal(configReturned.flowStrategy, configExpected.flowStrategy, "Did not return expected flowStrategy") + assert.notEqual(configReturned.flowStrategy, configurations.flowStrategy, "Did not return expected flowStrategy") + }) + + it('Passing a type invalid in logicalStrategy, must return an object with a default flowStrategy settings.', () => { + const configurations = { + flowStrategy: { + value: "RETURNRESPONSE" + } + }; + const configExpected = Object.assign({}, configDefault) + + const configReturned = validator(configurations) + assert.equal(configReturned.userScopesLocation, configExpected.userScopesLocation, "Did not return expected userScopesLocation") + assert.equal(configReturned.logicalStrategy, configExpected.logicalStrategy, "Did not return expected logicalStrategy") + assert.equal(configReturned.responseCaseError, configExpected.responseCaseError, "Did not return expected responseCaseError") + assert.equal(configReturned.flowStrategy, configExpected.flowStrategy, "Did not return expected flowStrategy") + assert.notEqual(configReturned.flowStrategy, configurations.flowStrategy, "Did not return expected flowStrategy") + }) + + }) + }) + +}) \ No newline at end of file