diff --git a/.eslintrc b/.eslintrc index c405710..e56236e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,7 +8,6 @@ "generators": true }, "env": { - "browser": true, "node": true, "es6": true }, @@ -26,7 +25,8 @@ "object-shorthand": [0, "never"], "wrap-iife": [2, "any"], "no-loop-func": 0, - "no-console": 0 + "no-console": 0, + "padded-blocks": 0 }, "globals" :{ "require": true diff --git a/.gitignore b/.gitignore index 215599a..6bb1677 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ npm-debug.log local-mocks/ +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 63067bb..5312df4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Minimum usage: `easy-mocker -c config.json -d folder/` | `-c`* | `path/to/config.json` | Path to api definition | | `-d`* | `path/to/mock-directory` | Path to mock folder | | `-p` | `port` | Webserver port | -| `-u` | `null` | Return different file for different user (to be implemented) | +| `-u` | `null` | Return different models for different user | _Options marked with * are mandatory.mv m_ @@ -28,20 +28,25 @@ Minimum usage: `easy-mocker -c config.json -d folder/` This is a sample structure for a configuration file: ``` -[ - { - "url": "users", - "base": "api/", - "methods": ["GET"], - "param": "id" +{ + "auth": { + "headerField": "x-randomField" //optional (default to `x-userid`) }, - { - "url": "posts", - "base": "api/", - "methods": ["GET", "POST"], - "param": "id" - } -] + "endpoints": [ + { + "url": "users", + "base": "api/", + "methods": ["GET"], + "param": "id" + }, + { + "url": "posts", + "base": "api/", + "methods": ["GET", "POST"], + "param": "id" + } + ] +} ``` That will generate the following endpoints: @@ -59,13 +64,3 @@ You can provide data to be loaded as a starting point for your development serve Examples for this files can be found in `spec/mocks`, anyway they are plain `JSON` arrays of objects. -## TODO - -- Create and endpont (or command to restore the original data) -- Add a delay option to slow down responses -- How to handle similar endpoints (`api/user/devices`, `api/other/devices`)? Now thery'll look for the same definition file `devices.json`. Probably adding an optional `filename` to the `config` should work. -- Handle users -- Add config options to allow specify a header different form `x-userid` to identify user -- If `-u` is set create `login` and `logout` endpoint - - diff --git a/package.json b/package.json index 583d248..29e72f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "easy-mocker", - "version": "1.2.1", + "version": "1.3.0", "keywords": [ "server", "cli", @@ -20,12 +20,12 @@ }, "author": "Matteo Scandolo", "homepage": "https://github.com/teone/easy-mocker", - "bugs": { - "url" : "https://github.com/teone/easy-mocker/issues" + "bugs": { + "url": "https://github.com/teone/easy-mocker/issues" }, - "repository" :{ - "type" : "git", - "url" : "https://github.com/teone/easy-mocker" + "repository": { + "type": "git", + "url": "https://github.com/teone/easy-mocker" }, "license": "MIT", "dependencies": { @@ -41,6 +41,7 @@ "eslint": "1.10.3", "eslint-config-airbnb": "^5.0.1", "mocha": "^2.4.5", + "mockery": "^1.4.0", "nodemon": "^1.8.1", "supertest": "^1.1.0" } diff --git a/spec/api.test.js b/spec/api.test.js index cce8a9d..28bd023 100644 --- a/spec/api.test.js +++ b/spec/api.test.js @@ -2,13 +2,29 @@ 'use strict'; const request = require('supertest'); - const app = require('../src/server'); const chai = require('chai'); const expect = chai.expect; + const mockery = require('mockery'); + const path = require('path'); let memory; + let app; describe('From the base config', () => { beforeEach((done) => { + mockery.enable({ + warnOnReplace: false, + warnOnUnregistered: false, + useCleanCache: true, + }); + mockery.resetCache(); + const configMock = { + user: false, + definitionFile: path.join(__dirname, './config/base.json'), + mockDir: path.join(__dirname, './mocks/base/'), + }; + mockery.registerMock('./lib/config', configMock); + mockery.registerMock('./config', configMock); + app = require('../src/server'); memory = require('../src/lib/in_memory'); memory.setup() .then(() => { @@ -16,6 +32,11 @@ }); }); + afterEach(() => { + mockery.deregisterMock('./config'); + mockery.disable(); + }); + describe('when quering items', () => { it('should return an array', (done) => { request(app) diff --git a/spec/config.test.js b/spec/config.test.js index 47fcc44..aaa6317 100644 --- a/spec/config.test.js +++ b/spec/config.test.js @@ -31,7 +31,7 @@ describe('given a mocks folder', () => { it('should fill the in-memory storage with base data', (done) => { - memory.loadBaseData(path.join(__dirname, './mocks/')) + memory.loadBaseData(path.join(__dirname, './mocks/base/')) .then(() => { expect(memory.memoryStorage).to.have.property('users').with.length(2); expect(memory.memoryStorage).to.have.property('posts').with.length(0); diff --git a/spec/config/base.json b/spec/config/base.json index 1ce4b5a..91f9de7 100644 --- a/spec/config/base.json +++ b/spec/config/base.json @@ -1,14 +1,16 @@ -[ - { - "url": "users", - "base": "api/", - "methods": ["GET", "POST", "DELETE"], - "param": "id" - }, - { - "url": "posts", - "base": "api/", - "methods": ["GET", "POST"], - "param": "id" - } -] \ No newline at end of file +{ + "endpoints": [ + { + "url": "users", + "base": "api/", + "methods": ["GET", "POST", "DELETE"], + "param": "id" + }, + { + "url": "posts", + "base": "api/", + "methods": ["GET", "POST"], + "param": "id" + } + ] +} \ No newline at end of file diff --git a/spec/config/users_enabled.json b/spec/config/users_enabled.json new file mode 100644 index 0000000..ab0f83f --- /dev/null +++ b/spec/config/users_enabled.json @@ -0,0 +1,13 @@ +{ + "auth": { + "headerField": "x-randomField" + }, + "endpoints": [ + { + "url": "posts", + "base": "api/", + "methods": ["GET", "POST", "DELETE"], + "param": "id" + } + ] +} \ No newline at end of file diff --git a/spec/mocks/users.json b/spec/mocks/base/users.json similarity index 100% rename from spec/mocks/users.json rename to spec/mocks/base/users.json diff --git a/spec/mocks/users_enabled/posts.json b/spec/mocks/users_enabled/posts.json new file mode 100644 index 0000000..697dab4 --- /dev/null +++ b/spec/mocks/users_enabled/posts.json @@ -0,0 +1,22 @@ +[ + { + "id": 1, + "name": "Jhon Snow", + "user": 1 + }, + { + "id": 2, + "name": "Igritte", + "user": 1 + }, + { + "id": 3, + "name": "Tyrion Lannister", + "user": 2 + }, + { + "id": 4, + "name": "Brienne of Tarth", + "user": 2 + } +] \ No newline at end of file diff --git a/spec/user.test.js b/spec/user.test.js new file mode 100644 index 0000000..872736a --- /dev/null +++ b/spec/user.test.js @@ -0,0 +1,163 @@ +(function () { + 'use strict'; + + const chai = require('chai'); + const expect = chai.expect; + const mockery = require('mockery'); + const path = require('path'); + const request = require('supertest'); + let userMiddleware; + let app; + let memory; + + describe('When provided the -u flag', () => { + before(() => { + mockery.enable({ + warnOnReplace: true, + warnOnUnregistered: false, + useCleanCache: true, + }); + }); + + + after(() => { + mockery.disable(); + }); + + describe('the user middleware function', () => { + describe('when using default header field', () => { + beforeEach(() => { + mockery.resetCache(); + const configMock = { + user: true, + definitionFile: path.join(__dirname, './config/base.json'), + }; + mockery.registerMock('./config', configMock); + userMiddleware = require('../src/lib/user').userMiddleware; + }); + afterEach(() => { + mockery.deregisterMock('./config'); + }); + it('should add user property to req', (done) => { + const req = { + headers: { + 'x-userid': 'myId', + }, + }; + + userMiddleware(req, {}, () => '') + .then(() => { + expect(req.user).to.equal('myId'); + done(); + }); + }); + }); + + describe('when provided a custom header field', () => { + beforeEach(() => { + mockery.resetCache(); + const configMock = { + user: true, + definitionFile: path.join(__dirname, './config/users_enabled.json'), + }; + mockery.registerMock('./config', configMock); + userMiddleware = require('../src/lib/user').userMiddleware; + }); + afterEach(() => { + mockery.deregisterMock('./config'); + }); + it('should add user property to req', (done) => { + const req = { + headers: { + 'x-randomfield': 'myId', + }, + }; + + userMiddleware(req, {}, () => '') + .then(() => { + expect(req.user).to.equal('myId'); + done(); + }) + .catch(e => { + done(e); + }); + }); + }); + }); + + describe('the apis', () => { + beforeEach((done) => { + mockery.resetCache(); + const configMock = { + user: true, + definitionFile: path.join(__dirname, './config/users_enabled.json'), + mockDir: path.join(__dirname, './mocks/users_enabled/'), + }; + mockery.registerMock('./config', configMock); + app = require('../src/server'); + memory = require('../src/lib/in_memory'); + memory.setup() + .then(() => { + done(); + }); + }); + + afterEach(() => { + mockery.deregisterMock('./config'); + }); + + describe('when GET', () => { + it('should filter data based on userid', (done) => { + request(app) + .get('/api/posts') + .set({'x-randomField': '1'}) + .end((err, res) => { + expect(res.status).to.equal(200); + expect(res.body.length).to.equal(2); + done(); + }); + }); + + describe('a single entry', () => { + it('should not read entries that belongs to other users', (done) => { + request(app) + .get('/api/posts/3') + .set({'x-randomField': '1'}) + .end((err, res) => { + expect(res.status).to.equal(403); + expect(res.body.error).to.equal('This is not your stuff! Keep your hands down!'); + done(); + }); + }); + }); + }); + + describe('when POST', () => { + it('should add user field to req.body', (done) => { + request(app) + .post('/api/posts') + .send({title: 'randomTitle'}) + .set({'x-randomField': '1'}) + .end((err, res) => { + expect(res.status).to.equal(200); + expect(res.body.user).to.equal(1); + done(); + }); + }); + }); + + describe('when DELETE', () => { + it('should prevent deleting other users entries', (done) => { + request(app) + .delete('/api/posts/3') + .set({'x-randomField': '1'}) + .end((err, res) => { + expect(res.status).to.equal(403); + expect(res.body.error).to.equal('This is not your stuff! Keep your hands down!'); + done(); + }); + }); + }); + }); + }); +})(); diff --git a/src/lib/api.js b/src/lib/api.js index b724c2d..1da4456 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -9,6 +9,10 @@ const config = require('./config'); const memoryStorage = require('./in_memory').memoryStorage; + const belongToUser = require('./user.js').belongToUser; + + // ERROR MESSAGES + const doesNotBelongToUser = 'This is not your stuff! Keep your hands down!'; const buildRest = (apiDefinitions) => { for (const endpoint of apiDefinitions) { @@ -17,11 +21,13 @@ switch (method) { case 'GET': router.get(`/${endpoint.base}${endpoint.url}`, (req, res) => { + // filtering on query params if (Object.keys(req.query).length > 0) { // django patch delete req.query.no_hyperlinks; // convert param number in numbers + // NOTE this will conflict with a query string based search for (const p in req.query) { if (!isNaN(req.query[p])) { req.query[p] = parseInt(req.query[p], 10); @@ -50,7 +56,7 @@ // NOTE Should we handle delete for a full collection? break; default: - throw new Error(`Query Method ${method} Not Handled!`); + throw new Error(`Method ${method} Not Handled!`); } if (endpoint.param) { @@ -58,8 +64,11 @@ switch (method) { case 'GET': router.get(`/${endpoint.base}${endpoint.url}/:${endpoint.param}`, (req, res) => { - const filter = {}; - filter[endpoint.param] = req.params[endpoint.param]; + + // checking ownership + if (!belongToUser(req, endpoint, memoryStorage[endpoint.url])) { + return res.status(403).send({error: doesNotBelongToUser}); + } return res.send(_.find( memoryStorage[endpoint.url], @@ -88,6 +97,12 @@ break; case 'DELETE': router.delete(`/${endpoint.base}${endpoint.url}/:${endpoint.param}`, (req, res) => { + + // checking ownership + if (!belongToUser(req, endpoint, memoryStorage[endpoint.url])) { + return res.status(403).send({error: doesNotBelongToUser}); + } + _.remove( memoryStorage[endpoint.url], el => el[endpoint.param] == req.params[endpoint.param] @@ -105,7 +120,7 @@ fs.readFileAsync(config.definitionFile) .then((file) => { - buildRest(JSON.parse(file)); + buildRest(JSON.parse(file).endpoints); }) .catch((e) => { throw new Error(e); diff --git a/src/lib/config.js b/src/lib/config.js index 08b204e..9448bbf 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -1,8 +1,6 @@ (function () { 'use strict'; - const args = require('optimist').argv; - const path = require('path'); if (process.env.NODE_ENV !== 'test') { if (!args.c) { @@ -13,16 +11,13 @@ throw new Error('Specifing a mock directory is MANDATORY! Use -d flag.'); } } - else { - args.c = path.join(__dirname, '../../spec/config/base.json'); - args.d = path.join(__dirname, '../../spec/mocks/'); - args.p = 4001; - } - module.exports = { + const config = { port: args.p || 4000, definitionFile: args.c, mockDir: args.d, user: args.u || false, }; + + module.exports = config; })(); diff --git a/src/lib/in_memory.js b/src/lib/in_memory.js index ff602d8..40bc516 100644 --- a/src/lib/in_memory.js +++ b/src/lib/in_memory.js @@ -33,7 +33,7 @@ const setup = P.promisify((done) => { fs.readFileAsync(config.definitionFile) .then((file) => { - buildStorage(JSON.parse(file)); + buildStorage(JSON.parse(file).endpoints); loadBaseData(config.mockDir) .then(() => { done(); diff --git a/src/lib/user.js b/src/lib/user.js new file mode 100644 index 0000000..55dabb6 --- /dev/null +++ b/src/lib/user.js @@ -0,0 +1,78 @@ +(function () { + 'use strict'; + + const config = require('./config'); + const fs = require('fs'); + const P = require('bluebird'); + P.promisifyAll(fs); + const _ = require('lodash'); + + let defaultField; + + const userMiddleware = (req, res, next) => + fs.readFileAsync(config.definitionFile) + .then((file) => { + + if (config.user) { + const auth = JSON.parse(file).auth; + + // get the header field + defaultField = 'x-userid'; + if (auth && auth.headerField) { + defaultField = auth.headerField.toLowerCase(); + } + + if (req.headers[defaultField]) { + req.user = req.headers[defaultField]; + switch (req.method) { + // attach userId to req.query for filtering + case 'GET': + if (!req.query) { + req.query = {}; + } + req.query.user = req.headers[defaultField]; + break; + // attach userId to req.body for persistance + case 'POST': + req.body.user = parseInt(req.headers[defaultField], 10); + break; + default: + break; + } + } + else { + return next(new Error('User not found')); + } + } + return next(); + }) + .catch(e => { + console.log(e); + next(e); + }); + + /** + * Check if a requested model belongs to the active user + * @param req An express request + * @param endpoint The endpoint definition from the config file + * @param collection The collection to be checked + */ + + const belongToUser = (req, endpoint, collection) => { + + // if users are not enabled, skip the checks + if (!config.user) { + return true; + } + + const userId = req.headers[defaultField]; + const modelId = req.params[endpoint.param]; + + const model = _.find(collection, (item) => item[endpoint.param] == modelId); + + return model.user === userId; + }; + + exports.userMiddleware = userMiddleware; + exports.belongToUser = belongToUser; +})(); diff --git a/src/server.js b/src/server.js index 3668c59..670a44a 100644 --- a/src/server.js +++ b/src/server.js @@ -9,11 +9,13 @@ // CONFIG const port = require('./lib/config').port; - const enableUser = require('./lib/config').user; // IN MEMORY STORAGE const memory = require('./lib/in_memory'); + // MIDDLEWARES + const userMiddleware = require('./lib/user').userMiddleware; + // ROUTES const apiRoutes = require('./lib/api'); @@ -21,24 +23,14 @@ app.use(bodyParser.json()); // attach user info to req - app.use((req, res, next) => { - if (enableUser) { - if (req.headers['x-userid']) { - req.user = req.headers['x-userid']; - } - else { - return next(new Error('User not found')); - } - } - return next(); - }); + app.use(userMiddleware); app.use('/', apiRoutes); // ERROR HANDLING app.use((err, req, res, next) => { if (process.env.NODE_ENV === 'test') { - console.log(err); + console.log(err, err.stack); } res.status(404).send({error: err}); });