diff --git a/package.json b/package.json index c51e873..f35dea0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,10 @@ { "name": "node-api-seed", "title": "Node Api Seed", - "version": "0.0.0", + "version": "0.0.1", + "engines": { + "node": ">=6.10.0" + }, "deploymentDate": "2016-11-09T19:15:04.309Z", "description": "The seed for pretty much any api I write in node.js", "main": "index.js", @@ -74,8 +77,7 @@ "winston": "2.3.1", "winston-daily-rotate-file": "1.4.6", "winston-graylog2": "0.6.0", - "winston-loggly": "1.3.1", - "snyk": "^1.33.0" + "winston-loggly": "1.3.1" }, "devDependencies": { "chai": "4.0.1", @@ -96,6 +98,7 @@ "proxyquire": "1.8.0", "sinon": "2.3.2", "sinon-chai": "2.10.0", + "snyk": "1.33.0", "supertest": "3.0.0", "swagger-ui": "3.0.13" }, diff --git a/src/crud/create.js b/src/crud/create.js index 9c37dc1..8a7c18f 100644 --- a/src/crud/create.js +++ b/src/crud/create.js @@ -22,8 +22,14 @@ function addCreateRoute(router, crudMiddleware, maps) { .describe(router.metadata.creationDescription || description(router.metadata)); return router; } +addCreateRoute.getSteps = getSteps; +addCreateRoute.sendCreateResult = sendCreateResult; +addCreateRoute.description = description; addCreateRoute.setStatusIfApplicable = setStatusIfApplicable; addCreateRoute.setOwnerIfApplicable = setOwnerIfApplicable; +addCreateRoute.getFromReqObject = getFromReqObject; +addCreateRoute.getData = getData; + module.exports = addCreateRoute; function getSteps(router, crudMiddleware, maps) { @@ -100,14 +106,14 @@ function setStatusIfApplicable(metadata) { if (!statuses || statuses.length <= 0) { return next(); } - req.body.status = statuses[0].name; + const statusToSet = statuses[0]; + req.body.status = statusToSet.name; req.body.statusDate = moment.utc().toDate(); req.body.statusLog = [ { status: req.body.status, - data: { - reason: 'Initial Status' //todo need to set this logically somehow - }, + //we use 'addCreateRoute.' here to allow stubbing in the unit tests + data: addCreateRoute.getData(statusToSet.initialData, req), statusDate: req.body.statusDate } ]; @@ -115,6 +121,84 @@ function setStatusIfApplicable(metadata) { }; } +function getData(rules, req) { + if (!rules) { + return; + } + //we use 'addCreateRoute.' here to allow stubbing in the unit tests + const fromReq = addCreateRoute.getFromReqObject(rules.fromReq, req); + return _.merge({}, rules.static, fromReq); +} + +const defaultDisallowedSuffixList = ['password', 'passwordHash', 'passwordSalt']; +const defaultAllowedPrefixList = ['user', 'process', 'body', 'params', 'query']; +const maxDepth = 10; +function getFromReqObject( + map, + req, + depth = 0, + disallowedSuffixList = defaultDisallowedSuffixList, + allowedPrefixList = defaultAllowedPrefixList +) { + if (!map) { + return; + } + if (depth > maxDepth) { + throw new Error( + util.format( + 'Circular reference detected in map object after maximum depth (%s) reached. Partial map\n%j\n', + maxDepth, + util.inspect(map, true, maxDepth) + ) + ); + } + const data = {}; + Object.keys(map).forEach(function(key) { + const value = map[key]; + if (_.isArray(value)) { + ensureMapIsString(value[0]); + if (value.length > 2) { + throw new Error( + util.format('Too many items in array, should be at most 2. %j', value) + ); + } + data[key] = getValue(req, value[0], value[1], disallowedSuffixList, allowedPrefixList); + return; + } + if (_.isObject(value)) { + data[key] = getFromReqObject( + value, + req, + depth + 1, + disallowedSuffixList, + allowedPrefixList + ); + return; + } + ensureMapIsString(value); + data[key] = getValue(req, value, undefined, disallowedSuffixList, allowedPrefixList); + }); + return data; +} + +function getValue(req, map, defaultValue, disallowedSuffixList, allowedPrefixList) { + const disallowed = disallowedSuffixList.find(suffix => map.endsWith(suffix)); + if (disallowed) { + throw new Error('Map is not allowed to end with ' + disallowed); + } + const allowed = allowedPrefixList.find(prefix => map.startsWith(prefix)); + if (!allowed) { + throw new Error(util.format('Map must start with one of %j', allowedPrefixList)); + } + return _.get(req, map, defaultValue); +} + +function ensureMapIsString(map) { + if (!_.isString(map)) { + throw new Error(util.format('Invalid map value, must be a string : \n%j\n', map)); + } +} + function setOwnerIfApplicable(metadata) { return function _setOwnerIfApplicable(req, res, next) { let ownership = metadata.schemas.core.ownership; diff --git a/src/metadata/hydrate-schema.js b/src/metadata/hydrate-schema.js index 5e8432c..baa5249 100644 --- a/src/metadata/hydrate-schema.js +++ b/src/metadata/hydrate-schema.js @@ -45,9 +45,9 @@ function addStatusInfo(schema) { properties: { status: schema.properties.status, statusDate: schema.properties.statusDate, - data: schema.updateStatusSchema + data: schema.updateStatusSchema //todo anyof? }, - required: ['status', 'statusDate', 'data'], + required: ['status', 'statusDate', 'data'], //todo - data check schema anyof? additionalProperties: false }, additionalItems: false @@ -84,7 +84,7 @@ function addOwnerInfo(schema) { type: ['object', 'string'] //todo? } }, - required: ['owner', 'ownerDate', 'data'], + required: ['owner', 'ownerDate', 'data'], //todo - data check schema anyof? additionalProperties: false }, additionalItems: false diff --git a/src/routes/users/user.json b/src/routes/users/user.json index dbe8764..a65c7fc 100644 --- a/src/routes/users/user.json +++ b/src/routes/users/user.json @@ -7,7 +7,12 @@ "statuses": [ { "name": "active", - "description": "Default status, shows that the user is active and can login" + "description": "Default status, shows that the user is active and can login", + "initialData":{ + "static":{ + "reason":"testing" + } + } }, { "name": "inactive", diff --git a/test/@util/request-mocking.js b/test/@util/request-mocking.js new file mode 100644 index 0000000..d66bb17 --- /dev/null +++ b/test/@util/request-mocking.js @@ -0,0 +1,56 @@ +'use strict'; +const httpMocks = require('node-mocks-http'); +const events = require('events'); + +module.exports = { + mockRequest, + shouldNotCallNext, + shouldCallNext, + shouldNotReturnResponse +}; + +function mockRequest(middlewareOrRouter, reqOptions, responseCallback, nextCallback) { + const req = httpMocks.createRequest(reqOptions); + const res = httpMocks.createResponse({ + eventEmitter: events.EventEmitter + }); + res.on('end', function() { + let resToReturn; + try { + resToReturn = { + statusCode: res._getStatusCode(), + body: JSON.parse(res._getData()), + headers: res._getHeaders(), + raw: res + }; + } catch (err) { + return responseCallback(err); + } + responseCallback(null, resToReturn); + }); + middlewareOrRouter(req, res, nextCallback); +} + +function shouldNotCallNext(done) { + return function next(err) { + if (err) { + return done(err); + } + return done(new Error('Next should not have been called')); + }; +} + +function shouldCallNext(done) { + return function next(err) { + if (err) { + return done(err); + } + return done(); + }; +} + +function shouldNotReturnResponse(done) { + return function resComplete() { + done(new Error('res.end should not have been called')); + }; +} diff --git a/test/crud/create.unit.js b/test/crud/create.unit.js new file mode 100644 index 0000000..0af0c90 --- /dev/null +++ b/test/crud/create.unit.js @@ -0,0 +1,547 @@ +'use strict'; +require('../@util/init.js'); +const addCreateRoute = require('../../src/crud/create'); +const mockRequest = require('../@util/request-mocking').mockRequest; +const moment = require('moment'); +const sinon = require('sinon'); +const httpMocks = require('node-mocks-http'); + +describe('Crud - create', function() { + describe('setStatusIfApplicable', function() { + it('Should not set req.body.status if the provided schema has no statuses', function(done) { + const metadata = buildMetadata(); + const middleware = addCreateRoute.setStatusIfApplicable(metadata); + const reqOptions = { + body: {} + }; + mockRequest(middleware, reqOptions, null, next); + + function next(error) { + expect(error).to.not.be.ok(); + expect(reqOptions.body.status).to.not.be.ok(); + expect(reqOptions.body.statusDate).to.not.be.ok(); + expect(reqOptions.body.statusLog).to.not.be.ok(); + done(); + } + }); + + it('Should set req.body.status to the first status in the schema', function(done) { + const metadata = buildMetadata([{ name: 'a' }, { name: 'b' }]); + const middleware = addCreateRoute.setStatusIfApplicable(metadata); + const reqOptions = { + body: {} + }; + mockRequest(middleware, reqOptions, null, next); + + function next(error) { + expect(error).to.not.be.ok(); + expect(reqOptions.body.status).to.equal('a'); + done(); + } + }); + + it('Should set req.body.statusDate to now', function(done) { + const metadata = buildMetadata([{ name: 'a' }]); + const middleware = addCreateRoute.setStatusIfApplicable(metadata); + const reqOptions = { + body: {} + }; + mockRequest(middleware, reqOptions, null, next); + + function next(error) { + expect(error).to.not.be.ok(); + expect(moment(reqOptions.body.statusDate).diff(new Date())).to.be.lessThan(1); + done(); + } + }); + + it('Should create a status log with one entry', function(done) { + const metadata = buildMetadata([{ name: 'a' }]); + const middleware = addCreateRoute.setStatusIfApplicable(metadata); + const reqOptions = { + body: {} + }; + mockRequest(middleware, reqOptions, null, next); + + function next(error) { + expect(error).to.not.be.ok(); + expect(reqOptions.body.statusLog).to.be.an('array').that.has.length(1); + done(); + } + }); + + it('Should create a status log entry with the status set to the first one in the schema', function( + done + ) { + const metadata = buildMetadata([{ name: 'a' }]); + const middleware = addCreateRoute.setStatusIfApplicable(metadata); + const reqOptions = { + body: {} + }; + mockRequest(middleware, reqOptions, null, next); + + function next(error) { + expect(error).to.not.be.ok(); + expect(reqOptions.body.statusLog[0].status).to.equal('a'); + done(); + } + }); + + it('Should create a status log entry with the statusDate set to now', function(done) { + const metadata = buildMetadata([{ name: 'a' }]); + const middleware = addCreateRoute.setStatusIfApplicable(metadata); + const reqOptions = { + body: {} + }; + mockRequest(middleware, reqOptions, null, next); + + function next(error) { + expect(error).to.not.be.ok(); + expect( + moment(reqOptions.body.statusLog[0].statusDate).diff(new Date()) + ).to.be.lessThan(1); + done(); + } + }); + + describe('Status log data', function() { + it('Should not exist if initialData was missing', function(done) { + const statusToSet = { + name: 'a' + }; + const metadata = buildMetadata([statusToSet]); + const middleware = addCreateRoute.setStatusIfApplicable(metadata); + const reqOptions = { + body: {} + }; + mockRequest(middleware, reqOptions, null, next); + + function next(error) { + expect(error).to.not.be.ok(); + const data = reqOptions.body.statusLog[0].data; + expect(data).to.not.be.ok(); + done(); + } + }); + + it('Should be an empty object if initialData was an empty object', function(done) { + const statusToSet = { + name: 'a', + initialData: {} + }; + const metadata = buildMetadata([statusToSet]); + const middleware = addCreateRoute.setStatusIfApplicable(metadata); + const reqOptions = { + body: {} + }; + mockRequest(middleware, reqOptions, null, next); + + function next(error) { + expect(error).to.not.be.ok(); + const data = reqOptions.body.statusLog[0].data; + expect(Object.keys(data)).to.have.lengthOf(0); + done(); + } + }); + + it('Should be merge the result from getData', function(done) { + const statusToSet = { + name: 'a', + initialData: {} + }; + const metadata = buildMetadata([statusToSet]); + const middleware = addCreateRoute.setStatusIfApplicable(metadata); + const reqOptions = { + body: {} + }; + const stubbedData = { + bob: true, + asd: { + value: 1, + name: 'bob' + } + }; + const stub = sinon.stub(addCreateRoute, 'getData'); + stub.returns(stubbedData); + mockRequest(middleware, reqOptions, null, next); + + function next(error) { + stub.restore(); + expect(error).to.not.be.ok(); + const data = reqOptions.body.statusLog[0].data; + expect(data).to.deep.equal(stubbedData); + done(); + } + }); + }); + }); + + describe('getData', function() { + it('Should not exist if rules was falsy', function() { + const data = addCreateRoute.getData(null, {}); + expect(data).to.not.be.ok(); + }); + + it('Should be an empty object if rules was an empty object', function() { + const data = addCreateRoute.getData({}, {}); + expect(data).to.be.ok(); + expect(Object.keys(data)).to.have.lengthOf(0); + }); + + it('Should return an object that deep equals rules.static if only initialData.static was set', function() { + const req = {}; + const rules = { + static: { + number: 1, + string: 'test', + bool: true, + array: [2, 'test', true, null, {}, []], + object: {} + } + }; + const data = addCreateRoute.getData(rules, req); + expect(data).to.deep.equal(rules.static); + }); + + it('Should merge the result from getFromReqObject if rules.fromReq existed', function() { + const req = {}; + const rules = { + fromReq: {} + }; + const stubbedData = { + bob: true + }; + const stub = sinon.stub(addCreateRoute, 'getFromReqObject'); + stub.returns(stubbedData); + const data = addCreateRoute.getData(rules, req); + stub.restore(); + expect(data.bob).to.equal(true); + }); + + it('Should merge the result from getFromReqObject and static if both were set', function() { + const req = {}; + const rules = { + fromReq: {}, + static: { + number: 1, + string: 'test', + bool: true, + array: [2, 'test', true, null, {}, []], + object: { + subObject: {} + } + } + }; + const stubbedData = { + bob: true + }; + const stub = sinon.stub(addCreateRoute, 'getFromReqObject'); + stub.returns(stubbedData); + const data = addCreateRoute.getData(rules, req); + stub.restore(); + expect(data.bob).to.equal(true); + expect(data.number).to.equal(rules.static.number); + expect(data.string).to.equal(rules.static.string); + expect(data.bool).to.equal(rules.static.bool); + expect(data.array).to.deep.equal(rules.static.array); + expect(data.object).to.deep.equal(rules.static.object); + }); + + it('Should prioritise fields from getFromReqObject over static if both were set', function() { + const req = {}; + const rules = { + fromReq: {}, + static: { + number: 1, + string: 'test', + bool: true, + array: [2, 'test', true, null, {}, []], + object: { + subObject: {} + } + } + }; + const stubbedData = { + bob: true, + number: 2, + string: 'test2', + bool: false, + array: [], + object: { + betterSubObject: {} + } + }; + const stub = sinon.stub(addCreateRoute, 'getFromReqObject'); + stub.returns(stubbedData); + const data = addCreateRoute.getData(rules, req); + stub.restore(); + expect(data.bob).to.equal(true); + expect(data.number).to.equal(stubbedData.number); + expect(data.string).to.equal(stubbedData.string); + expect(data.bool).to.equal(stubbedData.bool); + expect(data.array).to.deep.equal(rules.static.array); + expect(data.object.subObject).to.be.ok; + expect(data.object.betterSubObject).to.be.ok; + }); + }); + + describe('getFromReqObject', function() { + it('Should map shallow properties from the req using the map', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: 'a' + }; + const data = addCreateRoute.getFromReqObject(map, req, 0, undefined, ['a']); + expect(data.answer).to.equal('b'); + }); + + it('Should map deep properties from the req using the map', function() { + const req = httpMocks.createRequest({ + a: { + b: 'c' + } + }); + const map = { + answer: 'a.b' + }; + const data = addCreateRoute.getFromReqObject(map, req, 0, undefined, ['a']); + expect(data.answer).to.equal('c'); + }); + + it('Should support a nested map structure', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + nested: { + answer: 'a' + } + }; + const data = addCreateRoute.getFromReqObject(map, req, 0, undefined, ['a']); + expect(data.nested.answer).to.equal('b'); + }); + + it('Should support a nested map structure with a nested request object', function() { + const req = httpMocks.createRequest({ + a: { + b: 'c' + } + }); + const map = { + nested: { + answer: 'a.b' + } + }; + const data = addCreateRoute.getFromReqObject(map, req, 0, undefined, ['a']); + expect(data.nested.answer).to.equal('c'); + }); + + it('Should throw an error for circular reference maps', function() { + const req = httpMocks.createRequest({ + a: { + b: 'c' + } + }); + const map = { + nested: {} + }; + map.nested.answer = map; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(/circular reference/i); + }); + + const notAString = /must be a string/i; + + it('Should throw an error if the map was a number', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: 1 + }; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(notAString); + }); + + it('Should throw an error if the map was a boolean', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: true + }; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(notAString); + }); + + it('Should not throw an error if the map was an empty object', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: {} + }; + const data = addCreateRoute.getFromReqObject(map, req); + expect(data.answer).to.deep.equal({}); + }); + + it('Should throw an error if the map was null', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: null + }; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(notAString); + }); + + it('Should throw an error if the map was undefined', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: undefined + }; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(notAString); + }); + + it('Should use the default value if one was supplied', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: ['c', 'd'] + }; + const result = addCreateRoute.getFromReqObject(map, req, 0, undefined, ['c']); + expect(result.answer).to.equal('d'); + }); + + it('Should use the first value in the array for the map if only that is specified', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: ['a'] + }; + const result = addCreateRoute.getFromReqObject(map, req, 0, undefined, ['a']); + expect(result.answer).to.equal('b'); + }); + + it('Should throw an error if the first value in the array was not a string', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: [1] + }; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(notAString); + }); + + it('Should throw an error if the array is empty', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: [] + }; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(notAString); + }); + + it('Should throw an error if the array has more than 2 entries', function() { + const req = httpMocks.createRequest({ + a: 'b' + }); + const map = { + answer: ['a', 'a', 'a'] + }; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(/too many items in array/i); + }); + + const invalidSuffix = /Map is not allowed to end with/i; + it('Should not allow map values that end with something on the exception list', function() { + const req = httpMocks.createRequest({ + a: { + b: 'c' + } + }); + const map = { + answer: 'a.b' + }; + const disallowedSuffixList = ['.b']; + expect(function() { + addCreateRoute.getFromReqObject(map, req, 0, disallowedSuffixList); + }).to.throw(invalidSuffix); + }); + + it('Should not allow map values that end with something on the default exception list', function() { + const req = httpMocks.createRequest({ + a: { + b: 'c' + } + }); + const map = { + answer: 'a.password' + }; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(invalidSuffix); + }); + + const invalidPrefix = /Map must start with one of /i; + it('Should not allow access to req properties are not on the exception list', function() { + const req = httpMocks.createRequest({ + a: { + b: 'c' + } + }); + const map = { + answer: 'a.b' + }; + const allowedPrefixList = []; + expect(function() { + addCreateRoute.getFromReqObject(map, req, 0, undefined, allowedPrefixList); + }).to.throw(invalidPrefix); + }); + + it('Should not allow map values that end with something on the default exception list', function() { + const req = httpMocks.createRequest({ + body: { + b: 'c' + } + }); + const map = { + answer: 'body.password' + }; + expect(function() { + addCreateRoute.getFromReqObject(map, req); + }).to.throw(invalidSuffix); + }); + }); +}); + +function buildMetadata(statuses) { + const metadata = { + schemas: { + core: {} + } + }; + if (statuses) { + metadata.schemas.core.statuses = statuses; + } + return metadata; +}