diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/dictionaries/VLE2FE.xml b/.idea/dictionaries/VLE2FE.xml new file mode 100644 index 0000000..c274b8b --- /dev/null +++ b/.idea/dictionaries/VLE2FE.xml @@ -0,0 +1,9 @@ + + + + bcrypt + cfenv + dfopdb + + + \ No newline at end of file diff --git a/oas/oas.yaml b/oas/oas.yaml index 81e06bf..ba1bafd 100644 --- a/oas/oas.yaml +++ b/oas/oas.yaml @@ -15,6 +15,15 @@ info:
  • dev: handling machine learning models
  • admin: user administration
  • + Password policy: + diff --git a/oas/schemas.yaml b/oas/schemas.yaml index 21ebae4..82c9508 100644 --- a/oas/schemas.yaml +++ b/oas/schemas.yaml @@ -137,8 +137,6 @@ Template: type: object Email: - required: - - email properties: email: type: string @@ -151,14 +149,16 @@ User: name: type: string example: johndoe - levels: - type: array - items: - type: string - example: read + pass: + type: string + writeOnly: true + example: Abc123!# + level: + type: string + example: read location: type: string example: Rng device_name: type: string - example: Alpha II \ No newline at end of file + example: Alpha II diff --git a/oas/user.yaml b/oas/user.yaml index c9f10b1..f8434f5 100644 --- a/oas/user.yaml +++ b/oas/user.yaml @@ -130,16 +130,22 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/User' + required: + - email + - name + - pass + - level + - location + - device_name + allOf: + - $ref: 'oas.yaml#/components/schemas/User' responses: 200: description: user details content: application/json: schema: - type: array - items: - $ref: 'oas.yaml#/components/schemas/User' + $ref: 'oas.yaml#/components/schemas/User' 400: $ref: 'oas.yaml#/components/responses/400' 401: diff --git a/package-lock.json b/package-lock.json index ae887b0..956d6f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,49 @@ "js-tokens": "^4.0.0" } }, + "@hapi/address": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.0.1.tgz", + "integrity": "sha512-0oEP5UiyV4f3d6cBL8F3Z5S7iWSX39Knnl0lY8i+6gfmmIBj44JCBNtcMgwyS+5v7j3VYavNay0NFHDS+UGQcw==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@hapi/formula": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz", + "integrity": "sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==" + }, + "@hapi/hoek": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.4.tgz", + "integrity": "sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw==" + }, + "@hapi/joi": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-17.1.1.tgz", + "integrity": "sha512-p4DKeZAoeZW4g3u7ZeRo+vCDuSDgSvtsB/NpfjXEHTUjSeINAi/RrVOWiVQ1isaoLzMvFEhe8n5065mQq1AdQg==", + "requires": { + "@hapi/address": "^4.0.1", + "@hapi/formula": "^2.0.0", + "@hapi/hoek": "^9.0.0", + "@hapi/pinpoint": "^2.0.0", + "@hapi/topo": "^5.0.0" + } + }, + "@hapi/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw==" + }, + "@hapi/topo": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz", + "integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, "@jsdevtools/ono": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.2.tgz", @@ -165,6 +208,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, + "bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" + }, "binary-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", diff --git a/package.json b/package.json index 5c89ff2..234bb65 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,11 @@ "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "^8.0.0", + "@hapi/joi": "^17.1.1", "@types/mocha": "^5.2.7", "@types/node": "^13.1.6", + "bcryptjs": "^2.4.3", + "body-parser": "^1.19.0", "cfenv": "^1.2.2", "express": "^4.17.1", "json-schema": "^0.2.5", diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..1ac4222 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,90 @@ +import mongoose from 'mongoose'; +import cfenv from 'cfenv'; + +// database urls, prod db url is retrieved automatically +const TESTING_URL = 'mongodb://localhost/dfopdb_test'; +const DEV_URL = 'mongodb://localhost/dfopdb'; + +export default class db { + private static state = { // db object and current mode (test, dev, prod) + db: null, + mode: null, + }; + + static connect (mode = '', done: Function = () => {}) { // set mode to test for unit/integration tests, otherwise skip parameter. done is also only needed for testing + if (this.state.db) return done(); // db is already connected + + // find right connection url + let connectionString: string = ""; + if (mode === 'test') { // testing + connectionString = TESTING_URL; + this.state.mode = 'test'; + } + else if(process.env.NODE_ENV === 'production') { + let services = cfenv.getAppEnv().getServices(); + for (let service in services) { + if(services[service].tags.indexOf("mongodb") >= 0) { + connectionString = services[service]["credentials"].uri; + } + } + this.state.mode = 'prod'; + } + else { + connectionString = DEV_URL; + this.state.mode = 'dev'; + } + + // connect to db + mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true}, err => { + if (err) done(err); + }); + mongoose.connection.on('error', console.error.bind(console, 'connection error:')); + mongoose.connection.once('open', () => { + console.log(`Connected to ${connectionString}`); + this.state.db = mongoose.connection; + done(); + }); + } + + static getState () { + return this.state; + } + + static drop (done: Function = () => {}) { // drop all collections of connected db (only dev and test for safety reasons ;) + if (!this.state.db || this.state.mode === 'prod') return done(); // no db connection or prod db + this.state.db.db.listCollections().toArray((err, collections) => { // get list of all collections + if (collections.length === 0) { // there are no collections to drop + return done(); + } + else { + let dropCounter = 0; // count number of dropped collections to know when to return done() + collections.forEach(collection => { // drop each collection + this.state.db.dropCollection(collection.name, () => { + console.log('dropped collection ' + collection.name); + if (++ dropCounter >= collections.length) { // all collections dropped + done(); + } + }); + }); + } + }); + } + + static loadJson (json, done: Function = () => {}) { // insert given JSON data into db, uses core mongodb methods + if (!this.state.db || !json.hasOwnProperty('collections') || json.collections.length === 0) { + return done(); + } // no db connection or nothing to load + + let loadCounter = 0; // count number of loaded collections to know when to return done() + Object.keys(json.collections).forEach(collectionName => { // create each collection + this.state.db.createCollection(collectionName, (err, collection) => { + collection.insertMany(json.collections[collectionName], () => { // insert JSON data + console.log('loaded collection ' + collectionName); + if (++ loadCounter >= Object.keys(json.collections).length) { // all collections loaded + done(); + } + }); + }); + }); + } +}; \ No newline at end of file diff --git a/src/globals.ts b/src/globals.ts new file mode 100644 index 0000000..e6db442 --- /dev/null +++ b/src/globals.ts @@ -0,0 +1,11 @@ +const globals = { + levels: [ + 'read', + 'write', + 'maintain', + 'dev', + 'admin' + ] +}; + +export default globals; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 09fb57f..8c4af39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,37 +1,16 @@ -import cfenv from 'cfenv'; import express from 'express'; -import mongoose from 'mongoose'; +import bodyParser from 'body-parser'; import swagger from 'swagger-ui-express'; import jsonRefParser, {JSONSchema} from '@apidevtools/json-schema-ref-parser'; +import db from './db'; // tell if server is running in debug or production environment console.log(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : '===== DEVELOPMENT ====='); -// get mongodb address from server, otherwise set to localhost -let connectionString: string = ""; -if(process.env.NODE_ENV === 'production') { - let services = cfenv.getAppEnv().getServices(); - for (let service in services) { - if(services[service].tags.indexOf("mongodb") >= 0) { - connectionString = services[service]["credentials"].uri; - } - } -} -else { - connectionString = 'mongodb://localhost/dfopdb'; -} -mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true}); - -// connect to mongodb -let db = mongoose.connection; -db.on('error', console.error.bind(console, 'connection error:')); -db.once('open', () => { - console.log(`Connected to ${connectionString}`); -}); - - +// mongodb connection +db.connect(); // create Express app const app = express(); @@ -40,8 +19,17 @@ app.disable('x-powered-by'); // get port from environment, defaults to 3000 const port = process.env.PORT || 3000; +//middleware +app.use(express.json({ limit: '5mb'})); +app.use(express.urlencoded({ extended: false, limit: '5mb' })); +app.use(bodyParser.json()); +app.use((err, req, res, ignore) => { // bodyParser error handling + res.status(400).send({status: 'Invalid JSON body'}); +}); + // require routes app.use('/', require('./routes/root')); +app.use('/', require('./routes/user')); // Swagger UI let oasDoc: JSONSchema = {}; @@ -53,7 +41,19 @@ jsonRefParser.bundle('oas/oas.yaml', (err, doc) => { }); app.use('/api', swagger.serve, swagger.setup(oasDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'})); -// hook up server to port -app.listen(port, () => { - console.log(`Listening on http;//localhost:${port}`); +app.use((req, res) => { // 404 error handling + res.status(404).json({status: 'Not found'}); }); + +app.use((err, req, res, ignore) => { // internal server error handling + console.error(err); + res.status(500).json({status: 'Internal server error'}); +}); + + +// hook up server to port +const server = app.listen(port, () => { + console.log(`Listening on http://localhost:${port}`); +}); + +module.exports = server; \ No newline at end of file diff --git a/src/models/user.ts b/src/models/user.ts new file mode 100644 index 0000000..b72dabb --- /dev/null +++ b/src/models/user.ts @@ -0,0 +1,13 @@ +import mongoose from 'mongoose'; + +const UserSchema = new mongoose.Schema({ + name: String, + email: String, + pass: String, + key: String, + level: String, + location: String, + device_name: String +}); + +export default mongoose.model('user', UserSchema); \ No newline at end of file diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index cfec79c..0e2d625 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -1,19 +1,58 @@ import supertest from 'supertest'; import should from 'should/as-function'; +import db from '../db'; -let server = supertest.agent('http://localhost:3000'); +describe('/', () => { + let server; -describe('Testing /', () => { - it('returns the message object', done => { - server + before(done => { + process.env.port = '2999'; + db.connect('test', done); + }); + beforeEach(done => { + delete require.cache[require.resolve('../index')]; // prevent loading from cache + server = require('../index'); + db.drop(err => { // reset database + if (err) return done(err); + db.loadJson(require('../test/db.json'), done); + }); + }); + afterEach(done => { + server.close(done); + }); + it('returns the root message', done => { + supertest(server) .get('/') .expect('Content-type', /json/) - .expect(200) - .end(function(err, res) { - should(res.statusCode).equal(200); + .expect(200, (err, res) => { should(res.body).be.eql({message: 'API server up and running!'}); done(); }); }); }); + +describe('Testing unknown routes', () => { + let server; + + before(done => { + db.connect('test', done); + }); + beforeEach(done => { + delete require.cache[require.resolve('../index')]; // prevent loading from cache + server = require('../index'); + db.drop(err => { // reset database + if (err) return done(err); + db.loadJson(require('../test/db.json'), done); + }); + }); + afterEach(done => { + server.close(done); + }); + it('returns a 404 message', done => { + supertest(server) + .get('/unknownroute') + .expect(404); + done(); + }); +}); \ No newline at end of file diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts new file mode 100644 index 0000000..fe5c031 --- /dev/null +++ b/src/routes/user.spec.ts @@ -0,0 +1,75 @@ +import supertest from 'supertest'; +import should from 'should/as-function'; +import db from '../db'; +import userModel from '../models/user'; + + +describe('/user/new', () => { + let server; + + before(done => { + process.env.port = '2999'; + db.connect('test', done); + }); + beforeEach(done => { + delete require.cache[require.resolve('../index')]; // prevent loading from cache + server = require('../index'); + db.drop(err => { // reset database + if (err) return done(err); + db.loadJson(require('../test/db.json'), done); + }); + }); + afterEach(done => { + server.close(done); + }); + it('returns the added user data', done => { + supertest(server) + .post('/user/new') + .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) + .expect('Content-type', /json/) + .expect(200, (err, res) => { + if (err) return done(err); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'john.doe@bosch.com'); + should(res.body).have.property('name', 'johndoe'); + should(res.body).have.property('level', 'read'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha II'); + done(); + }); + }); + it('stores the data', done => { + supertest(server) + .post('/user/new') + .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) + .expect(200, err => { + if (err) return done(err); + userModel.find({name: 'johndoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'pass', 'email', 'level', 'location', 'device_name', 'key', '__v'); + should(data[0]).have.property('_id'); + should(data[0]).have.property('name', 'johndoe'); + should(data[0]).have.property('email', 'john.doe@bosch.com'); + should(data[0]).have.property('pass').not.eql('Abc123!#'); + should(data[0]).have.property('level', 'read'); + should(data[0]).have.property('location', 'Rng'); + should(data[0]).have.property('device_name', 'Alpha II'); + done(); + }); + }); + }); + it('rejects a username already in use', done => { + supertest(server) + .post('/user/new') + .send({email: 'j.doe@bosch.com', name: 'janedoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) + .expect(400, err => { + if (err) return done(err); + userModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + done(); + }); + }); + }); // TODO: authentication +}); \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts new file mode 100644 index 0000000..ea2994f --- /dev/null +++ b/src/routes/user.ts @@ -0,0 +1,31 @@ +import express from 'express'; +import mongoose from 'mongoose'; +import bcrypt from 'bcryptjs'; +import UserValidate from './validate/user'; +import UserModel from '../models/user'; + +const router = express.Router(); + +router.get('/users', (req, res) => { + res.json({message: 'users up and running!'}); +}); + +router.post('/user/new', (req, res, next) => { + // validate input + const {error, value: user} = UserValidate.input(req.body); + if(error !== undefined) { + res.status(400).json({status: 'Invalid body format'}); + return; + } + + user.key = mongoose.Types.ObjectId(); // use object id as unique API key + bcrypt.hash(user.pass, 10, (err, hash) => { // password hashing + user.pass = hash; + new UserModel(user).save((err, data) => { // store user + if (err) next(err); + res.json(UserValidate.output(data.toObject())); + }); + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts new file mode 100644 index 0000000..8f658f3 --- /dev/null +++ b/src/routes/validate/user.ts @@ -0,0 +1,44 @@ +import joi from '@hapi/joi'; +import globals from "../../globals"; + +export default class UserValidate { // validate input for user + static input (data) { + return joi.object({ + name: joi.string() + .alphanum() + .lowercase() + .required(), + + email: joi.string() + .email({minDomainSegments: 2}) + .lowercase() + .required(), + + pass: joi.string() + .pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#$%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$')) + .required(), + + level: joi.string() + .valid(...globals.levels) + .required(), + + location: joi.string() + .alphanum() + .required(), + + device_name: joi.string() + .required() + }).validate(data); + } + + static output (data) { // validate output from database for needed properties, strip everything else + return joi.object({ + _id: joi.any(), + name: joi.string(), + email: joi.string(), + level: joi.string(), + location: joi.string(), + device_name: joi.string() + }).validate(data, {stripUnknown: true}).value; + } +} diff --git a/src/test/db.json b/src/test/db.json new file mode 100644 index 0000000..ba55d6f --- /dev/null +++ b/src/test/db.json @@ -0,0 +1,17 @@ +{ + "collections": { + "users": [ + { + "_id": "5ea0450ed851c30a90e70894", + "email": "jane.doe@bosch.com", + "name": "janedoe", + "pass": "$2a$10$KDKZjCsgDXwhtKdXZ9oG2ueDuCZsRKOMSqHuBfCM/2R0V6DRns.sy", + "level": "write", + "location": "Rng", + "device_name": "Alpha I", + "key": "5ea0450ed851c30a90e70899", + "__v": 0 + } + ] + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c49a622..304952d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,13 +4,15 @@ "target": "es5", "outDir": "dist", "sourceMap": true, - "esModuleInterop": true + "esModuleInterop": true, + "resolveJsonModule": true }, "files": [ "./node_modules/@types/node/index.d.ts" ], "include": [ - "src/**/*.ts" + "src/**/*.ts", + "src/**/*.json" ], "exclude": [ "node_modules"