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:
+
+ - at least one digit
+ - at least one lower case letter
+ - at least one upper case letter
+ - at least one of the following special characters: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
+ - no whitespace
+ - at least 8 characters
+
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"